第三章 类加载机制:动态代码的生命线
1. 类加载各个过程
1.1 类加载过程概述
按照Java虚拟机规范,从class文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包括如下7个阶段:
flowchart TD
A[加载 Loading] --> B[链接 Linking]
B --> B1[验证 Verification]
B --> B2[准备 Preparation]
B --> B3[解析 Resolution]
B --> C[初始化 Initialization]
C --> D[使用 Using]
D --> E[卸载 Unloading]
其中:
- 第一过程的加载(loading)也称为装载
- 验证、准备、解析 3 个部分统称为链接(Linking)
1.2 过程一:Loading(装载)阶段
装载阶段做了什么?
所谓装载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象。
在加载类时,Java虚拟机必须完成以下3件事情:
- 通过类的全名,获取类的二进制数据流
- 解析类的二进制数据流为方法区内的数据结构(Java类模型)
- 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口
类模板对象与Class实例的位置
classDiagram
direction LR
class ClassLoader {
+loadClass()
}
class Heap {
+Order.class的Class对象
}
class MethodArea {
+Order.class的数据结构
}
ClassLoader --> Heap
Heap --> MethodArea
类模板对象:Java类在JVM内存中的一个快照,存储在方法区(JDK1.8之前:永久代;JDK1.8及之后:元空间)。
Class实例的位置:类将.class文件加载至元空间后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构。
1.3 过程二:Linking(链接)阶段
环节1:验证(Verification)
验证的目的是保证加载的字节码是合法、合理并符合规范的。
flowchart LR
A[验证阶段] --> B[格式验证]
A --> C[语义检查]
A --> D[字节码验证]
A --> E[符号引用验证]
B --> B1[魔数0xCAFEBABE检查]
B --> B2[版本号检查]
C --> C1[是否有父类存在]
C --> C2[final类是否被继承]
C --> C3[抽象方法是否实现]
D --> D1[跳转指令合法性]
D --> D2[参数类型正确性]
D --> D3[变量赋值类型检查]
E --> E1[类或方法是否存在]
E --> E2[访问权限检查]
环节2:准备(Preparation)
为类的静态变量分配内存,并将其初始化为默认值。
| 类型 | 默认值 |
|---|---|
int | 0 |
boolean | false |
引用类型 | null |
long | 0L |
float | 0.0f |
double | 0.0d |
char | '\u0000' |
byte | (byte)0 |
short | (short)0 |
注意:
- 这里不包含基本数据类型的字段用static final修饰的情况,因为final在编译的时候就会分配了
- 不会为实例变量分配初始化,实例变量是会随着对象一起分配到Java堆中
- 在这个阶段并不会有初始化或者代码被执行
环节3:解析(Resolution)
将类、接口、字段和方法的符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。
1.4 过程三:Initialization(初始化)阶段
子类加载前先加载父类
在加载一个类之前,虚拟机总是会试图加载该类的父类,因此父类的<clinit>总是在子类<clinit>之前被调用。
口诀:由父及子,静态先行。
哪些类不会生成<clinit>方法?
- 一个类中并没有声明任何的类变量,也没有静态代码块时
- 一个类中声明类变量,但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化操作时
- 一个类中包含static final修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式
类的初始化情况:主动使用vs被动使用
主动使用的情况(会触发初始化):
- 创建类的实例(new关键字、反射、克隆、反序列化)
- 调用类的静态方法
- 使用类、接口的静态字段(final修饰特殊考虑)
- 使用java.lang.reflect包中的方法反射类的方法
- 初始化子类时,如果发现其父类还没有进行过初始化
- 虚拟机启动时,用户需要指定一个要执行的主类
被动使用的情况(不会触发初始化):
- 通过子类引用父类的静态变量
- 通过数组定义类引用
- 引用常量
- 调用ClassLoader类的loadClass()方法加载一个类
1.5 过程四:Using(使用)
开发人员可以在程序中访问和调用它的静态类成员信息,或者使用new关键字为其创建对象实例。
1.6 过程五:Unloading(卸载)
类、类的加载器、类的实例之间的关系
flowchart LR
subgraph 堆区
A[MyClassLoader对象] --> B[Sample类的Class对象]
B --> C[Sample实例]
end
subgraph 方法区
D[Sample类二进制数据]
end
A -.双向关联.-> B
类卸载的条件
一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期。
卸载条件:
- 类的所有实例已被GC
- 加载该类的ClassLoader实例已被GC
- 该类对应的Class对象无引用
2. 类加载器的分类与测试
2.1 类加载器的分类说明
JVM支持两种类型的类加载器:
- 引导类加载器(Bootstrap ClassLoader)
- 自定义类加载器(User-Defined ClassLoader)
从概念上来讲,所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。
2.2 类加载器层次结构
classDiagram
direction BT
class BootstrapClassLoader
class ExtClassLoader
class AppClassLoader
class CustomClassLoader
CustomClassLoader --> AppClassLoader
AppClassLoader --> ExtClassLoader
ExtClassLoader --> BootstrapClassLoader
2.3 具体类加载器介绍
引导类加载器(Bootstrap ClassLoader)
- 使用C/C++语言实现,嵌套在JVM内部
- 加载Java的核心库(JAVA_HOME/jre/lib/rt.jar)
- 不继承自java.lang.ClassLoader,没有父加载器
- 出于安全考虑,只加载包名为java、javax、sun等开头的类
- 加载扩展类和应用程序类加载器,并指定为他们的父类加载器
扩展类加载器(Extension ClassLoader)
- Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
- 继承于ClassLoader类
- 父类加载器为启动类加载器
- 从java.ext.dirs系统属性所指定的目录中加载类库
系统类加载器(AppClassLoader)
- Java语言编写,由sun.misc.Launcher$AppClassLoader实现
- 继承于ClassLoader类
- 父类加载器为扩展类加载器
- 负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
- 应用程序中的类加载器默认是系统类加载器
用户自定义类加载器
- 通过类加载器可以实现非常绝妙的插件机制
- 自定义加载器能够实现应用隔离
- 所有用户自定义类加载器通常需要继承于抽象类java.lang.ClassLoader
3. 双亲委派模型
3.1 双亲委派机制定义与本质
定义:如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载。
本质:规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载。
3.2 双亲委派流程
flowchart TD
A[自定义加载器] --> B[AppClassLoader]
B --> C[ExtClassLoader]
C --> D[Bootstrap]
D -->|无法加载| C
C -->|无法加载| B
B -->|无法加载| A
3.3 双亲委派机制优势与劣势
优势
- 避免类的重复加载,确保一个类的全局唯一性
- 保护程序安全,防止核心API被随意篡改
劣势
检查类是否加载的委托过程是单向的,顶层的ClassLoader无法访问底层的ClassLoader所加载的类。
3.4 破坏双亲委派机制
第一次破坏:兼容性考虑
双亲委派模型在JDK 1.2之后才被引入,为了兼容已有代码,引入了findClass()方法。
第二次破坏:线程上下文类加载器
flowchart LR
A[DriverManager] --> B[线程上下文加载器]
B --> C[加载META-INF/services驱动]
典型例子:JNDI服务、JDBC、JCE等SPI机制
第三次破坏:代码热替换
典型例子:OSGi、Eclipse插件机制
3.5 Tomcat类加载机制
flowchart BT
A[Bootstrap] --> B[ExtClassLoader]
B --> C[AppClassLoader]
C --> D[Common]
D --> E[WebAppClassLoader]
E --> F[JasperLoader]
Tomcat类加载顺序:
- 使用bootstrap引导类加载器加载
- 使用system系统类加载器加载
- 使用应用类加载器在WEB-INF/classes中加载
- 使用应用类加载器在WEB-INF/lib中加载
- 使用common类加载器在CATALINA_HOME/lib中加载
4. ClassLoader源码剖析
4.1 ClassLoader与现有类加载器的关系
flowchart TD
A[ClassLoader] --> B[SecureClassLoader]
B --> C[URLClassLoader]
C --> D[ExtClassLoader]
C --> E[AppClassLoader]
4.2 ClassLoader的主要方法
classDiagram
class ClassLoader {
<<abstract>>
+getParent() ClassLoader
+loadClass(String) Class
#findClass(String) Class
#defineClass(byte[], int, int) Class
#resolveClass(Class)
#findLoadedClass(String) Class
}
核心方法说明
- getParent():返回该类加载器的超类加载器
- loadClass(String name):加载名称为name的类,双亲委派模式的实现
- findClass(String name):查找二进制名称为name的类,JVM鼓励重写此方法
- defineClass():根据给定的字节数组转换为Class的实例
- resolveClass(Class<?> c):链接指定的一个Java类
- findLoadedClass(String name):查找名称为name的已经被加载过的类
4.3 loadClass()方法剖析
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
// 首先,在缓存中判断是否已经加载同名的类
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 获取当前类加载器的父类加载器
if (parent != null) {
// 如果存在父类加载器,则调用父类加载器进行类的加载
c = parent.loadClass(name, false);
} else {
// parent为null:父类加载器是引导类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父类加载器无法完成类加载任务
}
if (c == null) {
// 调用当前ClassLoader的findClass()
c = findClass(name);
}
}
if (resolve) {
// 是否进行解析操作
resolveClass(c);
}
return c;
}
}
4.4 Class.forName()与ClassLoader.loadClass()对比
| 方法 | 特点 | 初始化 |
|---|---|---|
| Class.forName() | 静态方法 | 会执行类的初始化 |
| ClassLoader.loadClass() | 实例方法 | 不会执行类的初始化 |
5. 自定义类加载器
5.1 为什么要自定义类加载器?
- 隔离加载类:在某些框架内进行中间件与应用的模块隔离
- 修改类加载的方式:按需进行动态加载
- 扩展加载源:从数据库、网络等加载
- 防止源码泄漏:Java代码容易被编译和篡改,可以进行编译加密
5.2 两种实现方式
flowchart TD
A[自定义ClassLoader] --> B[重写loadClass方法]
A --> C[重写findClass方法]
B --> D[破坏双亲委派模型]
C --> E[遵循双亲委派模型]
style B fill:#f9f,stroke:#333
style C fill:#bbf,stroke:#333
style D fill:#f96,stroke:#333
style E fill:#6f9,stroke:#333
%% 添加注释
click B "https://docs.oracle.com/javase/8/docs/api/java/lang/ClassLoader.html#loadClass-java.lang.String-" "loadClass文档"
click C "https://docs.oracle.com/javase/8/docs/api/java/lang/ClassLoader.html#findClass-java.lang.String-" "findClass文档"
推荐方式:重写findClass()方法
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 获取类的字节数组
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
// 使用defineClass生成class对象
return defineClass(name, classData, 0, classData.length);
}
}
优势:
- 不破坏双亲委派模型的稳定结构
- 避免重写loadClass()方法的复杂性
- 代码复用性更好
6. 主要疑问点解答
6.1 面试常见问题
Q1: 既然Tomcat不遵循双亲委派机制,那么如果我自己定义一个恶意的HashMap,会不会有风险呢?
A: 显然不会有风险。Tomcat不遵循双亲委派机制,只是自定义的classLoader顺序不同,但顶层还是相同的,还是要去顶层请求classloader。
Q2: Tomcat如果使用默认的类加载机制行不行?
A: 不行。因为:
- 无法加载两个相同类库的不同版本
- 无法实现应用程序间的类库隔离
- 无法支持JSP文件的热替换
Q3: 双亲委派机制可以打破吗?为什么?
A: 可以打破。双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。历史上出现过3次较大规模的"被破坏"情况。
6.2 类加载时机总结
类加载 = 装载 + 链接(验证+准备+解析)+ 初始化
sequenceDiagram
participant JVM
participant ClassLoader
participant Class
JVM->>ClassLoader: 请求加载类
ClassLoader->>ClassLoader: 检查缓存
ClassLoader->>ClassLoader: 双亲委派
ClassLoader->>Class: 装载字节码
ClassLoader->>Class: 验证字节码
ClassLoader->>Class: 准备静态变量
ClassLoader->>Class: 解析符号引用
ClassLoader->>Class: 初始化静态代码
ClassLoader->>JVM: 返回Class对象
总结
类加载机制是JVM的核心组成部分,它确保了Java程序的安全性和稳定性。通过理解类加载的完整生命周期、双亲委派模型的工作原理、以及各种类加载器的职责分工,我们能够:
- 深入理解JVM运行机制:掌握从字节码到可执行代码的完整转换过程
- 解决实际开发问题:如类冲突、模块隔离、热部署等
- 优化应用性能:合理使用类加载机制,避免不必要的类加载开销
- 设计更好的架构:利用自定义类加载器实现插件化、模块化架构
类加载机制体现了Java"一次编写,到处运行"的设计哲学,是Java生态系统强大生命力的重要基础。掌握这一机制,对于成为优秀的Java开发者至关重要。