Java 类加载过程
本文部分参考《深入理解Java虚拟机》 -- 周志明 著
类加载过程概述
Java的类加载,就是把Class文件从磁盘(或其他任意地方,只要满足类文件规范)加载到虚拟机中,以供应用程序使用的过程。类加载是通过ClassLoader完成的。
类的生命周期主要包含以下几个阶段:
- 加载 Loading
- 验证 Verification
- 准备 Preparation
- 解析 Resolution
- 初始化 Initialization
- 使用 Using
- 卸载 Unloading
其中2/3/4这3个阶段也统称为连接(Linking)。通常这几个阶段都是按顺序进行的,但解析阶段在某些情况下有可能先于初始化,在后续会专门介绍。
Linking阶段一般由虚拟机实现,应用程序无法控制,而Loading和Initialization阶段可以由程序一定程度上控制。
类加载时机
JVM规范对初始化阶段的规定
JVM规范规定,以下几种情况,如果类还未进行过初始化,则必须立即对类进行初始化。也就是说加载、验证、准备阶段也一定会在这之前开始执行。
- 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时。对应到代码则是:使用new创建对象、读取或设置类的静态字段(static final字段除外)、调用类的静态方法
- 使用java.lang.reflect包对类进行反射调用
- 初始化一个类时,必须先初始化其父类
- 虚拟机启动时,优先初始化主类,即包含main()方法的类
- 使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄时,对应的类需要初始化
以上5种情况称之为对类的主动引用,JVM规范规定有且只有以上几种情况会触发初始化,至于其他被动引用的情况是否会初始化,需要看虚拟机的具体实现。
举几个被动引用的例子:
子类引用父类的静态字段
假设有如下父类和子类
public class SuperClass {
static {
System.out.println("SuperClass static init!");
}
public static int superStaticValue = 123;
}public class SubClass extends SuperClass {
static {
System.out.println("SubClass static init!");
}
public static int subStaticValue = 234;
public static final int subStaticFinalValue = 555;
}测试代码:
public static void main(String[] args) {
// 通过子类间接访问父类的静态变量,子类不会初始化
System.out.println(SubClass.superStaticValue);
}创建对象的引用数组
public static void main(String[] args) {
// 通过数组定义来引用类,不会触发类的初始化
SuperClass[] s = new SuperClass[10];
}这里实际上触发了对SuperClass[]这个数组类的初始化,这个类是由虚拟机自动生成的,对应字节码是anewarray,不属于必须初始化的情况之一。
引用static final字段
public static void main(String[] args) {
// 引用类的static final字段,不会触发初始化
System.out.println(SubClass.subStaticFinalValue);
}由于测试字段用了static final修饰,在编译时会进行常量传播优化,上述代码在字节码上对应为sipush 555,也就是直接把555这个常量值压入栈顶,并没有对SubClass进行引用,因此不会对SubClass进行初始化。
如果把上述SubClass.subStaticFinalValue的类型改为String,则会发现编译后"555"这个常量出现在了主类所在的常量池里,对该变量的访问直接转换成对常量池的引用。
对接口初始化的说明
接口中不能使用static{}语句块,但编译器仍会为接口生成<clinit>()类构造器。
类在初始化时会要求初始化其父类,但接口初始化时并不要求初始化父接口,只有在真正使用父接口时才会初始化。
加载阶段
加载阶段负责:
- 通过类的全限定名获取定义此类的二进制字节流
- 将字节流转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的访问入口
这里所说的二进制字节流,最常见的就是编译后的Class文件,但不限定于文件,也可以从网络获取、动态生成等。
对于非数组类来说,类的加载就是通过ClassLoader来完成的。通过重写ClassLoader的loadClass()或findClass()方法可以控制字节流的获取方式。
数组类则比较特殊,不是由ClassLoader创建的,而是由JVM直接创建。
验证阶段
验证阶段是对Class文件进行验证,确保是合法的Class文件且不会危害虚拟机自身的安全。如果验证发现Class文件格式不符合要求,则抛出java.lang.VerifyError
验证主要包括:
- 文件格式验证,验证通过后字节流进入方法区存储,后续的验证过程都是基于方法区的结构进行的
- 元数据验证,主要验证语义是否符合规范
- 字节码验证,主要是对方法体进行校验,保证方法运行时不会危害虚拟机安全
- 符号引用验证,验证类之间的符号引用的合法性。这个验证是在虚拟机将符号引用转化为直接引用时做的,也就是在解析阶段进行
准备阶段
准备阶段为类变量(static变量)分配内存并设置初始值(通常是零值),类变量所使用的内存在方法区中分配。
注意这一阶段不会初始化实例变量。
另,有一种特殊情况,如果static变量同时也是final的,编译器会认为这是一个常量,会在类字段的属性表中保存ConstantValue属性,准备阶段就会把初始值设置为指定的值,而不是零值。
解析阶段
解析阶段是把常量池中的符号引用替换为直接引用的过程。
符号引用 Symbolic Reference:主要用于Class文件的类/接口、字段、方法表中,使用一组符号来描述所引用的目标。符号引用是在Class文件中定义的,与虚拟机内存无关
直接引用 Direct Reference:就是java中常说的引用,通过引用可以定位到目标对象在内存中的位置。
虚拟机规范没有规范解析阶段的发生时间,可以在类加载时就进行解析,也可以在一个符号引用被使用时才解析,取决于虚拟机的实现。
除了invokedynamic指令之外,其他使用符号引用的指令都可以使用第一次解析的缓存结果(在运行时常量池中记录直接引用,并把常量标识为已解析),但invokedynamic指令就是为了支持动态语言特性,需要等到运行到这条指令时进行解析。(invokedynamic的其中一个用法就是lambda表达式)
这一阶段可能会抛出java.lang.NoSuchMethodError、java.lang.NoSuchFieldError、java.lang.IllegalAccessError
初始化阶段
初始化阶段主要就是执行<clinit>()方法。
clinit方法
- clinit方法是由编译器自动收集类中的所有static变量的赋值动作和static语句块中的语句合并产生的;
- 顺序由语句在源文件中出现的顺序决定;
- static语句块中只能访问到定义在这之前的变量,定义在之后的变量只能赋值,但不能访问;
- 子类的clinit方法执行之前,会保证先执行父类的clinit;
- clinit方法对于类或接口并不是必须的,如果类中没有static语句块,也没有static变量,则这个类就没有clinit方法;
- 执行接口的clinit方法不需要先执行父接口的clinit方法,同样实现类初始化时也不会执行接口的clinit方法;
- 对于一个类的clinit方法,虚拟机会保证在多线程环境下,只会有一个线程去执行,因此如果clinit执行时间很长可能导致应用阻塞;
- 在同一个类加载器下,一个类只会初始化一次,也就是clinit方法最多只会被调用一次;
- clinit抛异常会导致类初始化失败,由于类只会初始化一次,下一次访问这个类时会直接报
java.lang.NoClassDefFoundError
对非static语句块的说明
static语句块,即static{},普通语句块,即不带static的{}语句块,补充说明如下
- static语句块会在类初始化时调用,普通语句块在创建对象实例时调用
- static语句块只会调用一次,普通语句块每次创建时都会调用
- 普通语句块的调用会在构造方法之前
- 首次new一个子类对象时,调用顺序是:父类clinit - 子类clinit - 父类普通语句块 - 父类构造方法 - 子类普通语句块 - 子类构造方法