Java 类加载机制与双亲委派模型底层原理
一、 这篇文章要解决什么问题
在日常的业务开发中,大部分开发者对“类加载”毫无感知:直接 import,然后 new 出对象就完事了。但当你跨越初级阶段,开始排查疑难杂症或负责底层架构时,通常会遭遇以下“毒打”:
- 极其诡异的类异常:明明看到 Jar 包在工程里,为什么运行时一直抛
ClassNotFoundException或NoClassDefFoundError? - 同名类版本冲突(Jar Hell):引入了不同的第三方库,它们又传递依赖了同一个基础库的两个不兼容版本,导致运行时报
NoSuchMethodError,无法启动。 - 底层框架原理一直是黑盒:Tomcat 是如何隔离不同 Web 应用依赖的?JDBC 是怎么不主动硬编码就能发现 MySQL 驱动的?Arthas 或热部署(Hot Reload)工具到底是怎么把修改后的代码替换到运行中的 JVM 里的?
这篇文章,就是要把 Java 程序的“出生机制”——类加载与双亲委派模型,揉碎了、白话式地讲透。懂了它,你就拥有了排查复杂依赖问题和理解各类中间件底层的“上帝视角”。
二、 核心原理
要讲透机制,首先必须明确两个核心概念。
1. 类的生命周期
当我们在代码里写下 new MyObject() 时,JVM 并不是凭空变出这个对象的。它必须先把 MyObject.class 这个二进制文件的数据,读取到内存的“方法区”中,并在堆上创建一个对应的 java.lang.Class 大管家对象。
这个过程通常分为三个核心大阶段:加载(Loading) -> 链接(Linking,含验证、准备、解析) -> 初始化(Initialization)。而我们平时常说的“类加载器(ClassLoader)”,主要接管的就是第一步:加载。
2. 双亲委派模型究竟是什么?
其实“双亲委派”是一个典型的翻译背锅词汇。它的英文原名叫 Parents Delegation Model。这里有两个极易误解的点:
- 没有“双亲”,只有“单向层级”:这里的 Parent 指的是类加载器对象内部的一个
parent属性(即指向父加载器的引用),它不是继承关系(extends),而是组合关系。 - 什么是“委派”?:当一个类加载器接到了加载某个类的差事,它第一反应绝对不是自己去加载,而是做甩手掌柜,把这个任务“委派”给它的父加载器。父加载器收到后,也向自己的父加载器委派。
- 一直委派到最顶层的引导类加载器(Bootstrap ClassLoader)。如果顶层加载器说:“这活儿我干不了,找不到这个类”,任务才会被驳回给下一级,子加载器这时候才会迫不得已尝试自己去查找并加载。
为什么要这么设计?
核心是为了安全隔离和避免重复加载。
试想一下,如果黑客在你的项目里写了一个包含恶意代码的 java.lang.String 类。如果没有双亲委派,JVM 就会加载黑客的 String 类,整个系统底裤都被看穿。
有了双亲委派,不管你业务代码写了什么伪造类,任务都会一路丢给最顶层的 Bootstrap ClassLoader。Bootstrap 一看:“java.lang 包归我管,我直接加载官方提供的规范类就行了。” 于是黑客的伪造类永远没有机会被加载到内存中。这就构成了 Java 的沙箱安全机制。
三、 流程/机制描述
在 JVM 初始化时,默认会构建一个层级分明的类加载器体系。它们各司其职,管辖范围严格划分:
- Bootstrap ClassLoader(引导类加载器):
最高层大佬。由 C++ 实现,深陷在 JVM 内核中。它负责加载 Java 核心类库,比如
$JAVA_HOME/jre/lib/rt.jar(包含了java.util.*、java.lang.*等)。 - Extension ClassLoader(扩展类加载器):
二把手。负责加载 Java 的扩展类库,主要是
$JAVA_HOME/jre/lib/ext/目录下的 Jar 包。 - Application ClassLoader(应用程序类加载器): 干苦力的基层。负责加载当前应用 ClassPath(也就是你项目引入的各种三方依赖库和你自己写的代码)下的所有类。我们日常写的业务类,默认都是由它加载的。
- Custom ClassLoader(自定义类加载器):
根据业务需求,由开发者自己编写的代码(主要重写
findClass方法)。
工作流转图示:
具体执行机制:
- 比如代码碰到了
UserEntity类,应用类加载器接到任务。 - 应用类加载器内部查缓存,没加载过?向上委派给扩展类加载器。
- 扩展类加载器查缓存,没加载过?向上委派给引导类加载器。
- 引导类加载器扫描核心库路径(
rt.jar等),发现没有UserEntity。抛回给扩展类加载器。 - 扩展类加载器扫描
ext路径,发现也没有。抛回给应用类加载器。 - 应用类加载器扫描你的 ClassPath,找到了编译出的
UserEntity.class,读取字节流并转换为 Class 对象,加载成功。
四、 关键代码/示例
双亲委派的精髓,全都在 ClassLoader 这个骨架类的 loadClass 方法里。核心源码(抽取并简化核心逻辑)非常优美,堪称模板方法模式的典范:
1. JDK中体现双亲委派模型的核心源码骨架 (示意性逻辑,非可执行代码)
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 首先,检查类是否已经被加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 如果父加载器不为空,调用父级的loadClass向上委派
c = parent.loadClass(name, false);
} else {
// 如果父加载器为空,说明已经到了顶层,调用BootstrapClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器抛出异常,说明父级无法完成加载
}
if (c == null) {
// 父加载器都找不到,这才调用自身的 findClass 方法尝试自己加载
c = findClass(name);
}
}
return c;
}
使用示例
1. 自定义类加载器
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class MyCustomClassLoader extends ClassLoader {
// 指定类文件所在的根路径
private final String classRootPath;
public MyCustomClassLoader(String classRootPath) {
this.classRootPath = classRootPath;
}
/**
* 我们绝不要去重写 loadClass 方法(这会破坏双亲委派的核心骨架),
* 而是要重写 findClass 方法。当父加载器宣告失败兜底时,JVM 就会回调这里。
*/
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 读取 Class 文件字节码到内存中
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
// 调用底层的 native 方法,把字节流转换为 JVM 中的 Class 对象
return defineClass(name, classData, 0, classData.length);
}
// 从磁盘(或网络、数据库)加载字节数组的实现细节
private byte[] loadClassData(String className) {
// 把全限定名转换为路径,例如 com.example.Test -> com/example/Test.class
String filePath = className.replace('.', '/') + ".class";
Path path = Paths.get(classRootPath, filePath);
try (InputStream is = Files.newInputStream(path);
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
return bos.toByteArray();
} catch (Exception e) {
// 异常统一下沉记录或处理
e.printStackTrace();
return null;
}
}
}
2. 准备一个待加载的测试类
package cn.ctzw;
public class Test {
public void hello() {
System.out.println("业务逻辑执行:Hello!我是由自定义类加载器加载的!");
}
}
并使用 javac Test.java 编译成 Test.class 文件。随后将它移动到你指定的磁盘路径下。
javac -encoding UTF-8 -d . Test.java
3. 使用自定义类加载器装载并执行
import java.lang.reflect.Method;
/**
* @author jvs
*/
public class ClassLoaderTest {
public static void main(String[] args) {
try {
// 1. 指定你的字节码文件存放的绝对或相对根路径
String classRootPath = "/data/classes";
// 2. 实例化自定义的类加载器
MyCustomClassLoader myClassLoader = new MyCustomClassLoader(classRootPath);
// 3. 触发类加载:使用底层的 loadClass 方法传入类的全限定名
// 此时:由于 AppClassLoader 和 ExtClassLoader 在各自路径下都找不到这个类,
// 兜底一圈后,最终会回调到我们重写的 findClass 方法中。
Class<?> clazz = myClassLoader.loadClass("cn.ctzw.Test");
// 4. 因为在当前代码的编译期我们并没有 Test 类,所以必须使用反射来实例化它
Object instance = clazz.getDeclaredConstructor().newInstance();
// 5. 拿到类身上的 hello 方法
Method method = clazz.getMethod("hello");
// 6. 执行目标方法
method.invoke(instance);
// 7. 打印出该类的实际加载器,验证结果
System.out.println("当前 Test 类的类加载器是:" + clazz.getClassLoader().getClass().getSimpleName());
} catch (Exception e) {
// 异常统一下沉处理
e.printStackTrace();
}
}
}
当你运行这段代码时,控制台将输出:
业务逻辑执行:Hello!我是由自定义类加载器加载的!
当前 Test 类的类加载器是:MyCustomClassLoader
五、 常见误区
-
误区一:两个同名的类,只要全路径名一样,在机器上就是同一个类。 真相:在 JVM 中,类的唯一标识性是【全限定名】+【加载它的类加载器实例】。即使是同一份完全一模一样的
User.class二进制文件,如果分别被ClassLoader A和ClassLoader B加载,它们在堆内存中产生的Class对象也是截然不同的两个。互相之间转换对象类型会直接抛出ClassCastException!这是由于加载器命名空间不同造成的。这也是实现“依赖隔离”的基本盘。 -
误区二:双亲委派模型是绝对不能打破的神圣规则。 真相:并非如此。双亲委派只是一种“推荐规则”,并非强制性约束。实际上,Java 历史上经历了多次大规模“打破双亲委派”的真实场景:
- SPI 机制(典型如 JDBC 驱动加载):核心的
java.sql.DriverManager归 Bootstrap 老大加载。但在连接数据库时,它需要去实例化具体的、由第三方厂商提供并随便塞在 ClassPath 下的 MySQL 驱动实现类。这就是明显的“顶层加载器要求加载底层的类”。为了解决这个倒反天罡的问题,Java 引入了线程上下文类加载器(Thread Context ClassLoader, TCCL),底层相当于让父加载器去借用子加载器的手打破了双亲委派委派链。 - Tomcat 容器实现:Tomcat 是主动选择破坏双亲委派的。每个 Web 应用下
WEB-INF/lib里的类,Tomcat 的WebappClassLoader会优先自己加载,而不是无脑委派给父级。为什么?为了保证不同 WAR 包如果用了版本完全不同、但全限定名一样的类(如一个用 Spring 3,一个用 Spring 5)能够互不影响,彼此隔离。
- SPI 机制(典型如 JDBC 驱动加载):核心的
六、 实际工作中怎么用
很多开发者觉得造轮子写加载器离自己很远,事实并非如此。理解底层,你可以解决以下高端场景局:
- 解决极其恶劣的依赖冲突(Jar Hell):
假如历史包袱太重,你的工程必须同时依赖
rocketmq-client-v1.jar和rocketmq-client-v2.jar,而这两者底层类名一模一样但方法完全变了。依靠 Maven 的 exclude 排包根本行不通。这时可以怎么做?参考阿里 SOFAArk 或 OSGi 的思路,写两个自定义的类加载器,分别去读取 V1 和 V2 包。利用“不同类加载器加载的同名类完全隔离”的特性,将冲突强行隔绝在两堵墙之内。 - 实现插件化系统或热部署(Hot Reload):
如何让线上系统不重启的情况下更新某个逻辑类? JVM 其实不支持同一个类加载器把同名类重复卸载和加载。但我们可以取巧:当需要更新代码时,直接把负责加载该组件的那个自定义 ClassLoader 实例废弃掉,被当作垃圾回收。然后用一个新的自定义加载器实例去读取最新的
.class文件。这正是 JRebel 以及大多数热修复工具更新类的核心机制之一。 - 加密与防反编译:
核心敏感代码容易被竞品拿去 jd-gui 丢反编译工具看光?可以先用 AES 把
.class文件加密成打乱的二进制。然后自定义一个ClassLoader,在findClass读取文件后,先走一遍 AES 解密,再调用底层的defineClass。这样,你的程序能正常运行,但直接拿你包出去解压出来看的人只会得到一堆不知所云的乱码。