背景
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制(ClassLoad Mechanism)。
注意,这里说的 Class 文件,并非指存在于具体磁盘中的某个文件,而是一串二进制的字节流,至于这个字节流从哪里获取我们是不关心的。换句话说,这个字节流可能从网络传输中获得。
类的生命周期(Life Cycle)
类从被加载到内存中开始,到卸载(Unloading)出内存,经历了**加载(Loading)、链接(Linking)、初始化(Initialization)、使用(Using)**四个阶段。
其中**连接(Linking)又包含了验证(verification)、准备(Preparation)、解析(Resolutions)**三个步骤。
-
加载(Loading):通过一个类的**类全局限定名(Fully Qualified Class Name)**查找此类字节码文件,并利用字节码文件(.class文件),将这个字节流所代表的静态存储结构转化为方法区(method area)的运行时(runtime)数据结构。
-
链接(Linking):链接是检验类或接口并准备类型和父类接口的过程。
- 验证(verification):目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。
- 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
- 准备(Preparation):分配一个结构用来存储类信息,这个结构中包含了类中定义的成员变量,方法和接口信息等。
- 为类变量(即在类中由static修饰的字段变量)分配内存并且设置该类变量的初始值即0(如
static int i=5;
这里只将 i 初始化为 0,至于 5 的值将在初始化阶段时赋值)。 - 注意,这里不会为实例变量初始化。因为类变量会分配在方法区中,而实例变量是会随着对象一起分配到 Java 堆中。
- 为类变量(即在类中由static修饰的字段变量)分配内存并且设置该类变量的初始值即0(如
- 解析(Resolutions):主要将类的常量池中的符号引用(Symbolic References)替换为直接引用(Direct References)。
- **符号引用(Symbolic References)**是一组符号来描述目标,可以是任何字面量;
- 而**直接引用(Direct References)**就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
- 验证(verification):目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。
-
初始化(Initialization):类加载最后阶段,执行静态初始化程序,类把静态变量初始化成指定的值。
- 若该类具有超类,则对其进行初始化,执行静态初始化器和为类变量真正赋值(在准备阶段,只为类的 static 变量赋了该变量类型默认的值,而在初始化阶段将会为类变量真正赋值),成员变量也将被初始化。
这些步骤总体上是按照图中顺序进行的,但是Java语言本身支持运行时绑定,所以解析阶段也可以是在初始化之后进行的。以上顺序都只是说开始的顺序,实际过程中是交叉进行的,加载过程中可能就已经开始验证了。
类加载的时机
在深入了解类加载机制各个阶段的细节之前,我们首先要知道什么时候类需要被加载。
然而,Java 虚拟机规范并没有约束这一点,但是,却严格规定了类必须进行初始化(Initialization)阶段的 5 种情况。很显然,加载(Loading)和链接(Linking)阶段必须在初始化(Initialization)阶段之前。
下面具体来说说这 5 种情况:
主动引用
这 5 种场景中的行为称为对一个类进行主动引用。主动引用一定会触发类的初始化。
我们对上面的场景进行分别举例:
1. 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行初始化,则需要先触发其初始化。
public class NewClass {
public static int value = 1;
static {
System.out.println("NewClass init!");
}
public static void staticMethod() {
//System.out.println("staticMethod invoked");
}
}
public class Initialization1 {
public static void main(String[] args) {
// (1) new 字节码指令 - 使用new关键字实例化对象
new NewClass();
// 输出结果
// NewClass init!
// (2) getstatic 字节码指令 - 读取类的静态成员变量
int x = StaticAttributeClass.value;
// 输出结果
// NewClass init!
// (3)putstatic 字节码指令 - 读设置类的静态成员变量
StaticAttributeClass.value = 2;
// 输出结果
// NewClass init!
// (4)invokestatic 字节码指令 - 调用类的静态方法
StaticAttributeClass.staticMethod();
// 输出结果
// NewClass init!
}
}
2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。
public class ReflectClass {
static {
System.out.println("ReflectClass init!");
}
}
public class Initialization3 {
public static void main(String[] args) throws Exception {
Class classB = Class.forName("jvm.init.ReflectClass");
}
}
// 输出结果
ReflectClass init!
3. 当一个类初始化的时候,如果发现其父类还没有初始化,则需要先对其父类进行初始化。
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class Initialization4 {
public static void main(String[] args) {
new SubClass();
}
}
// 输出结果
SuperClass init!
SubClass init!
4. 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类。
其实就是public static void main(String[] args)所在的那个类。
public class MainClass {
static {
System.out.println("MainClass init ...");
}
public static void main(String[] args) {
System.out.println("main begin ...");
}
}
//输出结果
MainClass init ...
main begin ...
被动引用
除此之外所有引用类的方式,都不会触发初始化,因而称为被动引用。被动引用一定不会触发类的初始化。被动引用包括:
- 通过子类引用父类的静态变量,不会导致子类初始化。
- 通过数组定义类引用,用不会触发此类的初始化。
- 引用常量不会触发此类的初始化(常量在编译阶段就存入调用类的常量池中了)
我们仍然对上面的场景进行分别举例:
1. 通过子类引用父类的的静态字段,不会导致子类初始化
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class NotInitialization1 {
public static void main(String[] args) {
int x = SubClass.value;
}
}
// 输出结果
SuperClass init!
2. 通过数组定义来引用类,不会触发此类的初始化
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public class NotInitialization2 {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
}
// 无任何输出
3. 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
public class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final int value = 123;
}
public class NotInitialization3 {
public static void main(String[] args) {
int x = ConstClass.value;
}
}
// 无任何输出结果
1 加载(Loading)
加载是整个类加载过程的第一步。
加载阶段分为以下几步:
- 通过一个类的类全局限定名(Fully Qualified Class Name),获取定义此类的二进制字节流;
- 将这个字节流所代表的静态存储结构,转化为方法区(method area)的运行时(runtime)数据结构
- 在 Java 堆中生成一个代表这个类的
java.lang.Class
对象,此后,便可以通过这个类的java.lang.Class
对象访问到这个类方法区中的数据。
注意,这里第 1 条中的二进制字节流并不只是只能从本地的 Class 文件中获取,比如还可以从 Jar 包中获取、从网络中获取(最典型的应用便是 Applet)、由其他文件生成(JSP 应用)等。
数组类的加载
如果要加载的类不是数组类型,那么它就可以直接通过类加载器创建。
如果加载的类是数组类,则通过Java虚拟机创建(而不是类加载器)。
但是,数组类的元素类型(Element Type,指的是数组去掉所有维度后的类型)最终还是要靠类加载器创建,一个数据类的创建过程遵循以下规则:
- 如果数组的组件类型(ComponentType,指的是数组去掉一个维度的类型)是引用类型,就通过类加载器加载此组件类型,数组类将在加载该组件类型的类加载器的类名称空间上被标识;
- 如果数组的组件类型不是引用类型(例如int[]数组),Java虚拟机将会把数组类标记为与引导类加载器关联;
- 数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public。
类加载器(Class Loader)
在 Java 中,默认提供的三种类加载器,分别是BootStrapClassLoader(启动类加载器)、ExtClassLoader(扩展类加载器)、AppClassLoader(应用程序类加载器)。
- BootStrapClassLoader(启动类加载器):它由 C++ 实现的,在Java程序中不能显式地获取到。它负责加载存放在
%JAVA_HOME%/jre/lib
、%JAVA_HOME%/jre/classes
以及-Xbootclasspath参数指定的路径中的类。 - ExtClassLoader(扩展类加载器):它是由sun.misc.Launcher$ExtClassLoader实现,负责加载
%JAVA_HOME%/jre/lib/ext
路径下的所有classes
目录以及java.ext.dirs
系统变量指定的路径中的类库。 - AppClassLoader(应用程序类加载器):由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)中的类,开发者可以直接使用该类加载器。一般来说,开发者自定义的类就是由应用程序类加载器加载的。
双亲委派模型(Parents Delegation model)
双亲委派模型(Parents Delegation model)要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系不是以继承的关系来实现的,而是都使用递归的方式来调用父加载器的代码。
双亲委派模型的工作过程:
- 当前类加载器首先从自己已经加载的类中,查询是否此类已经加载,如果已经加载,则直接返回原来已经加载的类。
- 如果在当前类加载器的缓存中,没有找到期待被加载的类时,委托父类加载器去加载。父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到 BootStrapClassLoader(启动类加载器)。
- 当所有的父类加载器都没有加载此类时,才由当前的类加载器加载,并将其放入自己的缓存中,以便下次有加载请求时直接返回。
类加载器加载类的隔离问题
每个类加载器都有一个自己的命名空间,用来标示它加载的类。
当一个类加载器加载一个类时,它会通过保存在命名空间里的**类全局限定名(Fully Qualified Class Name)**进行搜索,来检测这个类是否已经被加载了。
JVM
及 Dalvik
对类唯一的识别是 ClassLoader id
+ PackageName
+ ClassName
,所以一个运行程序中是有可能存在两个包名和类名完全一致的类的。并且如果这两个”类”不是由同一个类加载器加载的时,是无法将一个类的实例强转为另外一个类的,这就是类加载器隔离。
双亲委托 是类加载器类一致问题的一种解决方案,也是 Android
差价化开发和热修复的基础。
自定义类加载器
一般地,在ClassLoader
方法的loadClass
方法中已经给开发者实现了双亲委派模型,在自定义类加载器的时候,只需要复写findClass
方法即可。
2 链接(Linking)
验证(verification)
验证(verification)作为链接(Linking)的第一步,验证的目的是确保 Class 文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。
Java虚拟机规范中关于验证阶段的规则也是在不断增加的,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。
- 文件格式的验证:验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的。
- 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合 Java 语法规范的元数据信息。
- 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
- 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。
文件格式验证
主要验证字节流是否符合Class文件格式规范,并且能被当前版本的虚拟机处理。
主要验证点:
- 是否以魔数
0xCAFEBABE
开头 - 主次版本号是否在当前虚拟机处理范围之内
- 常量池的常量是否有不被支持的类型 (检查常量tag标志)
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
- CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据
- Class文件中各个部分及文件本身是否有被删除的或者附加的其他信息 …
实际上验证的不仅仅是这些。这阶段的验证是基于二进制字节流的,只有通过文件格式验证后,字节流才会进入内存的方法区中进行存储。
元数据验证
主要对字节码描述的信息进行语义分析,以保证其提供的信息符合 Java 语言规范的要求。
主要验证点:
- 该类是否有父类(只有Object对象没有父类,其余都有)
- 该类是否继承了不允许被继承的类(被final修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,出现不符合规则的方法重载,例如方法参数都一致,但是返回值类型却不同)
字节码验证
主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,字节码验证将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
主要有:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似的情况:操作数栈里的一个int数据,但是使用时却当做long类型加载到本地变量中
- 保证跳转不会跳到方法体以外的字节码指令上
- 保证方法体内的类型转换是合法的。例如子类赋值给父类是合法的,但是父类赋值给子类或者其它毫无继承关系的类型,则是不合法的。
符号引用验证
最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段解析阶段发生。符号引用是对类自身以外(常量池中的各种符号引用)的信息进行匹配校验。 通常有:
- 符号引用中通过字符串描述的全限定名是否找到对应的类
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
- 符号引用中的类、方法、字段的访问性(private,public,protected、default)是否可被当前类访问 符号引用验证的目的是确保解析动作能够正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
准备(Preparation)
准备阶段的任务是为类的静态字段(类变量)分配内存空间,并且以该特定类型的系统默认值初始化这些字段。
注意,这个阶段进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
这个阶段不会执行任何的虚拟机字节码指令,而在初始化阶段才会显式地初始化这些字段,所以准备阶段不会做这些事情。
假设一个类变量的定义为:
public static int value = 123;
那么变量 value 在准备阶段过后的初始值为 0,而不是 3,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 3 的 putstatic 指令是在程序编译后,存放于类构造器方法之中的,所以把 value 赋值为 3 的动作将在初始化阶段才会执行。
下面看一下Java中所有基础类型的零值:
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | ‘\u0000’ |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
这里还需要注意如下几点:
- 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则 JVM 会为其赋予默认的零值;而对于局部变量,若在使用前没有显式地为其赋值,则在编译时就无法通过。
- 对于同时被 static 和 final 修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被 final 修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,而 JVM 不会为其赋予默认零值。
- 对于引用数据类型 reference 来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
- 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
一种特殊情况是,如果字段属性表中包含ConstantValue属性,那么准备阶段变量value就会被初始化为ConstantValue属性所指定的值,比如如果这样定义:
public static final int value = 123;
在编译时,变量值就指向了一个不变常量,因此,在准备阶段,value的值就会是 123。
解析(Resolutions)
解析阶段(Resolutions)是把常量池内的符号引用(Symbolic References)替换成直接引用(Direct References)的过程。
符号引用(Symbolic References)
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要可以唯一定位到目标即可。符号引用于内存布局无关,所以所引用的对象不一定需要已经加载到内存中。各种虚拟机实现的内存布局可以不同,但是接受的符号引用必须是一致的,因为符号引用的字面量形式已经明确定义在Class文件格式中。
符号引用就是 Class 文件中的CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量。
直接引用(Direct References):直接引用时直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机上翻译出来的直接引用一般不会相同。如果有了直接引用,那么它一定已经存在于内存中了。
以下Java虚拟机指令会将符号引用指向运行时常量池,执行任意一条指令都需要对它的符号引用进行解析:
对同一个符号进行多次解析请求是很常见的,除了invokedynamic指令以外,虚拟机基本都会对第一次解析的结果进行缓存,后面再遇到时,直接引用,从而避免解析动作重复。
字段解析
要解析一个未被解析过的字段符号引用,首先会对字段表内class_index项中索引的 CONSTANT_Class_info
符号引用进行解析,也就是字段所属的类或接口的符号引用。
如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段解析失败。如果解析完成,那将这个字段所属的类或者接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。
1 . 如果C本身包含了简单名称和字段描述符都与目标相匹配的字段,则直接返回这个字段的直接引用,查找结束。
2 . 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
3 . 再不然,如果C不是java.lang.Object
的话,将会按照继承关系从下往上递归搜索其父类,如果在类中包含
了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4 . 如果都没有,查找失败退出,抛出java.lang.NoSuchFieldError
异常。如果返回了引用,还需要检查访问权限,如果没有访问权限,则会抛出java.lang.IllegalAccessError
异常。
在实际的实现中,要求可能更严格,如果同一字段名在C的父类和接口中同时出现,编译器可能拒绝编译。
类方法解析
类方法解析也是先对类方法表中的class_index项中索引的方法所属的类或接口的符号引用进行解析。我们依然用C来代表解析出来的类,接下来虚拟机将按照下面步骤对C进行后续的类方法搜索。
1 . 首先检查方法引用的C是否为类或接口,如果是接口,那么方法引用就会抛出IncompatibleClassChangeError
异常
2 . 方法引用过程中会检查C和它的父类中是否包含此方法,如果C中确实有一个方法与方法引用的指定名称相同,并且声明是签名多态方法(Signature Polymorphic Method),那么方法的查找过程就被认为是成功的,所有方法描述符所提到的类也需要解析。对于C来说,没有必要使用方法引用指定的描述符来声明方法。
3 . 否则,如果C声明的方法与方法引用拥有同样的名称与描述符,那么方法查找也是成功。
4 . 如果C有父类的话,那么按照第2步的方法递归查找C的直接父类。
5 . 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在相匹配的方法,说明类C时一个抽象类,查找结束,并且抛出java.lang.AbstractMethodError
异常。
- 否则,宣告方法失败,并且抛出
java.lang.NoSuchMethodError
。 最后的最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,那么会抛出java.lang.IllegalAccessError
异常。
接口方法解析
接口方法也需要解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。
1 . 与类方法解析不同,如果在接口方法表中发现class_index对应的索引C是类而不是接口,直接抛出java.lang.IncompatibleClassChangeError
异常。
2 . 否则,在接口C中查找是否有简单名称和描述符都与目标匹配的方法,如果有则直接返回这个方法的直接引用,查找结束。
3 . 否则,在接口C的父接口中递归查找,直到java.lang.Object
类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
4 . 否则,宣告方法失败,抛出java.lang.NoSuchMethodError
异常。
由于接口的方法默认都是public的,所以不存在访问权限问题,也就基本不会抛出java.lang.IllegalAccessError
异常。
3 初始化(Initialization)
初始化是类加载的最后一步,在前面的阶段里,除了加载阶段可以通过用户自定义的类加载器加载,其余部分基本都是由虚拟机主导的。但是到了初始化阶段(Initialization),才开始真正执行用户编写的 Java 代码。
在准备阶段,变量都被赋予了初始值,但是到了初始化阶段,所有变量还要按照用户编写的代码重新初始化。换一个角度,初始化阶段是执行类构造器<clinit>()
方法的过程。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static语句块)中的语句合并生成的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。
public class Test {
static {
i=0; //可以赋值
System.out.print(i); //编译器会提示“非法向前引用”
}
static int i=1;
}
<clinit>()
方法与类的构造函数<init>()
方法不同,它不需要显示地调用父类构造器,虚拟机会宝成在子类的<clinit>()
方法执行之前,父类的<clinit>()
已经执行完毕,因此在虚拟机中第一个被执行的<clinit>()
一定是java.lang.Object
的。
也是由于<clinit>()
执行的顺序,所以父类中的静态语句块优于子类的变量赋值操作,所以下面的代码段,B的值会是2。
static class Parent {
public static int A=1;
static {
A=2;
}
}
static class Sub extends Parent{
public static int B=A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}
<clinit>()
方法对于类来说不是必须的,如果一个类中既没有静态语句块也没有静态变量赋值动作,那么编译器都不会为类生成<clinit>()
方法。
接口中不能使用静态语句块,但是允许有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()
方法,但是接口中的<clinit>()
不需要先执行父类的,只有当父类中定义的变量使用时,父接口才会初始化。除此之外,接口的实现类在初始化时也不会执行接口的()方法。
虚拟机会保证一个类的<clinit>()
方法在多线程环境中能被正确的枷锁、同步。如果多个线程初始化一个类,那么只有一个线程会去执行<clinit>()
方法,其它线程都需要等待。
Reference
- 深入理解JVM类加载机制 - https://juejin.im/post/5a1d5f286fb9a045132a7100
- Java虚拟机 —— 类的加载机制 - https://juejin.im/post/59c4dd9e5188257e876a1aee
- Jvm原理入门 - https://leeyuan.cf/2018/02/03/Jvm%E5%8E%9F%E7%90%86%E5%85%A5%E9%97%A8/
- Java中对类的主动引用和被动引用 - https://blog.csdn.net/u012312373/article/details/50379140