一个完整的类加载过程必须经历加载、连接和初始化3个步骤。如下图:
- 类加载阶段
由类加载器负责根据一个类的全限定名来读取此类的二进制字节流到JVM内部,并存储在运行时内存区中的方法区内,然后将其转换成一个与目标类型对应的java.lang.Class对象实例(Java虚拟机规范并没有明确要求一定要存储在Java堆区中,因此HotSpot VM选择将Class对象存储在方法区内),这个Class对象在日后就会作为方法区中该类的各种数据的访问入口。
- 类链接阶段
要做的事情就是将已经加载到JVM中的二进制字节流的类数据信息合并到JVM的运行时状态中,然而连接阶段则由验证、准备和解析3个阶段构成。
验证阶段:就是验证类数据信息是否符合JVM规范,是否是一个有效的字节码文件,而验证的内容则涵盖了类数据信息的格式验证、语义分析、操作验证等;
准备阶段:就是为类中的所有静态变量分配内存空间,并为其设置一个初始值(由于还没有产生对象,因此实例变量将不再此操作范围内);
解析阶段:就是将常量池中所有的符号引用全部转换为直接引用,不过Java虚拟机规范并没有明确要求解析阶段一定要按照顺序执行,因此解析阶段可以等到初始化之后再执行。
- 类初始化阶段
如果执行的是静态变量,那么就会使用用户指定的值覆盖掉之前在准备阶段中JVM为其设置的初始值,当然如果程序中没有为静态变量显式指定赋值操作,那么所持有的值仍然是之前的初始值;反之如果执行的是static代码块,那么在初始化阶段中,JVM就将会执行static代码块中定义的所有操作。
Java虚拟机规范在类加载和连接的时机上提供了较大的灵活性,但Java虚拟机规范却明确规定了类的初始化时机,也就是说,一个类或者接口应该在首次主动使用时执行初始化操作,如下所示:
- 为一个类型创建一个新的对象实例时(比如使用new关键字、反射或序列化);
- 调用一个类型的静态方法时(即在字节码中执行invokestatic指令);
-
调用一个类型或接口的静态字段,或者对这些静态字段执行赋值操作时(即在字节码中,执行getstatic指令或putstatic指令),不过用final关键字修饰的静态字段除外,它被初始化为一个编译时的常量表达式;
-
调用Java API中的反射方法时(比如调用java.lang.Class中的方法,或者java.lang.reflect包中其他类的方法);
-
初始化一个类的派生类时(Java虚拟机规范明确要求初始化一个类时,它的超类必须提前完成初始化操作);
-
JVM启动包含main()方法的启动类时。
上述所列出的6种情况都属于主动使用的情形,而其他的情形则都不属于主动使用,因此它们都不会导致一个类型被执行初始化操作。在此大家需要注意,尽管一个类在初始化之前必须要求它的超类提前完成初始化操作,但对于接口而言,这条规则却显得并不适用。只有在某个接口中声明的非常量字段被使用时,该接口才会被初始化,而不会因为实现这个接口的派生接口或派生类要初始化而被初始化,也就是说,接口并不要求在执行初始化操作的时候,它的超类接口必须提前完成初始化操作。
根据一个简单的程序示例剖析类加载机制(代码A)
public class LoadingTest {
public static LoadingTest obj = new LoadingTest();
public static int value1;
public static int value2 = 0;
public LoadingTest() {
value1 = 10;
value2 = value1;
System.out.println("before value1->" + value1);
System.out.println("before value2->" + value2);
}
public static void main(String[] args) throws Exception {
System.out.println("after value1->" + value1);
System.out.println("after value2->" + value2);
}
}
程序输出如下:
before value1->10
before value2->10
after value1->10
after value2->0
上述代码示例中,如果大家不细心的话,肯定会认为在main()方法中打印的Loading类的静态变量value1和value2的值都为10,但实际上程序最终输出的结果却是value1等于10,而value2等于0。在Loading类的构造方法中,尽管已经将静态变量value1和value2都显式赋值为10了,并且在构造方法中所打印的结果也的确为10,那为什么会与main()方法中输出的结果不一致呢?如果将上述代码的位置稍作调整后,程序最终的输出结果是否又会产生变化呢?如下所示:
(代码B)
public class LoadingTest {
public static int value1;
public static int value2 = 0;
public static LoadingTest obj = new LoadingTest();
public LoadingTest() {
value1 = 10;
value2 = value1;
System.out.println("before value1->" + value1);
System.out.println("before value2->" + value2);
}
public static void main(String[] args) throws Exception {
System.out.println("after value1->" + value1);
System.out.println("after value2->" + value2);
}
}
程序输出如下:
before value1->10
before value2->10
after value1->10
after value2->10
当将声明静态变量obj的代码位置放在声明静态变量value2之后,程序最终的输出结果就跟预期的值一致了,这又是为什么呢?简单来说,当类加载器将Loading类加载进JVM内部后,会在方法区中生成一个与该类型对应的载器将Loading类加载进JVM内部后,会在方法区中生成一个与该类型对应的java.lang.Class对象实例,当进入到准备阶段时,JVM便会为Loading类中的3个静态变量分配内存空间,并为其设置初始值(value1和value2的初始值为0,而obj的初始值则为null)。当经历到类加载过程中的初始化阶段时,程序最终的输出结果就会和代码的执行顺序有关了。在代码A中,静态变量obj是优先初始化的,那么JVM将会执行到其构造方法中,并覆盖掉静态变量value1和value2之前持有的初始值,也就是说,初始化静态变量obj后,value1和value2所持有的值就都是10。接下来JVM会检查静态变量value1是否也需要执行初始化,由于value1并没有显式地指定进行赋值操作,因此将会直接跳转到静态变量value2上,这里就非常关键了,并且也是代码A示例中程序最终的输出结果与预期不一致的“罪魁祸首”。尽管之前指定了value2所持有的值为10,但当执行到value2=0时等于又重新对静态变量value2显式执行了一次赋值操作,也就是说,当前的赋值操作将会覆盖掉之前在构造方法中的赋值操作,这就是为什么value2的输出结果为0,而非为10的真正原因。然而在代码B示例中,笔者将代码的执行顺序进行了调换,尽管value2同样也经历过2次赋值操作,但最后一次被赋予的值却是10,所以程序最终的输出结果就跟预期一致。

