类加载机制:虚拟机把描述类的数据从 Class
文件加载到内存,并对数据校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java
类型,这就是虚拟机的类加载机制。
类加载过程
类从被加载到虚拟机内存中开始,到类卸载出内存为止,它的整个生命周期包括:加载 Loading
, 验证 Verification
,准备 Preparation
,解析 Resolution
,初始化 Initialization
,使用 Using
和卸载 Unloading
共七个阶段。其中验证、准备、解析这三个阶段部分统称为连接 Linking
,七个阶段出现的顺序如图所示:
其中:加载、验证、准备、初始化、卸载这五个阶段的顺序是固定的,类的加载过程必须按照这个顺序执行,而解析有可能会在初始化之后才开始(比如:动态绑定,也称为晚绑定、动态分派 Difference between Binding and Dispatching in Java, wiki:Late_binding in java)。
类加载全过程也就是:加载、验证、准备、解析、初始化这五个阶段。
加载 Loading
加载是类加载过程的一个阶段,加载需要完成三件事:
- 通过类的全限定名获取定义此类的二进制字节流。这个字节流可以是从本地
Class
文件、网络下载、使用动态代理运行时生成等方式获取 - 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
非数组类的加载阶段,也就是通过全限定名获取类的二进制字节流,这个过程可控性很强,可以默认使用系统提供的类加载器去加载,也可以自定义加载器实现。
而数组类本身不通过类加载器创建,它是由虚拟机直接创建的,但是数组类的元素类型 Element Type
(也就是数组类型)还是要靠类加载器加载。
加载完成后,虚拟机将外部的二进制字节流,按照虚拟机所需格式存储到方法区中,并在方法区中实例化 java.lang.Class
对象,这个类对象将作为程序访问类型数据的接口。
验证 Verification
验证阶段确保加载的二进制字节流包含的信息符合虚拟机需求,以及做一些安全检查。
- 文件格式验证
比如验证文件是否以魔数开头0xCAFEBABE
开头;主次版本号是否符合虚拟机范围;Class
文件本身是否有被删除信息等等。 - 元数据格式验证
对字节码描述的信息进行语义分析,确保符合Java
语言规范。比如:类是否具有父类;是否能被继承;是否为抽象类;字段、方法是否和父类矛盾等等。 - 字节码验证
通过数据流和控制流分析,确定程序语义是否合法符合逻辑,使用类型检查完成数据流分析。比如:操作数栈的数据类型和指令能配合工作;确保类型转换是安全的等等。 - 符号引用验证
这个转换动作主要在解析阶段发生,确保解析阶段能够正常执行。符号引用验证主要对类自身以外的信息进行校验:符号引用中的全限定名能否找到对应的类;指定类中是否存在符合方法的字段描述符;符号引用中的访问控制符是否可以被当前类访问等等。
准备 Preparation
准备阶段是正式为类变量分配内存并设置类变量的初始值的阶段,这些变量都在方法区分配内存。需要注意两点:
- 内存分配仅仅包含类变量(
static
变量),并不包含实例变量(分配到堆内存) - 初始值是指数据类型的零值,而不是声明变量时的赋值
1 | public static int number = 123; |
示例中 number
在准备阶段值为 0,而 value
因为是常量 static final
,在准备阶段会被直接赋值为 100 。
解析 Resolution
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。
- 符号引用
以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要能准确定位到目标即可。符号引用字面量形式明确定义在Java
虚拟机规范的Class
文件格式中。 - 直接引用
可以只直接指向目标的指针、偏移量或者间接定位到目标的句柄。有了直接引用,那么引用目标一定是已经在内存中存在了。
解析动作主要针对类、接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。这几类符号引用的解析过程,《深入理解 Java
虚拟机》第七、八章中有详细介绍。
初始化 Initialization
类初始化阶段是类加载过程的最后一步,该阶段才真正开始执行 Java
程序代码。在准备阶段,类变量仅仅赋为零值,在类初始化阶段才会执行代码并赋值。类初始化阶段是执行类构造器 <clinit>()
方法的过程。
<clinit>()
方法是由编译器自动收集类中所有类变量的赋值动作和static{}
静态语句代码块合并产生的,收集顺序就是代码中出现的顺序。静态语句块只能访问定义在之前的静态变量,定义在之后的变量只能赋值不能访问<clinit>()
方法与类实例构造器(<init>()
)不同,它不需要显示调用父类构造器,虚拟机会确保子类<clinit>()
方法之前执行完父类的<clinit>()
方法,也就是说虚拟机第一个被执行的<clinit>()
方法肯定是java.lang.Object
的。这也意味着父类的静态语句会先于子类执行。<clinit>()
方法对于类或者接口并不是必须的,类中可以没有静态变量赋值及静态语句块,编译器也就不会生成<clinit>()
方法- 接口中不能有静态语句块, 但可以有静态变量定义和赋值,所以也会生成
<clinit>()
方法。但需要注意接口与类不同的是:执行接口<clinit>()
方法不需要先执行父接口的<clinit>()
方法,只有父接口的变量在使用时才会执行;实现接口的类初始化时也不会执行接口的<clinit>()
方法 - 虚拟机会保证一个类的
<clinit>()
方法在多线程环境中被正确的加锁、同步。多线程同时执行类初始化,那么只会有一个线程去执行类的<clinit>()
方法,其他线程会阻塞等待直到<clinit>()
方法执行完毕。 - 同一个类加载器下,类只会被初始化一次,也就是
<clinit>()
方法只会被执行一次
<clinit>()
方法是类初始化过程,即类加载过程的初始化阶段;<init>()
是类实例化过程,即遇到 new
关键字生成类对象阶段。
<clinit>()
类构造器,包含类变量(static
变量)初始化赋值,静态语句代码块(static{}
)。<init>()
实例构造器,包含实例变量初始化赋值,构造语句代码块({}
),构造方法。
类加载的时机
类初始化时机
什么时候开始进行类加载过程中的第一个阶段:加载?虚拟机并没有明确规定。但是虚拟机严格规定了:有且只有 5 种情况必须对类进行初始化(也就是一定会触发类加载过程):
- 遇到
new, getstatic, putstatic, invokestatic
这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这几条指令的场景如下
使用new
关键字实例化对象;读取或设置类的静态字段(被final
进入常量池的静态字段除外);调用一个类的静态方法。 - 使用
java.lang.reflect
包的方法对类进行反射调用的时候,如果类没有进行过初始化,需要先触发初始化 - 初始化一个类的时候,如果发现父类还没有初始化,先触发父类初始化
- 用户指定要执行额主类:即包含
main
主类的先初始化 - 如果
java.lang.invoke.MethodHandle
实例最后解析的结果为REF_getStatic, REF_putStatic, REF_invokeStatic
的方法句柄,如果没有初始化会触发其初始化
主动/被动引用
上面明确的 5 种类初始化场景,称为类的主动引用;而不会触发类初始化的引用,称为类的被动引用。主动/被动引用,是否触发类加载过程的加载阶段,虚拟机没有明确规定,当前测试的虚拟机只要引用了,都会触发类加载过程的加载阶段。
被动引用有如下几种情形:
- 子类引用父类静态字段
子类不会被初始化,而父类会被初始化。对于静态字段,只有直接定义这个字段的类才会被初始化。 - 类数组
数组引用类,不会触发类初始化。 - 静态常量
静态常量如果在编译期能够被确定,则不会触发类初始化,甚至都不会进入类加载阶段,比如字符串常量。它们会直接放入常量池,并且在编译器优化中,直接将该常量放入到引用类的常量池中,也就是说生成的引用类Class
文件中并不包含被引用类的符号引用,它们在Class
文件中毫无关系了。
而编译期无法确定,必须在运行时才能确定的字段,这类静态常量会导致类初始化(只有在类初始化阶段才开始执行代码),比如当前时间static final String STATIC_TIME = System.currentTimeMillis() + "";
。 - 反射
Classloader.loadClass
在使用反射过程中,ClassLoad.loadClass
只会触发类加载阶段,不会执行类初始化。 - 反射
Class.forName
在使用反射过程中,Class.forName
时会同时触发类加载和类初始化阶段,默认使用调用类的类加载器进行类加载。
类加载器
类加载阶段中:通过一个类的全限定名来获取描述此类的二进制字节流,这个过程是在虚拟机外部实现的,实现这个过程的模块即为类加载器。
对于任一个类:都需要由类加载器和这个类本身一同确立其在 Java
虚拟机中的唯一性。每个类加载器,都有一个独立的类名称空间。也就是说,比较两个类是否相等,只有在同一个类加载器中加载才有意义。即使两个类属于同一个 Class
文件,由同一个虚拟机加载,只要加载它们的类加载器不同,这两个类必定不相等。相等的测试,可以是 Class
的 equals(), isAssignableFrom(), isInstance()
等方法,或者 instanceof
关键字。
类加载器分类
- 启动类加载器
Bootstrap ClassLoader
这个是C/C++
实现,虚拟机的一部分,负责加载系统类,JAVA_HOME\lib
目录或者-Xbootclasspath
参数指定路径下的类库(rt.jar
等基础库)加载到虚拟机内存中。启动类加载器无法被用户程序直接引用,引导类加载器没有对应的ClassLoader
。虚拟机只加载JAVA_HOME\lib
按照文件名识别的类库,自定义类库即使放到这个目录下,虚拟机并不会去加载。 - 扩展类加载器
Extension ClassLoader
由sun.misc.Launcher$ExtClassLoader
实现,负责加载JAVA_HOME\lib\ext
中的标准扩展类库,用户可以直接使用这些扩展类加载器。如果将自定义jar
包放到这个路径下,扩展类加载器将会加载这些类。 - 应用程序类加载器
Application ClassLoader
由sun.misc.Launcher$AppClassLoader
实现,负责加载CLASSPATH
中的类库及应用类。这个类加载器是ClassLoader.getSystemClassLoader()
的返回值,所以一般称为系统类加载器,用户可以直接使用。
通常在搭建 Java
开发环境时,都需要添加 JAVA_HOME, CLASSPATH
两个全局环境变量,就是为了给类加载器指定路径的。
双亲委派模型 Parents Delegation Model
上图所示的类加载器之间的层次关系,称为类加载器的双亲委派模型。该模型要求除了顶层启动类加载器外,其余的类加载器都应当有自己的父加载器。这里的父子关系不是继承关系,而是组合关系来复用父类代码。
双亲委派模型原则:某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。
破坏双亲委派模型
双亲委派模型并不是强制性的约束,而是 Java
推荐使用这种方式。
- 自定义类加载器
后面源码分析中可以看到ClassLoader.loadClass()
方法实现了双亲委派模型,该方法可以被重写loadClass
,但是一般情况下会遵循双亲委派模型。所以JDK
中定义了findClass
方法,推荐自定义类加载器重写该方法,既不会破坏双亲委派模型,又可以实现自己的加载器。 - 线程上下文加载器
Thread Context ClassLoader
可以通过Thread.setContextClassLoader
来设置。如果线程创建时没有设置,它将从父线程中继承一个;如果全局范围内都没有设置,默认使用应用程序类加载器。它打破了双亲委派模型,也就是父加载器请求子加载器去完成类加载的动作。
系统默认的上下文加载器为系统加载器,可以参考Launcher
源码。 - 代码热替换
HotSwap
和模块热部署HotDeployment
应用程序像计算机的外设一样,可以热插拔鼠标、U
盘等,不需要重启。自定义加载器可以实现这些功能,并且有非常广泛的应用,如Android
热部署、插件化等。
类加载源码
虚拟机加载程序的入口类:sun.misc.Launcher.java
,学习类加载机制也选这个文件开始分析。
类图结构
从类图结构中可以看出:AppClassLoader
和 ExtClassLoader
都是 ClassLoader
的子类,他们在类关系中是并行的,并不是父子结构。
Launcher
源码分析
1 | // 1. Launcher |
Launcher, ExtClassLoader, AppClassLoader
三个类的构造方法:
Launcher
依次实例化ExtClassLoader, AppClassLoader
,并将ExtClassLoader
的实例传递给AppClassLoader
;设置当前线程的加载器setContextClassLoader
,即每个线程默认加载器是AppClassLoader
。ExtClassLoader
其父加载器设置为空null
,虚拟机默认null
为启动类加载器,即其父加载器是BootstrapClassLoader
。AppClassLoader
其父加载器设置为ExtClassLoader
。
各个类加载器对应的属性
从上面 Launcher
的源码中也可以看出,各个加载器的路径都是通过属性来读取的:
- 启动类加载器
Bootstrap ClassLoader
:sun.boot.class.path
- 扩展类加载器
Extension ClassLoader
:java.ext.dirs
- 应用程序类加载器
Application ClassLoader
:java.class.path
ClassLoader
源码分析
ClassLoader
包含几个重要的方法,其中 ClassLoader.loadClass
中实现了双亲委派模型。
1 | public abstract class ClassLoader { |
loadClass
loadClass
传入的参数是类全名,比如:com.***.Test, com.***.Test$innerclass
,并返回一个Class
类型的实例。它实现了双亲委派模型,如果父加载器都没有加载成功,则调用findClass
来查找。findClass
findClass
方法中直接抛出异常,也就是必须由子类实现。我们自定义类加载器时,通常需要重写findClass
,而不是loadClass
(会覆盖掉双亲委派)。该方法是protected
的,也就是外部类并不需要主动调用,参数为loadClass
传入的类全名。getParent
获取父加载器。defineClass
有多个重载方法,作用是将一个byte
数组转换为Class
类的实例,通常是将.class
文件转换为二进制数组并通过defineClass
来获取类实例。这个方法非常重要,将指定类全名的二进制数组转换成运行时内存数据结构,并校验有效性等等。getSystemClassLoader
静态方法,获取系统类加载器。系统类加载器初始化时,会调用sun.misc.Launcher.getLauncher()
,即Launcher.AppClassLoader
是Java
中的系统类加载器。
类加载示例
类加载的日志开关,设置 JVM
参数:-verbose:class/-XX:+TraceClassLoading
来打印类加载过程。
子类引用父类静态字段
子类不会被初始化,而父类会被初始化。虽然虚拟机没有明确规定是否触发子类加载,但是当前测试虚拟机表现为:只要引用了,父类子类都会被加载。
1 | public class TestSubClassReferenceSuperStaticFiled { |
类加载过程及输出结果:
1 | [Opened /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/rt.jar] |
测试结果可以看出,父类、子类、测试类都被加载,但是只执行了父类初始化阶段,子类没有被初始化。
数组定义的引用类
1 | public class TestClassArray { |
类加载过程及输出结果:
1 | [Opened /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/rt.jar] |
测试结果可以看出,只有类加载阶段,没有被初始化。
静态字段
1 | public class TestStaticField { |
上面源码中,静态字段 STATIC_STR
是字符串常量;而 STATIC_TIME
虽然也是静态常量,但是需要在运行时才能确定。
1 | [Opened /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/rt.jar] |
- 静态字段
STATIC_STR
编译时确定,属于常量,不会触发类初始化,甚至不会进入类加载阶段。 - 静态字段
STATIC_TIME
运行时确定,会触发类初始化并运行代码计算时间。STATIC_TIME
被final
修饰,只会被赋值一次。
反射类加载和初始化不同阶段
1 | public class TestClassLoadingAndInit { |
类加载阶段、类初始化阶段的输出结果:
1 | [Opened /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/rt.jar] |
反射过程中,这两个阶段分别由不同方法触发:
- 类加载阶段
ClassLoad.loadClass
仅仅会触发类加载阶段。 - 类初始化阶段
Class.forName
会触发类加载及类初始化阶段,默认使用调用类的类加载器进行类加载。
类初始化顺序
虚拟机会确保子类初始化之前会执行父类初始化。
1 | public class TestClassInitSeq { |
在静态代码块中,只能对定义在之后的静态变量赋值,不能访问!否则会提示 Illegal forward reference.
。
1 | [Opened /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/rt.jar] |
父类 <clinit>()
方法会优先于子类 <clinit>()
方法执行。
类加载器默认属性和父加载器
1 | public class TestPropAndParent { |
运行结果:
1 | xmt@server005:~/$ java TestProp |
系统默认的类加载器为 AppClassLoader
,其父加载器为 ExtClassLoader
。而 ExtClassLoader
的父加载器为 null
,虚拟机默认其为启动类加载器。
自定义类加载器
自定义加载器的主要步骤:
- 获取字节数组
读取本地class
文件转换为数组,或者从网络等其他地方读取到二进制字节流并转换为数组。 defineClass
转换
将字节数组转换为方法区的运行时数据结构。- 重写
loadClass/findClass
方法加载
推荐重写findClass
方法自定义加载类,loadClass
很容易破坏双亲委派模型。
重写 loadClass
方法
注意:在重写 loadClass
时,需要调用 super.loadClass
尽量保留双亲委派模型来处理父类。
对于任一个类:都需要由类加载器和这个类本身一同确立其在虚拟机中的唯一性。如下为自定义类加载器并验证 instanceof
判断类是否相同。
1 | public class TestClassInstanceOf { |
测试结果:
1 | [Opened /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/rt.jar] |
重写 loadClass
方法的注意事项:
- 如果找不到指定
class
文件,调用父类方法处理
当找不到指定class
文件时,需要父类方法的双亲委派模型处理,比如defineClass
会加载父类,而很多基础父类组件如Object
是三大父加载器加载的。 - 被加载类
class
文件位置
因为重写loadClass
可以直接避开双亲委派模型,所以任意位置的class
文件都可以正确解析。
疑问:自定义类加载器为什么要加载自己 TestClassInstanceOf$1
? 如果将自定义类加载器改为 static class
静态内部类,则不会加载自己。反编译 TestClassInstanceOf$1.class
文件,可以发现 static
方法内的匿名内部类,反编译后是一个 final class
,而且和静态内部类一样,不会引用外部类 this
。
重写 findClass
方法
1 | // 被加载测试类,它的 class 文件不能放在:自定义类加载器文件的当前目录及其子目录中 |
重写 findClass
方法的注意事项:
- 被加载类
class
文件位置
被加载类Test.class
文件,不能放在MyClassLoader
文件所在目录及其子目录中。重写findClass
保留了双亲委派模型,会被AppClassLoader
系统加载器扫描到并加载,自定义加载器无法生效。
在Android Studio
中,这个class
文件甚至不能直接由AS
生成。打印AppClassLoader
加载路径为:...;C:\Users\xmt\AndroidStudioProjects\xmt\gitlab\04_androidbasic\android_basic_knowledge\javabase\build\classes\java\main;...
,也就是说,所有由AS
工具生成的class
文件都会默认被系统加载器加载。 - 自定义加载器
判断传入的class
文件目录及对应文件是否存在,如果存在则先转换为数组,并通过defineClass
将二进制文件转换为运行时数据结构。
后续
- 线程上下文加载器
Android
类加载机制
参考文档
- 疯狂
Java
讲义-第二版 – 第 18 章 类加载机制与反射 - 深入理解
Java
虚拟机:JVM
高级特性与最佳实践 第 2 版 - 第 7 章 虚拟机类加载机制 - sun: Launcher.java
- 超详细java中的ClassLoader详解
- 深入理解Java类加载器(一):Java类加载原理解析
- Understanding Extension Class Loading
- JVM常见问题-类加载机制
- 深入浅出ClassLoader
- Dynamic Class Loading and Reloading
- 深入理解Java类加载器