JVM 类加载器如何保证核心类库不被覆盖?
结论先行:靠四道闸门层层拦截:双亲委派让加载请求先交给启动类加载器(bootstrap loader),核心类在它那里就命中返回,用户类根本没有机会定义同名类;包名保护在 ClassLoader.preDefineClass 里硬性拒绝任何加载器定义 java.* 开头的类;启动类加载器不可见,它由 C++ 实现、在 Java 层拿不到引用,无法被替换;模块封装(JDK 9+)进一步限制对 JDK 内部包的访问。四道闸门任意一道生效,就足以阻止「伪造 java.lang.String」。
一、四道闸门
| 闸门 | 生效位置 | 拦截效果 |
|---|---|---|
| 双亲委派 | ClassLoader.loadClass | 请求先给父加载器,核心类在 bootstrap 命中,子加载器不再尝试 |
| 包名检查 | ClassLoader.preDefineClass | 非 bootstrap 加载器定义 java.* 直接抛 SecurityException |
| 启动加载器不可见 | getParent() 返回 null | Java 代码无法得到它的引用,无法替换或包装 |
| 模块封装 | JPMS(JDK 9+) | 未导出包不可访问,非法的跨模块反射需 --add-opens |
用户代码: new MyLoader().loadClass("java.lang.String")
│
▼
MyLoader.loadClass ──委派──→ AppClassLoader ──委派──→ Platform ──委派──→ Bootstrap
│
命中:返回 JDK 的 String
│
└─ 若 Bootstrap 找不到(理论上不会),MyLoader 才 findClass
└─ defineClass("java.lang.String") → preDefineClass 抛
SecurityException: Prohibited package name: java.lang
二、亲手验证一次
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class HijackDemo extends ClassLoader {
private final Path classFile;
public HijackDemo(Path classFile) {
super(null); // parent=null 也无法绕过包名检查
this.classFile = classFile;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] bytes = Files.readAllBytes(classFile); // 自己伪造的 java.lang.String.class
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
public static void main(String[] args) throws Exception {
HijackDemo loader = new HijackDemo(Path.of("fake/java/lang/String.class"));
Class<?> c = loader.loadClass("java.lang.String"); // 委派给 bootstrap,直接命中真类
System.out.println(c.getClassLoader()); // null(bootstrap 加载)
loader.findClass("java.lang.String"); // 绕开委派直接定义
}
}
// 输出:
// null
// Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang
注意两点:loadClass 走委派后拿到的是真类,getClassLoader() 为 null 正是 bootstrap 的标志;而 findClass 直接调用时,defineClass 的包名检查会立刻拒绝。
三、历史上的「合法」覆盖
| 手段 | 版本 | 现状 |
|---|---|---|
-Xbootclasspath/p: 前置替换 rt.jar 内容 | JDK 8 及以前 | JDK 9 起移除(-Xbootclasspath 系列全部删除) |
把 jar 放进 jre/lib/ext | JDK 8 及以前 | JDK 9 起扩展机制被模块系统取代 |
--patch-module java.base=xxx.jar | JDK 9+ | 仍可用,但需显式打补丁,属测试/诊断手段,生产不建议 |
自定义加载器加载 java.* | 所有版本 | 始终被拒绝 |
所以「核心类库不可被覆盖」并不是绝对的密码学保证,而是「默认路径全部封死,只剩需要显式授权的诊断入口」。JDK 自己升级、打补丁用的就是 --patch-module 这类机制。
四、常见追问
| 追问 | 回答要点 |
|---|---|
| 双亲委派为什么能保证唯一性 | 同一个类名最终都由同一个父加载器定义,方法区里只有一份元数据 |
自己写加载器能加载 java.lang.String 吗 | 不能,preDefineClass 检查包名,抛 SecurityException |
| 加密 class 的加载器怎么工作 | 覆写 findClass,先解密再 defineClass,委派语义仍然保留 |
| 类隔离(多版本共存)怎么实现 | 每个版本一个加载器,且被隔离的类不能是 java.* 这类核心类 |
| 模块化之后还有什么保护 | 默认强封装,未导出包连反射都要 --add-opens,比包名检查更细 |
小结
核心类库安全靠「委派优先 + 包名白名单 + 引导加载器不可触达 + 模块封装」四层。答题时能指出 preDefineClass 和 Prohibited package name 这条硬拦截,比只说「双亲委派」更有说服力。