Java高并发学习笔记(三):类加载
1 来源
- 来源:《Java高并发编程详解 多线程与架构设计》,汪文君著
- 章节:第九、十、十一章
本文这三章的笔记整理。
2 类加载简介
类加载的过程可以简单分为三个阶段:
- 加载阶段:主要负责查找并且加载类的二进制数据文件
- 连接阶段:可以细分为验证、准备、解析三个阶段,验证就是确保类文件的正确性,准备就是为类的静态变量分配内存,并且为其初始化默认值,解析就是把类中的符号引用转换为直接引用
- 初始化阶段:为类的静态变量赋予正确的初始值
3 主动使用与被动使用
JVM
规范规定了每个类或接口在首次主动使用的时候都需要进行初始化,规定了以下六种主动使用类的场景:
- 通过
new
关键字会导致类的初始化 - 访问类的静态变量
- 访问类的静态方法
- 对某个类进行反射操作
- 初始化子类会导致父类初始化
- 启动类(就是包含
main()
的类)也会初始化
除了以上六种情况外,其余的都叫被动使用,不会导致类的加载和初始化,比如引用类的静态常量不会导致类的初始化。
4 类加载详解
前面也说了类加载可以简单分为三个阶段:
- 加载阶段
- 连接阶段
- 初始化阶段
下面先来看一下加载阶段。
4.1 加载阶段
加载阶段就是将class
文件中的二进制数据读取到内存之中,然后将该字节流代表的静态存储结构转换为方法区中运行时数据结构,并且在堆中生成一个该类的java.lang.Class
对象,作为访问方法区数据结构的入口。
类加载的最终产物就是堆内存中的class
对象,JVM
规范中指出类加载是通过一个全限定名去获取二进制数据流,来源包括:
class
文件:这是最常见的格式,就是加载javac
编译后的字节码文件- 运行时动态生成:比如
ASM
可以动态生成,或者可以通过动态代理java.lang.Proxy
生成等 - 通过网络获取:比如
RMI
- 读取压缩文件:比如
JAR
、WAR
包 - 从数据库读取:比如读取
MySQL
中的BLOB
字段类型的数据 - 运行时生成
class
文件并且动态加载:比如Thrift
、Avro
等序列化框架,将某个schema
生成若干个class
文件并进行加载
类加载阶段结束后,JVM
会将这些二进制字节流按照JVM
定义的格式存放在方法区中,形成特定的数据结构后再在堆内存中实例化一个java.lang.Class
对象。
4.2 连接阶段
该阶段可以分为三个小阶段:
- 验证
- 准备
- 解析
需要注意的是这三个小阶段其实不是顺序进行的,而是交叉着进行的,也就是解析的时候其实也会有验证的过程。
4.2.1 验证
验证是为了确保字节流所包含的内容符合JVM
规范,并且不会出现危害JVM
自身安全的代码,当字节流信息不符合要求的时候,会抛出VerifyError
这样的异常或其子异常,验证的信息包括:
- 文件格式
- 元数据
- 字节码
- 符号引用
4.2.1.1 验证文件格式
包括:
- 魔数(
0xCAFEBABE
) - 主次版本号
- 是否存在残缺或附加信息
- 常量池常量类型是否支持
- 常量池引用是否指向不存在常量或不支持类型常量
- 其他
4.2.1.2 验证元数据
元数据验证其实是进行语义分析的过程,语义分析是为了确保字节流符合JVM
规范要求,包括:
- 检查某个类是否存在父类,是否继承某个接口,这些父类或接口是否合法,或是否存在
- 检查是否继承了
final
的类 - 检查抽象类,检查是否实现了父类的抽象方法或接口方法
- 检查重载,比如相同的方法名称、相同的参数但是返回类型不同,这是不允许的
4.2.1.3 验证字节码
字节码验证主要是验证程序的控制流程,包括:
- 保证当前线程在程序计数器中的指令不会跳转到不合法的字节码指令中去
- 保证类型的转换是合法的
- 保证任意时刻虚拟机栈中的操作栈类型与指令代码都能正确被执行
- 其他验证
4.2.1.4 验证符号引用
验证符号引用转换为直接引用的合法性,保证解析动作的顺利执行,包括:
- 通过符号引用描述的字符串全限定名称是否能够顺利找到相关的类
- 符号引用中的类、字段、方法是否对当前类可见
- 其他
4.2.2 准备
经过验证后,就开始了准备阶段,这阶段比较简单,就是对对象的静态变量分配内存并且设置初始值,类变量的内存会被分配到方法区中。设置初始值就是为相应的类变量给定一个相关类型在没有被设置时的默认值,比如Int
的初始值为0,引用的初始值为null
。
4.2.3 解析
解析就是在常量池中寻找类、字段、接口和方法的符号引用,并且将这些符号引用替换成直接引用的过程。解析主要针对类接口、字段、类方法和接口方法进行的,包括:
- 类接口解析
- 字段解析
- 类方法解析
- 接口方法解析
4.3 初始化阶段
初始化阶段主要就是执行<clinit>
方法的过程,该方法是编译阶段生成的,也就是说包含在字节码文件中,该方法包含了所有类变量的赋值动作和静态语句块的执行代码。另一方面,<clinit>
与构造方法不同,不需要显式调用父类构造器,虚拟机会保证父类的<clinit>
方法最先执行。
还需要注意的是<clinit>
只能被虚拟机执行,虚拟机还会保证多线程下的安全性,因此,如果在静态代码块中如果包含了加载其他类的操作可能会引起死锁,例子可以看这里。
5 类加载器
5.1 JVM
中的三类核心类加载器
JVM
中有三类核心类加载器,分别是:
- 启动类加载器:启动类加载器是最顶层的类加载器,没有父加载器,由
C++
编写,负责JVM
核心类库的加载,比如加载整个java.lang
包中的类 - 扩展类加载器:扩展类加载器的父加载器是启动类加载器,主要加载
jre/lib/ext
子目录下的类库,纯Java
实现,是URLClassLoader
的子类 - 应用类加载器:也叫系统类加载器,负责加载
classpath
下的类库,应用类加载器的父加载器为扩展类加载器,同时它也是自定义类加载器的默认父加载器
5.2 双亲委派机制
一个类加载器加载一个类的时候,并不会尝试直接加载该类,而是先交给父加载器尝试加载,一直到顶层的父加载器(启动类加载器),如果父加载器加载失败,则会自己尝试加载,图示如下:
6 线程上下文类加载器
JDK
中提供了很多SPI
(Service Provider Interface
),比如JDBC
等,JDBC
只规定了这些接口之间的逻辑关系,但不提供具体的实现,换句话说,JDBC
完全透明了应用程序和第三方厂商数据库驱动的具体实现,应用程序只需要面向接口编程即可。但问题是:
java.lang.sql
中的所有接口都是由JDK
提供的,加载这些接口的类加载器是启动类加载器- 第三方厂商的类库驱动由系统类加载器加载
由于双亲委派机制,Connections
、Statement
等都是由启动类加载器加载,而第三方JDBC
驱动包中的实现不会被加载。解决这个问题的关键,就是使用了线程上下文类加载器打破了双亲委派机制。
比如MySQL
驱动的加载过程,就是通过线程上下文类加载器加载的,
private static Connection getConnection(String url, Properties info, Class<?> caller) throws SQLException {
//...
if (callerCL == null || callerCL == ClassLoader.getPlatformClassLoader()) {
callerCL = Thread.currentThread().getContextClassLoader();
}
while(true) {
//...
if (isDriverAllowed(aDriver.driver, callerCL)) {
}
}
//...
}
private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) {
//...
try {
aClass = Class.forName(driver.getClass().getName(), true, classLoader);
} catch (Exception var5) {
result = false;
}
//...
return result;
}
通过线程上下文类加载器,就变成了启动类加载器去委托子类加载器去加载实现的方式,也就是JDK
自己亲自打破了双亲委派机制这种方式,这种加载方式几乎涉及所有的SPI
加载,包括JAXB
、JCE
、JBI
等。