JVM 类加载器如何保证核心类库不被覆盖?
2026年03月02日
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于
Spring AI + Spring Boot3.x + JDK 21..., 点击查看; - 《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot3.x + JDK 17..., 点击查看项目介绍; 演示链接: http://116.62.199.48:7070/; - 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/
面试考察点
- 对类加载器机制的理解:面试官想确认你是否了解 Java 的类加载器体系,包括引导类加载器、扩展类加载器、应用程序类加载器,以及它们之间的父子关系。
- 双亲委派模型的掌握:这是核心考察点。面试官不仅仅想知道你听说过 “双亲委派”,更想让你解释清楚它的工作流程,以及它如何保障核心类库的 “唯一性” 和 “安全性”。
- 安全性与稳定性的认知:为什么需要防止核心类库被覆盖?面试官希望你能从 JVM 运行安全和应用稳定性的角度说明,如果核心类被篡改可能带来的严重后果(如类型混淆、权限失控)。
- 实际应用场景:比如在开发自定义类加载器(如 Web 容器、热部署框架)时,你是否考虑过破坏双亲委派的风险,以及如何正确使用线程上下文类加载器解决 SPI 等问题。
核心答案
JVM 通过 双亲委派模型 来保证核心类库不会被覆盖。当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成。每一层加载器都是如此,因此所有的加载请求最终都应该传递到最顶层的 启动类加载器(Bootstrap ClassLoader) 中。只有当父类加载器在自己负责的搜索范围内无法找到所需的类时,子加载器才会尝试自己加载。
这样一来,对于像 java.lang.String 这样的核心类,无论你是在哪里定义的,都会首先被启动类加载器尝试加载。JVM 已经内置了 rt.jar 中的标准 String 类,所以启动类加载器会加载成功,然后就直接返回这个已经被加载的类。你在应用程序中编写的任何同名类都不会被加载,从而避免了核心类库被自定义类覆盖的风险。
深度解析
原理/机制
-
类加载器层次结构(以 JDK 8 为例):
- Bootstrap ClassLoader:用 C++ 实现,负责加载
%JAVA_HOME%/lib目录下的核心类(如rt.jar),是最高层的加载器。 - Extension ClassLoader:负责加载
%JAVA_HOME%/lib/ext目录下的扩展类,父加载器是 Bootstrap。 - Application ClassLoader:负责加载
classpath上的类,也就是我们平时写的业务类,父加载器是 Extension。 - 自定义类加载器:一般继承
ClassLoader,父加载器通常是 Application。
- Bootstrap ClassLoader:用 C++ 实现,负责加载
-
双亲委派的工作流程:
- 当你通过自定义类加载器去加载
java.lang.String时,它会先询问父加载器(Application)是否已加载。 - Application 又去问 Extension,Extension 再去问 Bootstrap。
- Bootstrap 检查自己负责的路径,发现已经有
java.lang.String存在,就直接加载并返回。 - 结果就是,整个 JVM 中只会存在一份由 Bootstrap 加载的核心
String类,任何地方访问的都是同一个类,保证了类型的一致性。
- 当你通过自定义类加载器去加载
代码示例
下面是一个简化版的双亲委派实现,可以在 ClassLoader 的源码 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 {
// 如果没有父加载器,说明是顶层,调用 Bootstrap 加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父加载器抛出异常,说明父加载器无法完成加载
}
if (c == null) {
// 父加载器加载失败,才自己尝试加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
可以看到,加载过程总是优先检查父加载器,只有父加载器无法加载时,才调用自己的 findClass。这就是“双亲委派”的代码体现。
对比分析:如果没有双亲委派会怎样?
- 假设你自定义了一个
java.lang.String类,并且用一个没有双亲委派的类加载器去加载它。 - 此时 JVM 中就可能同时存在两个不同的
String类:一个由 Bootstrap 加载,一个由你的类加载器加载。 - 这会导致严重的类型混乱:比如你的代码里 new 出来的
String对象,和通过反射或其他方式获取的String对象,可能分属不同的类加载器,它们之间无法互相赋值,因为 JVM 认为它们是不同的类型。 - 更危险的是,如果你篡改了
String的核心方法(如equals、hashCode),可能会破坏所有依赖这些方法的集合类(HashMap等),引发无法预料的错误甚至安全漏洞(比如自定义的String可能绕过权限检查)。
最佳实践
- 自定义类加载器时,务必遵循双亲委派模型:重写
findClass而不是loadClass,这样就能保留默认的委托逻辑。除非你非常清楚自己在做什么,并且确实需要打破双亲委派(例如实现模块热部署、OSGi 等),否则不要重写loadClass。 - 处理 SPI 场景时,使用线程上下文类加载器:有些情况下(如 JDBC 驱动加载),核心类(如
DriverManager)是由 Bootstrap 加载的,但它需要调用由应用程序类加载器加载的具体驱动实现。此时就需要打破双亲委派,使用Thread.currentThread().getContextClassLoader()来获取应用程序类加载器进行加载。这是一种“逆向”的委派,但它是规范的做法。
常见误区
- 误区 1:认为双亲委派是 JVM 强制执行的。实际上,类加载器的双亲委派是通过
ClassLoader类的默认loadClass实现实现的,开发者完全可以通过重写loadClass来打破它。例如 Tomcat 为了隔离不同 Web 应用,就自定义了优先加载 Web 应用类的类加载器。 - 误区 2:混淆 “双亲委派” 和 “类加载器隔离”。双亲委派是为了保证核心类的一致性,而类加载器隔离(如 Tomcat 的 WebAppClassLoader)则是在安全的前提下,让每个应用拥有自己的类空间,两者并不矛盾。
- 误区 3:认为只有核心类库才受保护。实际上,任何类只要被父加载器加载过,子加载器都不会再去加载,所以双亲委派保证了类在 JVM 中的唯一性,不仅仅是核心类。
总结
JVM 通过双亲委派模型,让核心类库优先由顶层的引导类加载器加载,从而防止了用户自定义的类恶意或无意地覆盖核心 API,这是 Java 类型安全和运行稳定的重要基石。理解并合理使用这一机制,是开发可靠 Java 应用的必要前提。