JVM 类加载器如何保证核心类库不被覆盖?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书
《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于
Spring AI + Spring Boot 3.x + JDK 21...,查看介绍《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...,查看介绍;演示链接:http://116.62.199.48:7070/《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/
新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍
截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
双亲委派机制理解:面试官不仅仅是想知道你听过 "双亲委派" 这四个字,更是想知道你是否清楚完整的委派流程——从子加载器到父加载器,层层往上,每一层干了什么。
-
安全性思维:考察你是否理解类加载器的安全属性——不只是委派,JVM 在
ClassLoader实现层面也做了保护,防止恶意代码伪装核心类。 -
边界意识:是否知道双亲委派不是铁板一块,有哪些场景会打破它(SPI、OSGi、热部署等),以及打破之后会带来什么问题。
核心答案
JVM 通过 3 层防线 保证核心类库不被覆盖:
| 防线 | 机制 | 作用 |
|---|---|---|
| 第一层 | 双亲委派机制 | 加载类时先委托父加载器,父加载器能加载就不自己加载 |
| 第二层 | ClassLoader 源码保护 |
loadClass() 方法优先检查父加载器,且核心类库由 BootstrapClassLoader 独占 |
| 第三层 | SecurityManager + 包名校验 |
禁止自定义类使用 java. 开头的包名,防止伪装核心类 |
深度解析
一、类加载器的层级关系
先搞清楚 Java 里的类加载器是怎么分层级的,这是理解双亲委派的前提:
上图展示了 JVM 类加载器的四层结构。有几个关键点:
BootstrapClassLoader是最顶层的,由 C++ 实现(所以你在 Java 代码里打印它的getClass()会得到null),它负责加载 JVM 运行所需的最核心的类,比如java.lang.Object、java.lang.String这些。- 各加载器之间是 组合关系,不是继承关系。每个
ClassLoader内部都有一个parent字段指向父加载器。 - 加载的范围是严格分开的:
Bootstrap加lib核心,Extension加ext扩展,Application加classpath应用。
二、双亲委派——核心保护机制
这是保证核心类库不被覆盖的 最核心机制。
双亲委派的核心逻辑就一句话:先问爸爸能不能加载,爸爸搞不定再自己来。
上面是加载 java.lang.String 的完整流程。即使你在自己的项目里写了一个 java.lang.String 类,ApplicationClassLoader 也不会加载它,因为请求最终会委派到 BootstrapClassLoader,由它加载 JDK 自带的版本。
用代码验证一下:
public class StringTest {
public static void main(String[] args) {
// 自己写一个 java.lang.String 放到 classpath 下
// 打印看看加载的是哪个 String
String s = new String("hello");
System.out.println(s.getClass().getClassLoader());
// 输出: null
// null 表示是 BootstrapClassLoader 加载的(JDK 自带的)
// 而不是你的 ApplicationClassLoader 加载的伪造版本
}
}
三、ClassLoader 源码层面的保护
双亲委派不只是"约定",在 ClassLoader 的源码里写死了。来看 java.lang.ClassLoader.loadClass() 的核心逻辑(JDK 8):
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// ① 加锁,保证线程安全
synchronized (getClassLoadingLock(name)) {
// ② 先检查是否已经被加载过(每个 ClassLoader 有自己的缓存)
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);
}
}
return c;
}
}
关键步骤:
- ② 先查缓存:每个类加载器内部有一个
classes缓存(底层是个HashMap),如果之前加载过这个类,直接返回。这保证了同一个类不会被重复加载。 - ③ 委托父加载器:
parent.loadClass(name, false),递归地向上委派。 - ⑤ 自己加载:只有父加载器全都
ClassNotFoundException之后,才会调用自己的findClass()。
这意味着什么?就算你自定义一个 ClassLoader,重写 findClass() 方法,试图加载一个 java.lang.String——也没用,因为这个请求在步骤 ③ 就已经被父加载器拦截了。
四、包名校验——最后一道保险
就算有人绕过了双亲委派(比如自定义 ClassLoader 重写 loadClass()),JVM 还有最后一道防线:包名校验。
// 假设你写了一个恶意的自定义 ClassLoader,绕过双亲委派
public class EvilClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) {
// 直接自己加载,不委托给 parent!
return findClass(name); // 试图加载自己定义的 java.lang.String
}
}
JVM 在类加载的 验证阶段 会检查:如果类名以 java. 开头,必须由 BootstrapClassLoader 加载。否则直接抛出 SecurityException:
java.lang.SecurityException: Prohibited package name: java.lang
这个校验在 ClassLoader 的 defineClass() 方法中实现:
// java.lang.ClassLoader#defineClass() 源码(简化)
protected final Class<?> defineClass(String name, byte[] b, ...) {
// 检查包名
if (checkName(name) && name.startsWith("java.")) {
// java. 开头的包名,只允许 BootstrapClassLoader 定义
// 其他 ClassLoader 尝试定义直接抛 SecurityException
throw new SecurityException("Prohibited package name: " + name);
}
// ...
}
所以,即使你绕过了双亲委派,也过不了这一关。
五、打破双亲委派的场景
双亲委派是个好设计,但不是没有例外。面试官很可能追问这个:
| 场景 | 怎么打破的 | 为什么需要打破 |
|---|---|---|
| SPI(ServiceLoader) | 线程上下文类加载器(Thread ContextClassLoader) |
BootstrapClassLoader 看不到 classpath 的实现类 |
| Tomcat | 每个 WebApp 有独立的 ClassLoader,优先自己加载 |
不同应用可能依赖同一个库的不同版本 |
| OSGi | 模块化的类加载器网状结构 | 模块间热部署、动态加载 |
| 热部署(热加载) | 每次加载创建新的 ClassLoader 实例 |
丢弃旧的 ClassLoader 让旧类被 GC 回收 |
其中 SPI 是最经典的例子。JDBC 的接口(java.sql.Driver)在 rt.jar 里,由 BootstrapClassLoader 加载。但具体实现(比如 MySQL Driver)在 classpath 下,BootstrapClassLoader 根本看不到 classpath。怎么解决?
// java.sql.DriverManager 使用线程上下文类加载器加载 SPI 实现
// 线程上下文类加载器默认是 ApplicationClassLoader
ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 用 ApplicationClassLoader 去加载 classpath 下的 MySQL Driver
Class<?> driverClass = cl.loadClass("com.mysql.cj.jdbc.Driver");
这就是"父加载器"反过来委托"子加载器"的典型案例。JDBC 这个设计确实有点无奈,但也是 JDK 6 引入 SPI 机制后最优雅的解法了。
六、JDK 9+ 模块化的变化
JDK 9 引入了模块化系统(JPMS),类加载器有了变化:
ExtensionClassLoader改名为PlatformClassLoaderBootstrapClassLoader不再只加载rt.jar(因为rt.jar没了),改为加载平台模块- 模块的
module-info.java中可以声明哪些包对外暴露(exports),哪些只在模块内可见 - 新增了 Module Layer 机制,提供了比双亲委派更灵活的类加载策略
不过双亲委派的核心思想没有变,只是在模块化层面多了一层封装和控制。
面试高频追问
-
追问一:能不能自己写一个
java.lang.String替换 JDK 的?不能。三层防线都挡着:双亲委派会让
BootstrapClassLoader先加载 JDK 自带的版本;即使绕过双亲委派,defineClass()会抛SecurityException: Prohibited package name: java.lang;如果还想绕,SecurityManager也会拦截。 -
追问二:双亲委派有什么缺点?
最大的问题是 灵活性不足。比如上面说的 SPI 场景,父加载器加载的接口需要用到子加载器才能看到的实现类,双亲委派天然不支持这种 "向下委托",只能用线程上下文类加载器这种比较 hack 的方式绕过。
-
追问三:Tomcat 是怎么打破双亲委派的?
Tomcat 的每个 Web 应用都有独立的
WebAppClassLoader,它加载类时的顺序是:先检查是否已加载 → 自己的WEB-INF/classes和WEB-INF/lib→ 委托给父加载器。和标准双亲委派 相反,是先自己加载,加载不到再委托给父。这样不同应用可以用同一个库的不同版本,互不干扰。
常见面试变体
- "什么是双亲委派模型?为什么要这样设计?"
- "如何破坏双亲委派机制?有哪些实际场景?"
- "Tomcat 的类加载器是怎么设计的?"
- "
ClassLoader的loadClass()和findClass()有什么区别?"
记忆口诀
三层防线:双亲委派(先问爹)→ 源码保护(loadClass() 写死了委派逻辑)→ 包名校验(java. 包名禁止自定义加载)。一个例外:SPI 用线程上下文类加载器打破委派。
总结
JVM 保护核心类库不被覆盖,靠的是 双亲委派 + defineClass() 包名校验 + SecurityManager 三层防线。其中双亲委派是核心:所有类加载请求先向上委托到 BootstrapClassLoader,核心类由它独占加载,下层加载器没有机会覆盖。面试时把这个机制讲清楚,再提一下 SPI 和 Tomcat 打破双亲委派的场景,基本满分。
