Android修炼系列(二),Class类加载过程与类加载器
在说类加载器和双亲委派模型之前,我们先来梳理下Class类文件的加载过程,JAVA虚拟机为了保证 实现语言的无关性,是将虚拟机只与“Class 文件”字节码 这种特定形式的二进制文件格式 相关联,而不是与实现语言绑定。
类加载过程
Class类从被加载到虚拟机内存开始,到卸载出内存为止,其生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)7个阶段。其中加载过程见下:
加载阶段
加载阶段做了什么?过程见下图。其中类的全限定名是Class文件(JAVA由编译器自动生成)内的代表常量池内的16进制值所代表的特定符号引用。因为Class文件格式有其自己的一套规范,如第1-4字节代表魔数,第5-6字节代表次版本,第7-8字节代表主版本号等等。
说白了就是,虚拟机不关心我们的这种“特定二进制流”从哪里来的,从本地加载也好,从网上下载的也罢,都没关系。虚拟机要做的就是将该二进制流写在自己的内存中并生成相应的Class对象(并不是在堆中)。在这个阶段,我们能够通过我们自定义类加载器来控制二进制流的获取方式。
验证阶段
验证阶段,正因为加载阶段虚拟机不介意二进制的来源,所以就可能存在着影响虚拟机正常运行的安全隐患。所以虚拟机对于该二进制流的校验工作非常重要。校验方式包括但不限于:
准备阶段
准备阶段在此阶段将正式为类变量分配内存并设置变量的初始化值。注意的是,类变量是指 static 的静态变量,是分配在方法区之中的,而不像对象变量,分配在堆中。还有一点需要注意,final 常量在此阶段就已经被赋值了。如下:
public static int SIZE = 10; // 初始化值 == 0
public static final int SIZE = 10; // 初始化值 == 10
复制代码
解析阶段
解析阶段是将常量池内的符号引用替换为直接引用的过程。符号引用就是上文说的Class文件格式标准所规定的特定字面量,而直接引用就是我们说的指针,内存引用等概念
初始化阶段
到了初始化阶段,就开始真正执行我们的字节码程序了。也可以理解成:类初始化阶段就是虚拟机内部执行类构造 < clinit >() 方法的过程。注意,这个类构造方法可不是虚拟机内部生成的,而是我们的编译器自动生成的,是编译器自动收集类中的所有类变量的 赋值动作 和静态语句块(static{}块)中的语句合并产生的,具体分析见下。
注意,这里说的是类变量赋值动作,即static 并且具有赋值操作,如果无赋值操作,那么在准备阶段进行的方法区初始化就算完成了。为何还要加上static{} 呢?我们可以把static{} 理解成:是由多个静态初始化动作组织成的一个特殊的“静态子句”,与其他的静态初始化动作一样。这也是为何 static {} 只会执行一遍并在对象构造方法之前执行的原因。如下代码:
public class Tested {
public static int T;
// public static int V; // 无赋值,不在类构造中再次初始化
public int c = 1; // 不会在类构造中
static {
T = 10;
}
}
复制代码
还有一点,编辑器收集类变量的顺序,也就是虚拟机在此初始化阶段的执行顺序,这个顺序就是变量在类中语句定义的先后顺序,如上面的:语句 2 : T 在 6 : T 之前,这是两个独立的语句。类构造< clinit >的其他特点如下:
编译期的< clinit >
我们将流程回溯到编译期阶段,以刚刚的Tested 类代码为例。通过 javap -c /Tested.class (注意:/../Tested 绝对路径),获取Class文件:
public class com.tencent.lo.Tested {
public static int T;
public int c;
public com.tencent.lo.Tested();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_1
6: putfield #2 // Field c:I
9: return
static {};
Code:
0: bipush 10
2: putstatic #3 // Field T:I
5: return
}
复制代码
在Class 文件中我们能很明显的看到 invokespecial 对应的对象构造 "< init >" : () V ,那为什么没有看到< clinit > 类构造方法呢?其实上面的 static {} 就是。我们来看下OpenJDK源码的 Constants接口,此接口定义了在编译器中所用到的常量,这是一个自动生成的类。
public interface Constants extends RuntimeConstants {
public static final boolean tracing = true;
Identifier idClassInit = Identifier.lookup("<clinit>");
Identifier idInit = Identifier.lookup("<init>");
}
复制代码
在MemberDefinition类 中,判断是否为类构造器字符:
public final boolean isInitializer() {
return getName().equals(idClassInit); // 类构造
}
public final boolean isConstructor() {
return getName().equals(idInit); // 对象构造
}
复制代码
而在MemberDefinition 的 toString() 方法中,我们能够看到,当类构造时,会输出特定字符,而不会像对象构造那样输出规范的字符串。
public String toString() {
Identifier name = getClassDefinition().getName();
if (isInitializer()) { // 类构造
return isStatic() ? "static {}" : "instance {}";
} else if (isConstructor()) { // 对象构造
StringBuffer buf = new StringBuffer();
buf.append(name);
buf.append('(');
Type argTypes[] = getType().getArgumentTypes();
for (int i = 0 ; i < argTypes.length ; i++) {
if (i > 0) {
buf.append(',');
}
buf.append(argTypes[i].toString());
}
buf.append(')');
return buf.toString();
} else if (isInnerClass()) {
return getInnerClass().toString();
}
return type.typeString(getName().toString());
}
复制代码
类加载器
“虚拟机将类加载阶段中的“通过一个全限定名来获取描述此类的二进制字节流”这个动作放到了外部来实现,以便开发者可以自己决定如何获取所需的类文件,而实现这个动作的代码模块就被称为类加载器。对于任意一个类来说,只有在类加载器相同的情况下比较两者是否相同才有意义,否则即使是同个文件,在不同加载器下,在虚拟机看来其仍然是不同的,是两个独立的类。我们可以将类加载器分为三类”:
双亲委派
而所谓的双亲委派模型就是:“如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把加载的操作委托给父类加载器去完成,每一层次加载器都是如此,因此所有的加载请求都会传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时(它的搜索范围没有找到所需的类,因为上面所说的启动类加载器和扩展类加载器,只能加载特定目录之下的,或被-x参数所指定的类库),子类才会尝试自己加载”。注意这里说的父类只是形容层次结构,其并不是直接继承关系,而是通过组合方式来复用父类的加载器的。
“双亲委派的好处就是,使加载器也具备了优先级的层次结构。例如,java.lang.Object存放在< JAVA_HOME>\lib 下的rt.jar包内,无论哪个类加载器要加载这个类,最终都会委派给最顶层的启动类加载器,所以保证了Object类在各类加载器环境中都是同一个类。相反,如果没有双亲委派模型,如果用户编写了一个java.lang.Object类,并放在程序的ClassPath下,那么系统将会出现多个不同的Object类”。
为何?因为每个加载器各自为政,不会委托给父构造器,如上面所说,只要加载器不同,即使类Class文件相同,其也是独立的。
试想如果自己在项目中编写了一个java.lang.Object 类(当然不能放入rt.jar类库中替换掉同名Object文件,这样做没有意义,如果虚拟机加载校验能通过的话,只是相当于改了源码嘛),我们通过自定义的构造器来加载这个类可以吗?理论上来说,虽然这两个类都是java.lang.Object,但由于构造器不同,对于虚拟机来说这是不同的Class文件,当然可以。但是实际上呢?来段代码见下:
public void loadPathName(String classPath) throws ClassNotFoundException {
new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
InputStream is = getClass().getResourceAsStream(name);
if (is == null)
return super.loadClass(name);
byte[] b;
try {
b = new byte[is.available()];
is.read(b);
} catch (Exception e) {
return super.loadClass(name);
}
return defineClass(name, b, 0, b.length);
}
}.loadClass(classPath);
}
复制代码
实际的执行逻辑是 defineClass 方法。可以发现,自定义加载器是无法加载以 java. 开头的系统类的。
protected final Class<?> defineClass(String name, byte[] b, int off, int len,
ProtectionDomain protectionDomain)
throws ClassFormatError {
protectionDomain = preDefineClass(name, protectionDomain);
... // 略
return c;
}
private ProtectionDomain preDefineClass(String name, ProtectionDomain pd) {
if (!checkName(name))
throw new NoClassDefFoundError("IllegalName: " + name);
// 在这里能看到系统类,自定义的加载器是不能加载的
if ((name != null) && name.startsWith("java.")) {
throw new SecurityException
("Prohibited package name: " +
name.substring(0, name.lastIndexOf('.')));
}
... // 略
return pd;
}
复制代码
如果你用AS直接查看,你会发现,defineClass 内部是没有具体实现的,源码见下。可这并不代表android 的 defineClass 方法实现与 java 不同,因为都是引用的 java.lang 包下的ClassLoader 类,逻辑肯定都是一样的。之所以看到的源码不一样,这是由于SDK和JAVA源码包的区别导致的。SDK内的源码是谷歌提供给我们方便开发查看的,并不完全等同于源码。
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
throws ClassFormatError
{
throw new UnsupportedOperationException("can't load this type of class file");
}
复制代码
好了,本文到这里就结束了,关于类加载过程的讲解也应该够用了。如果本文对你有用,来点个赞吧,大家的肯定也是阿呆i坚持写作的动力。
参考 1、周志明,深入理解JAVA虚拟机:机械工业出版社