Java中的类加载机制
类的生命周期
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。其中,解析和初始化的先后顺序不一定。
加载(Loading)
在类的加载过程中,会发生以下三件事
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入
口。
总的来说,就是将类的字节码载入方法区中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 fifield 有:
- _java_mirror 即 java 的类镜像,例如对 String 来说,就是 String.class,作用是把 klass 暴露给 java 使用
- _super 即父类
- _fifields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法表
在Java内存中,会出现这样的数据存储情况。
验证(Verification)
验证的数据十分的多,大致完成以下四个阶段的验证
- 文件格式验证(Class 文件格式检查)
- 元数据验证(字节码语义检查)
- 字节码验证(程序语义检查)
- 符号引用验证(类的正确性检查)
总的来说,就是验证类是否符合JVM规范,是否安全。
准备(Preparation)
为 static 变量分配空间,设置默认值
-
static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
-
static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
-
如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
-
如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
注意下面两条代码
public static int value = 123; //赋值在初始阶段,准备阶段还是默认值 0 public static final int value = 123; //常量会在编译时确定,所以在准备阶段,JVM会将其赋值为123
解析(Resolution)
将常量池中的符号引用解析为直接引用。关于引用方式,我们看看 《深入理解 Java 虚拟机》7.34 节第三版对符号引用和直接引用的解释。
在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化(Initialization)
类的初始化是类加载的最后一个阶段,也是在这个阶段,JVM才开始执行Java类中编写的代码。初始化阶段就是执行类构造器< clinit >()方法的过程。
关于< clinit > 方法
< clinit >()并不是程序员在Java代码中直接编写,而是虚拟机生成的一个方法,它会从上至下依次收集所有 static 静态代码块和静态成员赋值的代码,并且保证在子类的< clinit >()方法执行前,父类的< clinit >()方法已经执行完毕。要注意的是,Java虚拟机必须保证一个类的< clinit >()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的< clinit >()方法,其他线程都需要阻塞等待,直到活动线程执行完毕< clinit >()方法。如果在一个类的< clinit >()方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
《Java虚拟机规范》严格规定了有且只有六种情况必须立即对类进行“初始化”:
1)遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
- 使用new关键字实例化对象的时候。
- 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
- 调用一个类型的静态方法的时候。
2)使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
5)当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
6)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
总结一下导致类初始化以及不初始化的情况
- main 方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或静态方法时
- 子类初始化,如果父类还没初始化,会引发
- 子类访问父类的静态变量,只会触发父类的初始化
- Class.forName
- new 会导致初始化
不会导致类初始化的情况
- 访问类的 static fifinal 静态常量(基本类型和字符串)不会触发初始化
- 类对象.class 不会触发初始化
- 创建该类的数组不会触发初始化
- 类加载器的 loadClass 方法
- Class.forName 的参数 2 为 false 时
由此我们可以得出,类初始化是【懒惰的】
实验测试(每次只执行其中一个)
class A {
static int a = 0;
static {
System.out.println("a init");
}
}
class B extends A {
final static double b = 5.0;
static boolean c = false;
static {
System.out.println("b init");
}
}
public class TestLoad{
static {
System.out.println("main init");
}
public static void main(String[] args) throws ClassNotFoundException {
// 1. 静态常量(基本类型和字符串)不会触发初始化
System.out.println(B.b);
// 2. 类对象.class 不会触发初始化
System.out.println(B.class);
// 3. 创建该类的数组不会触发初始化
System.out.println(new B[0]);
// 4. 不会初始化类 B,但会加载 B、A
ClassLoader cl = Thread.currentThread().getContextClassLoader();
}
}
参考
-
《深入理解 Java 虚拟机 第三版》 周志明
-
https://www.bilibili.com/video/BV1yE411Z7AP/?spm_id_from=333.337.search-card.all.click