深蓝黑背景品牌封面:左侧大标题「双亲委派为什么要存在」,副标题「能不能打破、怎么打破」;右侧原创装饰——三个自上而下叠放的类加载器方块(启动、扩展、应用),请求自底部向上逐级委托的箭头贯穿三层;左上标注「经典题解析 · Java 后端」,右上标注「Day 06 · 10-04」 图 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 行演示的正是它。

二,热部署与插件化。模块要动态装卸,卸载一个类的前提是它的类加载器整体被回收。给每个模块配独立的类加载器、自己优先加载,才能实现隔离与热替换。

三,容器多应用。一个容器跑多个应用,同名类(甚至同一依赖的不同版本)必须互不干扰,各应用用自己的加载器优先加载,把「委派优先」反转成「隔离优先」。

机制图:左侧是双亲委派的委托流程——应用类加载器收到加载请求后不自己动手,先委托扩展类加载器,再委托启动类加载器;父级能加载就由父级加载,父级加载不了才逐层下放回子级。它保证两件事:核心类不被篡改(用户自定义的 java.lang 类永远轮不到加载)、同一个类只被加载一次。右侧是需要打破委派的三种典型场景:①SPI 机制——核心接口在启动类加载器,实现类在 classpath,父级看不见子级的类,用线程上下文类加载器反向加载;②热部署与插件化——每个模块独立类加载器,自己优先加载实现隔离;③容器多应用——各应用同名类互不干扰。底部提示:打破不是推翻,而是在委派之外开一条受控的旁路 图 2:委托链、两个保证与三种打破方式。

易错点清单。

一,把「打破委派」理解成推翻模型——三种场景都是在委派之外开一条受控旁路,默认路径依然完好。二,答 SPI 时说不清「为什么父级加载不了实现类」——根因是加载器的可见性单向向上。三,以为自定义类加载器就等于打破委派——自定义加载器照样遵守委派,只有改变「先问父级」的顺序才算。四,忘记类的唯一性包含加载器维度,解释不清为什么同名类能共存于多应用场景。

回到面试现场,建议的答题结构:三级加载器一句话带过,重点讲委派流程与两个保证,再用「数据库驱动加载」把 SPI 困境与上下文类加载器讲透,最后补热部署与容器隔离两个场景。AI 平台常要动态加载用户自定义算子与插件,这套「何时守约定、何时开旁路」的判断,正是平台化改造的日常。

你的项目里有需要打破双亲委派的场景吗?插件隔离还是热部署?欢迎聊聊你的方案。