双亲委派模型
双亲委派是类加载器之间的一种协作约定:收到加载请求时,先委派给父加载器,父加载器无法完成时才自己加载。它保证了 java.lang.String 无论在哪个加载器下都指向同一个类,是 Java 安全模型的地基。但在 SPI、热部署、模块化和 Tomcat 等场景中,这个模型会被有意打破。本文讲清规则、破坏原因和替代方案。
模型的结构
类加载器的层次关系是「父子」而非「继承」——通过组合关系持有 parent 引用,而不是 extends。
Bootstrap ClassLoader
↑
Platform ClassLoader(JDK 8 为 Extension)
↑
Application ClassLoader
↑
自定义 ClassLoader
JDK 8 与 JDK 17 的差异:
| 项目 | JDK 8 | JDK 17 |
| --- | --- | --- |
| 第二层名称 | ExtClassLoader | PlatformClassLoader |
| 核心类路径 | rt.jar | jrt:/ 模块镜像 |
| 扩展机制 | lib/ext 目录 | 平台模块,--add-modules |
| 获取方式 | ClassLoader.getSystemClassLoader().getParent() | 同上,返回 Platform |
Bootstrap ClassLoader 在 Java 中拿不到引用,String.class.getClassLoader() 返回 null 就是这个原因。
工作流程
ClassLoader.loadClass 的核心逻辑是:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 已加载则直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 2. 父加载器不为空则委派
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 3. 父加载器为空则委派给 Bootstrap
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器无法加载,继续
}
if (c == null) {
// 4. 自己加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
四个步骤对应委派的核心:先查缓存,再向上委派,最后自己加载。注意 findBootstrapClassOrNull 在 JDK 中是通过 native 方法实现的,Java 层看不到。
委派带来的两个保证
- 唯一性:同一个类在同一个加载器层次中只会被加载一次,避免核心类被替换。
- 安全性:用户自定义的
java.lang.String永远不会被加载,因为 Bootstrap 已经加载了真正的String。
如果用户写了一个同名类并放在 classpath 上,JVM 会优先使用 Bootstrap 加载的版本,自定义版本被静默忽略。这是「同名类不生效」问题最常见的成因。
破坏双亲委派的三次浪潮
第一次:JDK 1.2 之前
双亲委派在 JDK 1.2 才引入,此前的 loadClass 是抽象方法,用户必须重写。为了兼容,JDK 1.2 新增了 findClass,并让 loadClass 变成可重写但默认实现委派逻辑的方法。这是历史包袱,不是设计破坏。
第二次:SPI 与线程上下文类加载器
SPI(Service Provider Interface)要求接口由 Bootstrap 加载,实现由 Application 加载。但 Bootstrap 无法「向下」加载 classpath 中的实现类。
以 JDBC 为例:java.sql.Driver 在 java.sql 模块中,由 Bootstrap 加载;com.mysql.cj.jdbc.Driver 在 classpath 中,由 Application 加载。Bootstrap 要加载实现类,就必须突破层次限制。
解决方案是线程上下文类加载器(Thread Context ClassLoader,TCCL):
ServiceLoader<Driver> loader = ServiceLoader.load(Driver.class);
for (Driver driver : loader) {
System.out.println(driver.getClass().getName());
}
ServiceLoader.load 内部使用 Thread.currentThread().getContextClassLoader() 来加载实现类。默认情况下 TCCL 就是 Application ClassLoader,因此可以加载到 classpath 中的实现。
这是「父加载器请求子加载器加载」的典型场景,方向与双亲委派相反。
第三次:热部署与模块化
OSGi 和 Tomcat 需要「同一个类在不同模块中有不同版本」,这要求打破唯一性。Tomcat 的类加载结构是:
Bootstrap
↑
System
↑
Common
↑
├── Webapp1
└── Webapp2
每个 Web 应用有独立的 WebappClassLoader,它优先加载自己的 WEB-INF/classes 和 WEB-INF/lib,加载不到才委派给 Common。这被称为「子优先」或「破坏双亲委派」,目的是实现应用隔离。
Tomcat 的 WebappClassLoaderBase.loadClass 明确实现了这个顺序:先查本地缓存,再查自己的仓库,最后才委派父加载器。唯一的例外是 javax.servlet 等容器共享的类,这些被强制委派。
线程上下文类加载器的正确用法
TCCL 的本质是给「父加载器」一个向下查找的入口。框架在启动时把当前线程的 TCCL 设置为应用加载器,之后任何需要加载实现类的地方都通过它获取。
ClassLoader tccl = Thread.currentThread().getContextClassLoader();
try {
Thread.currentThread().setContextClassLoader(customLoader);
// 此处 ServiceLoader 会用 customLoader 加载实现
ServiceLoader<Codec> codecs = ServiceLoader.load(Codec.class);
codecs.forEach(System.out::println);
} finally {
Thread.currentThread().setContextClassLoader(tccl);
}
务必用 try-finally 恢复原值。如果忘记恢复,后续复用该线程的请求会继承错误的加载器,表现为「偶发找不到类」——这类问题在压测时几乎不出现,只在线上特定线程上复现。
如何确认一个类由谁加载
排查类冲突时,第一步是确认加载器身份:
public class WhoLoadedMe {
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("com.mysql.cj.jdbc.Driver");
ClassLoader loader = clazz.getClassLoader();
System.out.println("class = " + clazz);
System.out.println("loader = " + loader);
System.out.println("loader parent = " + loader.getParent());
System.out.println("code source = " + clazz.getProtectionDomain().getCodeSource().getLocation());
}
}
CodeSource 会给出该类来自哪个 jar 或目录。当同一个类出现在多个依赖中时,输出能直接指出实际生效的那一份,比逐个翻 mvn dependency:tree 更快。
隔离方案对比
打破委派的目的都是隔离,但实现层次不同:
| 方案 | 隔离粒度 | 委派方向 | 代价 |
| --- | --- | --- | --- |
| 默认双亲委派 | 无 | 父优先 | 无法多版本共存 |
| Tomcat Webapp | 每个 Web 应用 | 子优先,容器类除外 | 应用间类型不兼容 |
| OSGi | 每个 Bundle | 显式 Import/Export 包 | 复杂度高,学习成本大 |
| JDK 9 模块层 | 每个 Layer | 按模块图解析 | 需要模块化改造 |
JDK 9 引入的 ModuleLayer 提供了一种标准化的隔离手段,可以在同一进程内加载多个版本的模块,而不必自己实现类加载器:
ModuleLayer layer = ModuleLayer.boot();
layer.modules().forEach(m -> System.out.println(m.getName() + " <- " + m.getClassLoader()));
多版本共存仍然困难,因为模块图在启动时确定,动态加载需要新建 Layer。对绝大多数业务应用来说,Tomcat 式的子优先已经够用,OSGi 的收益往往抵不过维护成本。
如何自定义加载器而不破坏模型
如果只是想从特殊位置加载类,应该重写 findClass 而不是 loadClass。这样既能保持委派语义,又能扩展加载来源。
public class EncryptedClassLoader extends ClassLoader {
private final Path dir;
private final byte key;
public EncryptedClassLoader(Path dir, byte key, ClassLoader parent) {
super(parent);
this.dir = dir;
this.key = key;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] encrypted = Files.readAllBytes(dir.resolve(name.replace('.', '/') + ".class"));
byte[] bytes = new byte[encrypted.length];
for (int i = 0; i < encrypted.length; i++) {
bytes[i] = (byte) (encrypted[i] ^ key);
}
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}
重写 loadClass 才会改变委派顺序,只有实现隔离或热部署时才需要这么做。
常见坑 / 最佳实践
- 在自定义加载器中直接重写
loadClass并复制父类逻辑。一旦 JDK 升级导致loadClass实现变化(如 JDK 17 增加了并行加载能力),复制来的代码会失去这些改进。优先重写findClass。 - 同一个类被两个加载器加载后做强制类型转换。
com.foo.User在加载器 A 和 B 中是两个不同的Class,转换必抛ClassCastException。跨加载器传递对象应使用接口加公共父加载器,或使用序列化/JSON 转换。 - 依赖 TCCL 的框架在异步线程中失效。线程池复用线程时 TCCL 不会自动传递,导致
ServiceLoader找不到实现。应在任务提交时捕获getContextClassLoader()并在任务内设置。 - Tomcat 下把依赖放在
$CATALINA_HOME/lib。这会加载到 Common 加载器,被所有应用共享,版本冲突和类泄漏风险陡增。应用依赖应放在WEB-INF/lib。 - JDK 17 上通过反射访问核心类内部。模块化后
--illegal-access默认拒绝,需要显式--add-opens java.base/java.lang=ALL-UNNAMED,否则大量框架会报InaccessibleObjectException。
小结
- 双亲委派通过「先委派父加载器,失败再自己加载」保证类的唯一性和核心类安全。
- 实现上重写
findClass是扩展加载来源,重写loadClass才是改变委派顺序。 - SPI 通过线程上下文类加载器实现父加载器请求子加载器,是第一次真正的破坏。
- Tomcat 等容器用子优先的加载器实现应用隔离,代价是跨应用类型不兼容。
- JDK 9 起扩展加载器改为平台加载器,核心类由模块系统管理,
rt.jar已不存在。
- 点赞
- 收藏
- 关注作者
评论(0)