图 1:本文封面,点明两个考点「为什么存在」与「怎么打破」。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「说说你了解的类加载器,可以打破双亲委派吗,怎么打破」——这道题表面考类加载,实际考的是你对「约定为什么存在、何时该开例外」的工程判断力。
先铺背景。Java 的类由类加载器读入 JVM,默认有三层:启动类加载器负责 Java 核心类库(java.lang 包那些),扩展类加载器负责扩展目录的类,应用类加载器负责你自己 classpath 下的类。双亲委派是它们之间的协作约定:一个类加载器收到加载请求后,先不自己动手,而是把请求委托给父加载器;父级一路向上委托,只有父级加载不了,才轮到子级尝试。
为什么需要这个约定?它保证两件性命攸关的事。
第一,核心类不被篡改。假设你在自己的代码里写了一个 java.lang.String,如果没有委派,应用类加载器会直接加载你这个版本,核心类库被偷梁换柱,安全体系形同虚设。有了委派,请求先上交到启动类加载器,加载的永远是正版核心类——你写的同名类永远轮不到登场。
第二,同一个类只被加载一次。类的唯一性由「类全名 + 加载它的类加载器」共同决定。委派让所有加载请求汇聚到最高层能处理的加载器,配合加载缓存,保证全 JVM 范围内同一个类只有一份定义,类型比较、单例语义才不会出灵异问题。
用代码把这条链路跑一遍。
下面用 Node.js 22.15.0 模拟三级加载器的委派过程(node classloader.js),直观看「核心类不被篡改」和「SPI 困境」:
'use strict';
// 模拟三级类加载器的双亲委派:先委托父级,父级加载不了才自己来
function makeLoader(name, classes, parent = null) {
const cache = new Map(); // 已加载的类:保证同一个类只加载一次
return {
name,
loadClass(className, via = '委派链') {
if (cache.has(className)) {
return { className, loadedBy: cache.get(className), note: '命中缓存,不重复加载' };
}
if (parent) {
try {
return parent.loadClass(className, via); // 第一步永远是向上委托
} catch {
// 父级整条链都加载不了,才轮到自己
}
}
if (!classes.has(className)) throw new Error(`ClassNotFound: ${className}`);
cache.set(className, name);
return { className, loadedBy: name, note: `经${via}加载` };
},
};
}
const bootstrap = makeLoader('启动类加载器', new Set(['java.lang.String', 'java.sql.Driver']));
const extension = makeLoader('扩展类加载器', new Set(['sun.misc.Unsafe']), bootstrap);
const app = makeLoader('应用类加载器', new Set(['com.demo.Main', 'com.demo.String', 'com.mysql.jdbc.Driver']), extension);
// 场景一:应用类加载器请求加载 java.lang.String——即使你自己写了一个,也轮不到你的
console.log('1)', app.loadClass('java.lang.String'));
console.log('2)', app.loadClass('com.demo.Main'));
// 场景二:同一个类重复请求,第二次走缓存
app.loadClass('com.demo.Main');
console.log('3)', app.loadClass('com.demo.Main'));
// 场景三:SPI 困境——Driver 接口在启动类加载器,实现类在应用类加载器,父级看不见子级的类
try {
bootstrap.loadClass('com.mysql.jdbc.Driver');
} catch (e) {
console.log('4) 启动类加载器直接加载 SPI 实现:', e.message);
}
// 解法:线程上下文类加载器,父级拿到「倒过来的通道」反向加载
const contextLoader = app; // 线程上下文类加载器默认就是应用类加载器
console.log('5)', contextLoader.loadClass('com.mysql.jdbc.Driver', '线程上下文类加载器'));
实测输出:
1) { className: 'java.lang.String', loadedBy: '启动类加载器', note: '经委派链加载' }
2) { className: 'com.demo.Main', loadedBy: '应用类加载器', note: '经委派链加载' }
3) { className: 'com.demo.Main', loadedBy: '应用类加载器', note: '命中缓存,不重复加载' }
4) 启动类加载器直接加载 SPI 实现: ClassNotFound: com.mysql.jdbc.Driver
5) {
className: 'com.mysql.jdbc.Driver',
loadedBy: '应用类加载器',
note: '经线程上下文类加载器加载'
}
第 1 行是关键证据:请求从应用类加载器发出,最终却是启动类加载器完成加载——你写在应用路径下的同名类根本没有机会。第 4、5 行则引出打破委派的经典场景。
什么时候必须打破委派?三种典型。
一,SPI 机制。接口定义在核心类库(启动类加载器),实现却躺在用户的 classpath 里——父级按规定看不见子级的类,数据库驱动加载就是这般困境。解法是线程上下文类加载器:父级通过这条「倒过来的通道」反向找到应用类加载器完成加载,模拟输出的第 5 行演示的正是它。
二,热部署与插件化。模块要动态装卸,卸载一个类的前提是它的类加载器整体被回收。给每个模块配独立的类加载器、自己优先加载,才能实现隔离与热替换。
三,容器多应用。一个容器跑多个应用,同名类(甚至同一依赖的不同版本)必须互不干扰,各应用用自己的加载器优先加载,把「委派优先」反转成「隔离优先」。
图 2:委托链、两个保证与三种打破方式。
易错点清单。
一,把「打破委派」理解成推翻模型——三种场景都是在委派之外开一条受控旁路,默认路径依然完好。二,答 SPI 时说不清「为什么父级加载不了实现类」——根因是加载器的可见性单向向上。三,以为自定义类加载器就等于打破委派——自定义加载器照样遵守委派,只有改变「先问父级」的顺序才算。四,忘记类的唯一性包含加载器维度,解释不清为什么同名类能共存于多应用场景。
回到面试现场,建议的答题结构:三级加载器一句话带过,重点讲委派流程与两个保证,再用「数据库驱动加载」把 SPI 困境与上下文类加载器讲透,最后补热部署与容器隔离两个场景。AI 平台常要动态加载用户自定义算子与插件,这套「何时守约定、何时开旁路」的判断,正是平台化改造的日常。
你的项目里有需要打破双亲委派的场景吗?插件隔离还是热部署?欢迎聊聊你的方案。