Appearance
Java 类加载机制详解
为什么需要类加载机制
Java 是一种静态类型语言,所有代码必须在类中定义。与 C/C++ 在编译期就完成链接不同,Java 采用动态加载策略:类不是在编译时全部链接,而是在运行时按需加载。
这种设计带来了核心优势:
| 优势 | 说明 |
|---|---|
| 按需加载 | 不会一次性加载所有类,节省内存,加快启动 |
| 动态扩展 | 可以从网络、数据库、加密文件等任意来源加载类 |
| 版本隔离 | 同一个 JVM 中可以同时存在同名类的不同版本 |
| 热部署 | 运行时替换类,无需重启应用(如 Tomcat 热部署) |
| 安全性 | 类加载器配合安全管理器,形成沙箱安全机制 |
类加载的生命周期
一个类从 .class 文件到 JVM 中可用,需要经历以下阶段:
+--------+ +--------+ +---------+ +----------+ +----------+
| 加载 |-->| 验证 |-->| 准备 |-->| 解析 |-->| 初始化 |
| Loading| |Verify | |Prepare | |Resolve | |Initialize|
+--------+ +--------+ +---------+ +----------+ +----------+
| |
| +--------+ +----------+ |
+----->| 使用 |-->| 卸载 |<-------------+
| Using | | Unloading|
+--------+ +----------+注意: 加载、验证、准备、初始化、卸载这五个阶段的顺序是确定的,但解析阶段可能在初始化之后(为了支持动态绑定)。
各阶段详解
1. 加载(Loading)
- 通过类的全限定名获取类的二进制字节流
- 将字节流代表的静态存储结构转化为方法区的运行时数据结构
- 在堆中生成
java.lang.Class对象,作为方法区中该类数据的访问入口
2. 验证(Verification)
确保 Class 文件字节流符合 JVM 规范:
验证过程:
+------------------+
| 文件格式验证 | -- 魔数 0xCAFEBABE、主次版本号、常量池类型等
+------------------+
| 元数据验证 | -- 是否有父类、final 类是否被继承、抽象方法实现等
+------------------+
| 字节码验证 | -- 类型转换是否合法、跳转指令是否越界等
+------------------+
| 符号引用验证 | -- 符号引用通过全限定名能否找到对应类
+------------------+3. 准备(Prepare)
为类变量(static 变量)分配内存并设置初始零值:
java
public static int value = 123; // 准备阶段 value = 0,初始化阶段才赋值为 123
public static final int CONST = 123; // 常量,准备阶段直接赋值为 1234. 解析(Resolve)
将常量池内的符号引用替换为直接引用:
- 符号引用:以字面量描述所引用的目标,与 JVM 内存布局无关
- 直接引用:直接指向目标的指针、相对偏移量或句柄
5. 初始化(Initialization)
执行类构造器 <clinit>() 方法:
- 由编译器自动收集类中所有类变量的赋值动作和静态代码块合并产生
- 子类
<clinit>执行前,父类<clinit>一定已执行 - 接口的
<clinit>不会自动执行父接口的<clinit>
类初始化的时机(主动引用)
以下情况会触发类初始化:
- 使用
new关键字实例化对象 - 读取或设置一个类的静态字段(
final修饰的常量除外) - 调用一个类的静态方法
- 使用
java.lang.reflect对类进行反射调用 - 初始化子类时,先初始化父类
- 虚拟机启动时,包含
main()方法的类 - 动态语言支持(
MethodHandle)
类加载器层次结构
+---------------------------------------------------+
| Bootstrap ClassLoader(启动类加载器) |
| 加载 <JAVA_HOME>/lib/ 核心类库 |
| 由 C++ 实现,Java 中表示为 null |
| rt.jar, java.lang.*, java.util.* |
+---------------------------------------------------+
^
| 父加载器
|
+---------------------------------------------------+
| Platform ClassLoader(平台类加载器) |
| JDK 8 及之前称为 Extension ClassLoader |
| 加载 <JAVA_HOME>/lib/ext/ 或 java.ext.dirs |
+---------------------------------------------------+
^
| 父加载器
|
+---------------------------------------------------+
| Application ClassLoader(应用类加载器) |
| 加载 classpath / 系统属性 java.class.path |
| 也称为 System ClassLoader |
+---------------------------------------------------+
^
| 父加载器
|
+---------------------------------------------------+
| Custom ClassLoader(自定义类加载器) |
| 用户自定义,可以加载任意来源的类 |
+---------------------------------------------------+双亲委派模型
工作原理
双亲委派模型(Parent Delegation Model)是 Java 类加载的核心机制:
收到类加载请求 "com.example.MyClass"
|
v
检查是否已加载? ---- 是 ----> 返回已加载的 Class 对象
| 否
v
委托父加载器加载
|
v
+------------------+
| 父加载器加载? |
+------------------+
| |
成功 失败
| |
v v
返回Class 自己尝试加载
|
v
+------------------+
| 自己加载? |
+------------------+
| |
成功 失败
| |
v v
返回Class ClassNotFoundException代码实现: java.lang.ClassLoader.loadClass(String, boolean):
java
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 2. 委托父加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 尝试 Bootstrap ClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器加载失败,忽略
}
if (c == null) {
// 3. 父加载器加载失败,自己加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}为什么需要双亲委派
核心原因:保证 Java 核心类库的安全和唯一性。
如果不使用双亲委派:
- 用户可以自定义一个
java.lang.String类替换 JDK 的 String,导致安全漏洞 - 同一个类可能被不同的类加载器加载多次,导致类型转换异常(
ClassCastException)
JVM 判断两个类是否相同: 全限定名相同 + 同一个类加载器加载。不同类加载器加载的同一个类,JVM 认为是不同的类。
打破双亲委派模型
为什么需要打破
在某些场景下,双亲委派模型无法满足需求:
| 场景 | 问题 | 案例 |
|---|---|---|
| SPI 机制 | 核心库(Bootstrap)需要调用应用实现类 | JDBC 驱动加载 |
| 热部署 | 需要替换已加载的类 | Tomcat WebappClassLoader |
| 模块隔离 | 不同版本的同名类需要共存 | OSGi, Spring Boot DevTools |
Tomcat 如何打破双亲委派
Tomcat 的类加载器结构:
+------------------+
| Bootstrap | -- JVM 核心类
+------------------+
^
|
+------------------+
| System | -- CLASSPATH
+------------------+
^
|
+------------------+
| Common | -- Tomcat 共享类($CATALINA_HOME/lib)
+------------------+
^ ^
| |
+---------+ +---------+
| Catalina| | Shared |
+---------+ +---------+
^ ^
| |
+---------+ +---------+
| WebApp1 | | WebApp2 | -- Web 应用各自的类
+---------+ +---------+WebAppClassLoader 的加载策略(打破了双亲委派):
- 先从自己的缓存中查找
- 如果没找到,从自己的类路径加载(先自行加载,而不是向上委托)
- 如果没找到,委托给父加载器(反向双亲委派)
- Java 核心类(
java.*)仍然走双亲委派(安全保护)
这样每个 Web 应用都有独立的类空间,支持热部署。
线程上下文类加载器(TCCL)
SPI 机制的核心问题:java.sql.DriverManager 由 Bootstrap 加载,但具体的 JDBC 驱动实现(如 com.mysql.cj.jdbc.Driver)在 classpath 中,由 Application 加载。Bootstrap 无法加载 Application 类空间中的类。
解决方案:线程上下文类加载器
java
// DriverManager 内部加载驱动的简化逻辑
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// ServiceLoader.load 内部:
Thread.currentThread().getContextClassLoader(); // 获取 TCCL
// TCCL 默认为 ApplicationClassLoader,可以加载 classpath 中的驱动类TCCL 的使用方式:
java
// 获取当前线程上下文类加载器
ClassLoader tccl = Thread.currentThread().getContextClassLoader();
// 设置自定义类加载器
Thread.currentThread().setContextClassLoader(customClassLoader);
// 典型使用:在框架代码中加载 SPI 实现类
Class<?> implClass = Class.forName("com.mysql.cj.jdbc.Driver", true, tccl);自定义类加载器
findClass vs loadClass
| 方法 | 职责 | 是否遵循双亲委派 |
|---|---|---|
loadClass(String) | 完整的类加载流程 | 是(先委托父加载器) |
findClass(String) | 具体查找字节码逻辑 | 不涉及(由 loadClass 调用) |
最佳实践: 自定义类加载器应重写 findClass 而非 loadClass,以保持双亲委派。
实现示例
java
import java.io.*;
/**
* 自定义类加载器:从指定目录加载 .class 文件
*/
public class CustomClassLoader extends ClassLoader {
private String classPath;
public CustomClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 将全限定名转换为文件路径
String fileName = name.replace('.', File.separatorChar) + ".class";
String filePath = classPath + File.separator + fileName;
try {
byte[] classData = loadClassData(filePath);
// defineClass 将字节数组转换为 Class 对象
return defineClass(name, classData, 0, classData.length);
} catch (IOException e) {
throw new ClassNotFoundException("无法加载类: " + name, e);
}
}
private byte[] loadClassData(String filePath) throws IOException {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
FileInputStream fis = new FileInputStream(filePath)) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
bos.write(buffer, 0, bytesRead);
}
return bos.toByteArray();
}
}
}测试自定义类加载器:
java
public class CustomClassLoaderTest {
public static void main(String[] args) throws Exception {
String classPath = "D:\\my-classes";
CustomClassLoader loader1 = new CustomClassLoader(classPath);
CustomClassLoader loader2 = new CustomClassLoader(classPath);
// 不同类加载器加载的同一个类,JVM 认为是不同的类
Class<?> clazz1 = loader1.loadClass("com.example.Hello");
Class<?> clazz2 = loader2.loadClass("com.example.Hello");
System.out.println(clazz1 == clazz2); // false
System.out.println(clazz1.getClassLoader()); // CustomClassLoader@xxx
}
}加密类加载器
java
/**
* 加载加密的 .class 文件(简单 XOR 加密示例)
*/
public class EncryptedClassLoader extends ClassLoader {
private String classPath;
private byte key = (byte) 0x5A;
public EncryptedClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String filePath = classPath + "/" + name.replace('.', '/') + ".class";
try {
byte[] encrypted = loadByteCode(filePath);
byte[] decrypted = decrypt(encrypted);
return defineClass(name, decrypted, 0, decrypted.length);
} catch (Exception e) {
throw new ClassNotFoundException(name, e);
}
}
private byte[] decrypt(byte[] data) {
byte[] result = new byte[data.length];
for (int i = 0; i < data.length; i++) {
result[i] = (byte) (data[i] ^ key);
}
return result;
}
private byte[] loadByteCode(String filePath) throws IOException {
try (InputStream is = new FileInputStream(filePath)) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int len;
while ((len = is.read(buf)) != -1) {
bos.write(buf, 0, len);
}
return bos.toByteArray();
}
}
}代码示例:深入理解类加载过程
1. 观察类初始化顺序
java
class Parent {
public static int A = 1;
static {
System.out.println("Parent 静态代码块, A = " + A);
A = 2;
}
}
class Child extends Parent {
public static int B = A;
static {
System.out.println("Child 静态代码块, B = " + B);
}
}
public class InitOrderDemo {
public static void main(String[] args) {
System.out.println(Child.B); // 触发 Child 初始化
}
}
// 输出:
// Parent 静态代码块, A = 1
// Child 静态代码块, B = 2
// 22. 观察被动引用(不触发初始化)
java
class SuperClass {
static {
System.out.println("SuperClass 初始化!");
}
public static int value = 123;
public static final int CONST = 456;
}
public class PassiveRefDemo {
public static void main(String[] args) {
// 1. 通过子类引用父类静态字段 → 只初始化父类
System.out.println(SubClass.value); // 不触发 SubClass 初始化
// 2. 常量在编译期就存入常量池 → 不触发初始化
System.out.println(SuperClass.CONST);
// 3. 数组定义 → 不触发初始化
SuperClass[] arr = new SuperClass[10];
}
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass 初始化!");
}
}3. 演示双亲委派
java
public class DelegationDemo {
public static void main(String[] args) {
// 查看 String 的类加载器
ClassLoader cl = String.class.getClassLoader();
System.out.println("String 的类加载器: " + cl); // null → Bootstrap
// 查看自定义类的类加载器
cl = DelegationDemo.class.getClassLoader();
System.out.println("DelegationDemo 的类加载器: " + cl); // AppClassLoader
// 逐级查看父加载器
while (cl != null) {
System.out.println(" -> " + cl);
cl = cl.getParent();
}
}
}
// 输出示例:
// String 的类加载器: null
// DelegationDemo 的类加载器: jdk.internal.loader.ClassLoaders$AppClassLoader@...
// -> jdk.internal.loader.ClassLoaders$AppClassLoader@...
// -> jdk.internal.loader.ClassLoaders$PlatformClassLoader@...类加载器隔离的实际应用
同一类不同版本共存
java
/**
* 演示:两个不同版本的类同时存在于 JVM 中
*/
public class VersionIsolationDemo {
public static void main(String[] args) throws Exception {
// version1 目录和 version2 目录有同名但不同版本的类
CustomClassLoader loader1 = new CustomClassLoader("D:\\libs\\version1");
CustomClassLoader loader2 = new CustomClassLoader("D:\\libs\\version2");
Class<?> apiV1 = loader1.loadClass("com.example.ApiService");
Class<?> apiV2 = loader2.loadClass("com.example.ApiService");
// 两个版本可以同时使用,互不干扰
Object v1 = apiV1.getDeclaredConstructor().newInstance();
Object v2 = apiV2.getDeclaredConstructor().newInstance();
System.out.println("v1 class: " + v1.getClass().getClassLoader());
System.out.println("v2 class: " + v2.getClass().getClassLoader());
}
}面试高频问题
Q1: 类加载过程是怎样的?
A: 类加载分为五个阶段:加载(获取字节流)→ 验证(检查字节码合法性)→ 准备(为 static 变量分配内存并赋零值)→ 解析(符号引用转直接引用)→ 初始化(执行 <clinit>() 方法)。其中解析阶段可能在初始化之后。
Q2: 什么是双亲委派模型?为什么需要它?
A: 双亲委派模型要求除了顶层的 Bootstrap ClassLoader 外,每个类加载器在收到类加载请求时,先委托父加载器加载,父加载器无法加载时才自己加载。这样保证 Java 核心类库的安全(不会被人为替换)和唯一性(同一个类不会被加载多次)。
Q3: 如何打破双亲委派模型?
A: 重写 ClassLoader.loadClass() 方法,改变"先委托父类"的默认行为。典型场景包括:Tomcat 的 WebAppClassLoader(先自己加载,再委托父类,实现热部署和隔离);SPI 机制中使用线程上下文类加载器加载实现类;OSGi 模块化框架的网状类加载器。
Q4: findClass 和 loadClass 有什么区别?
A: loadClass 是完整类加载流程(检查缓存 → 委托父类 → 调用 findClass),遵循双亲委派。findClass 只负责从特定来源读取字节码。自定义类加载器应重写 findClass 而非 loadClass,以保持双亲委派模型。
Q5: 线程上下文类加载器(TCCL)是什么?解决了什么问题?
A: TCCL 是每个线程都可以设置的一个类加载器引用。它解决了 SPI 机制中"核心库(Bootstrap 加载)需要调用应用实现类(Application 加载)"的类加载器逆向问题。例如 JDBC 的 DriverManager 通过 TCCL 来加载用户的 JDBC 驱动实现。
Q6: 什么情况下不应该使用双亲委派?
A: 1)Web 容器中需要隔离不同应用的类(Tomcat);2)热部署场景需要替换已加载的类(Spring Boot DevTools);3)模块化系统中同名类的不同版本需要共存(OSGi);4)SPI 机制中核心库需要调用应用实现类(JDBC 驱动)。
Q7: 两个全限定名相同的类是否一定相等?
A: 不一定。JVM 中类唯一性由两个条件决定:全限定名相同 + 由同一个类加载器加载。如果两个类由不同的类加载器加载,即使全限定名相同,JVM 也认为它们是不同的类,之间无法直接赋值。
