本节摘要:类加载器不只是"取字节流的人",更是一套决定两个类是否相等的命名制度。本节拆解启动、平台、应用三级加载器的分工,讲透双亲委派的运作与动机,再摆出它必须被打破的三个经典场景——SPI、热部署、容器隔离,并给出自定义加载器的最小可跑实现。
上一节末尾留下的问题现在接上:加载阶段"按名取流"由谁执行?答案先亮出来——类加载器,而且它同时干着两件事:物理上找到字节流,逻辑上给类划定命名空间。第二件事常被忽略却更根本:在 JVM 里,一个类的唯一身份是"加载器实例加上全限定名",两个同名的类只要加载器不同,就互不相等。理解了这条身份规则,容器隔离、依赖冲突、热部署失效等现象全部有了统一的解释框架。
JDK 9 之后的体系是三级(此前是启动、扩展、应用三级,扩展级在模块化改造后让位给平台级):启动类加载器(Bootstrap)用本地代码实现,负责最核心的类库(java.base 等启动模块),在 Java 侧表现为 null;平台类加载器(Platform)负责模块化后的标准扩展模块;应用类加载器(Application,也叫系统类加载器)负责 classpath 上你自己的类,没有指定时它是默认加载器。
JDK 8 及以前的应用还能打印到 ExtClassLoader;JDK 9 以后同样的代码打出来的是 PlatformClassLoader——看到资料里还在讲"扩展类加载器",先核对 JDK 版本再下结论。
public class LoaderPeek { public static void main(String[] args) { System.out.println(String.class.getClassLoader()); // null,启动加载器 System.out.println(javax.net.ssl.SSLSocketFactory.class.getClassLoader()); // 平台级 System.out.println(LoaderPeek.class.getClassLoader()); // 应用级 } }
$ java LoaderPeek null platform jdk.internal.loader.ClassLoaders$AppClassLoader@...

规则一句话:收到加载请求先委派给父级,父级反馈找不到才允许自己动手。它防的事很具体——假如没有这套秩序,用户在 classpath 放一个手写的 java.lang.String,应用加载器就会加载它并广泛传播,核心类库的信任链条立刻崩塌。有了委派,任何 String 请求最终都由启动级答复,篡名类根本没有入场机会。顺带的第二个好处是避免重复加载:父级已加载的类,子级不会再造一份。
防篡改的能力可以实验验证。手写一个 java.lang.String 丢到 classpath:
package java.lang; public class String { public static void main(String[] args) { System.out.println("fake string"); } }
$ java java.lang.String 错误: 在类 java.lang.String 中找不到 main 方法
报错说明运行起来的 String 不是你的那个假货——真正的 String 没有这个 main,委派机制把请求交给了启动级,你的类被晾在一边。同时这条报错还顺带演示了另一层保护:哪怕委派给启动级加载,核心包(java 开头)的类也不允许被应用代码声明。
委派的秩序在两种需求面前失灵。其一是"父级接口要回调子级实现":JDBC 的 DriverManager 由平台级(老版本为启动级)加载,但各厂商的驱动实现类在应用 classpath 上——父加载器按委派规则看不见子级的类,这就是著名的 SPI 困境。Java 的解法是线程上下文类加载器:核心库借这个"后门"临时反向使用应用加载器,DriverManager 靠它找到并加载驱动实现。说 SPI "打破"了双亲委派,打破的正是"父不能向下看"这个限制。
其二是隔离需求:Web 容器要求两个应用各自加载自己的 Spring 版本互不干扰。办法是给每个应用一个独立的 WebAppClassLoader,且故意不先委派(或部分委派):应用自己的类自己加载,只有 JDK 与容器共享类才向上交——把委派方向按需反转,同名类就能在不同加载器里和平共处(类的身份规则保证了它们互不相等)。
其三是热部署:不断丢弃旧加载器、新建加载器重装同一批类。类的卸载条件(加载器死亡)决定了这是唯一路径。理解这三类场景,再看 OSGi 的模块化网状委派、Tomcat 的加载器树,就都是同一主题的变奏。
JDK 9 的模块系统 JPMS 从另一条路提供了官方版隔离:模块在声明里写明 exports 与 requires,加载前就能判定可见性,模块级加载器按模块图分发请求。它解决的是"标准化封装",与容器的"应用级隔离"互补而非替代——如今写普通应用感知不到它,但理解它能帮你看懂 JDK 自身的类库重组。
自定义加载器只需继承 ClassLoader 并重写 findClass(保持 loadClass 的委派逻辑不被破坏):
import java.nio.file.*; public class Reloader extends ClassLoader { private final Path baseDir; public Reloader(Path baseDir, ClassLoader parent) { super(parent); this.baseDir = baseDir; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { try { byte[] bytes = Files.readAllBytes( baseDir.resolve(name.replace('.', '/') + ".class")); return defineClass(name, bytes, 0, bytes.length); } catch (java.io.IOException e) { throw new ClassNotFoundException(name, e); } } public static void main(String[] args) throws Exception { Path dir = Paths.get(args[0]); Class<?> c1 = new Reloader(dir, Reloader.class.getClassLoader()) .loadClass("com.demo.Task"); System.out.println("first loader: " + c1.getClassLoader()); System.out.println("instance works: " + c1.getConstructor().newInstance()); } }
要"热替换",就丢弃旧 Reloader 实例再 new 一个加载同一份字节码——你会得到两个身份不同、字节码相同的类。每替换一轮,旧加载器及其全部类要等它真正不可达后才被回收,Metaspace 才随之回落;热部署泄漏(第二章埋过的伏笔)的根源就是旧加载器被静态字段、线程、监听器等长期引用而死不透。
⚠️ 常见坑:图省事重写 loadClass 而不调父级逻辑,等于亲手拆掉委派链。隔离型加载器(容器场景)确实需要定制 loadClass,但必须明确知道自己在豁免哪些前缀、向上保留哪些;普通场景一律重写 findClass,让父类模板守住秩序。
💡 关键直觉:双亲委派不是安全规则而是"命名空间即身份"这条规则的运行策略。判断任何加载器设计的合理性,都可以归结为一个问题——它让哪些类被谁看见,从而决定了哪些类相等、哪些不相等。
加载器制度看透了,"按名取流"的最后一层窗户纸还剩一块:字节流可以运行时现造。下一节进入字节码生成的世界——动态代理如何凭空造出一个类,ASM 与 Byte Buddy 的塑形能力边界在哪,代价又是什么。