Java 类加载机制:双亲委派、打破与热替换的实战
适合写过自定义 ClassLoader、见过ClassNotFoundException但不知道具体根因、或者对 Tomcat 怎么隔离多个应用感兴趣的开发者。不适合连 ClassLoader 是啥都不知道的新手。去年有个项目上线前启动不起来报ClassCastException——同一个接口的两个对象类加载器不同互相转型失败。查了一天发现是一个依赖被 Maven 的依赖仲裁搞了双版本一个 jar 从lib/加载另一个从WEB-INF/lib/加载。当时我就在想JDK 那么多类都没问题为什么到我们这里就冲突了双亲委派模型到底怎么工作的Tomcat 怎么实现应用的隔离读 OpenJDK 的ClassLoader.java源码和ClassLoader#loadClass()的实现我花了一个周末才彻底消化。双亲委派不是概念是代码很多人背过双亲委派模型的描述——子类加载器把请求委派给父类加载器加载不了再自己加载——但没见过实际代码// java/lang/ClassLoader.java — OpenJDK 8 // 双亲委派的核心实现精简 protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 1. 检查该类是否已被加载——避免重复加载 Class? c findLoadedClass(name); if (c null) { try { if (parent ! null) { // 2. 委派给父加载器 c parent.loadClass(name, false); } else { // 3. 没有父加载器 → 走 Bootstrap ClassLoader c findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器抛异常 → 不处理继续 } if (c null) { // 4. 父加载器也没加载到 → 自己尝试 c findClass(name); } } if (resolve) { resolveClass(c); } return c; } }这个方法的逻辑极其清晰但精髓不在代码本身而在它为什么这么设计。加载 java.lang.String ↓ 自定义 ClassLoader 收到请求 ↓ 询问父加载器 ↓ Application ClassLoader ↓ 询问父加载器 ↓ Extension ClassLoader ↓ 询问父加载器 ↓ Bootstrap ClassLoaderC 实现加载 rt.jar ↓ 找到了→ 返回如果改成子加载器优先Web 容器就是这么做的会发生什么一个恶意应用写一个java.lang.System放到 classpath 里——它可以劫持 JDK 核心类双亲委派模型从设计上就杜绝了这种替换因为java.lang.System一定被 Bootstrap ClassLoader 加载用户自定义的java.lang.System永远没机会被加载。我觉得这是 Java 安全模型中最被低估的一部分。很多人关注沙箱、SecurityManager但最基础的类隔离就是双亲委派提供的。层级结构三个基础加载器// java/lang/ClassLoader.java // Bootstrap ClassLoader 在 Java 源码中的引用方式 static private class NativeLibrary { // Bootstrap ClassLoader 的 native 方法 // 加载 rt.jar 中的核心类 }// jdk/internal/loader/ClassLoaders.java — JDK 9 // JDK 9 之后有了明确的类加载器定义 public class ClassLoaders { // Platform ClassLoaderJDK 9 前叫 Extension ClassLoader private static class PlatformClassLoader extends BuiltinClassLoader { // ... } // Application ClassLoader private static class AppClassLoader extends BuiltinClassLoader { // 加载 classpath 上的类 } }三者的关系加载器加载路径实现备注Bootstrap ClassLoaderjre/lib/rt.jar, jre/lib/i18n.jarC无对应 Java 对象所有 ClassLoader 的根Extension/Platformjre/lib/ext/*.jarsun.misc.Launcher$ExtClassLoaderJDK 9 改名为 Platform ClassLoaderApplication/Systemclasspath-cpsun.misc.Launcher$AppClassLoader默认加载器URLClassLoader 子类打破双亲委派为什么要打破双亲委派也不是万能药。以下几个场景都必须打破它1. SPI 机制ServiceLoader// javax.sql.DriverManager — JDBC 驱动加载 // DriverManager 被 Bootstrap ClassLoader 加载 // 但 JDBC 驱动实现类如 com.mysql.cj.jdbc.Driver在 classpath 上 // Bootstrap 加载不到 → 需要 Thread Context ClassLoaderTCCL public class DriverManager { static { // 使用 Thread.currentThread().getContextClassLoader() // 来加载 SPI 实现类 ServiceLoaderDriver loadedDrivers ServiceLoader.load(Driver.class); // ... } }问题DriverManager在rt.jar中被 Bootstrap ClassLoader 加载。而 MySQL 驱动在应用 classpath 上。Bootstrap ClassLoader 加载不了应用类。解决方案线程上下文类加载器TCCL。Thread.currentThread().getContextClassLoader()获取的是 Application ClassLoader可以加载 classpath 上的类。// java/lang/Thread.java — TCCL 的 setter public void setContextClassLoader(ClassLoader cl) { // 在 Web 应用场景中TCCL 通常是 WebAppClassLoader this.contextClassLoader cl; }这实际上就是父类加载器请求子类加载器的过程——完全反转了双亲委派的方向。SPI 是 Java 核心框架中最早打破双亲委派的场景。2. Tomcat 的 WebAppClassLoaderTomcat 为什么要打破双亲委派两个需求应用隔离部署在同一个 Tomcat 的两个应用可以使用不同版本的 SpringWeb 容器优先Servlet API 应该由 Tomcat 提供而不是应用// org.apache.catalina.loader.WebappClassLoaderBase.java — Tomcat 9 // Tomcat 的自定义类加载器 public class WebappClassLoaderBase extends URLClassLoader { Override public Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { // 1. 先检查本地缓存已加载的类 Class? clazz findLoadedClass0(name); if (clazz ! null) return clazz; // 2. 检查 JVM 已经加载的类 clazz findLoadedClass(name); // 调用 native 方法 if (clazz ! null) return clazz; // 3. 系统类加载器优先——防止应用覆盖 JDK 内部类 try { clazz system.loadClass(name); if (clazz ! null) return clazz; } catch (ClassNotFoundException e) { } // 4. 打破双亲委派——先自己尝试加载 try { // 从 WEB-INF/classes 和 WEB-INF/lib 加载 clazz findClass(name); if (clazz ! null) return clazz; } catch (ClassNotFoundException e) { } // 5. 最后才让父加载器加载共享类 if (!filter(name)) { clazz parent.loadClass(name); } throw new ClassNotFoundException(name); } }对比标准双亲委派标准AppClassLoader → Parent → Bootstrap自底向上委派自顶向下尝试 Tomcat自加载 → Bootstrap → App → Parent自己优先Tomcat 的打破双亲委派带来的限制是不同应用中同名的类必须是同一个——否则就冲突。这就是 ClassCastException 的根源。3. OSGI 的类加载网络OSGI 走得更远——它不是简单的子优先而是一个类加载器网络每个 Bundle 有自己的 ClassLoader显式声明导入/导出包。// OSGI 的 Import-Package 声明 // Bundle A 声明导出Export-Package: com.example.service // Bundle B 声明导入Import-Package: com.example.service;version1.0 // B 加载 com.example.service.X 时由 A 的 ClassLoader 提供说实话OSGI 的类加载设计在理论上最优雅但在实践中太复杂——依赖关系声明稍有不慎就报 ClassNotFoundException。我看过几个 OSGI 项目维护成本很高。实战写一个热替换 ClassLoader// 热替换 ClassLoader——打破双亲委派每次都重新加载 public class HotSwapClassLoader extends ClassLoader { private final String classPath; public HotSwapClassLoader(String classPath) { super(ClassLoader.getSystemClassLoader()); // 父加载器 this.classPath classPath; } Override public Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { // 自己先尝试加载非核心包 if (!name.startsWith(java.) !name.startsWith(javax.)) { Class? c findLoadedClass(name); if (c null) { byte[] classData loadClassData(name); // 从文件读字节码 if (classData ! null) { c defineClass(name, classData, 0, classData.length); } } if (c ! null) { if (resolve) resolveClass(c); return c; } } // 核心包走双亲委派 return super.loadClass(name, resolve); } private byte[] loadClassData(String name) { // 从 classPath 类名 读 .class 文件 // 返回字节数组 } }用这个 ClassLoader 每 new 一次就能加载一份全新的类定义不共享之前已经加载的类。HotSwapClassLoader v1 → 加载 MyService版本 1 HotSwapClassLoader v2 → 加载 MyService版本 2—— 新字节码新 Class 对象但有个关键限制MyService 引用的接口定义不能变——如果接口变了所有 ClassLoader 中加载该接口的类型都无法转型。有限的热替换实现完全的热替换方法体变更不用重启需要更复杂的机制Instrumentation Agentjava.lang.instrument.Instrumentation.retransformClasses()Java Agents在agentmain或premain方法中注册 ClassFileTransformer# 通过 agent 附加到运行中的 JVM java -javaagent:hotswap-agent.jar -jar app.jar但说实话Java 的热替换是出了名的难搞。不是技术做不到而是 JVM 对重定义一个已经加载的类有很多限制不能增删字段和方法不能改变继承关系。如果要彻底的热替换还不如直接用 JRebel 或者 DCEVM。类加载器与内存泄漏类加载器是 Java 内存泄漏的经典源头之一。一个类加载器一旦创建它加载的所有类以及这些类的静态字段都不会被 GC 回收——直到类加载器本身被回收。// 每次部署重新加载应用时 // 旧的 WebappClassLoader 本来应该被回收 // 但如果有一个全局缓存如 static Map持有该类加载器加载的对象引用…… // OLd WebappClassLoader → 无法回收 → 它加载的所有类 → 无法回收 → PermGen/Metaspace 泄漏我见过一个真实的案例同一个应用被重新部署了 20 多次后Metaspace 涨到了 1GB。原因是某个工具类里有个 static List 保存了所有的 DAO 对象引用。解决方案不要从全局静态集合中强引用 ClassLoader 加载的对象监听 ServletContextListener.contextDestroyed 事件清理所有引用用 ThreadLocal 时格外小心Web 容器的线程池不会自动清 TTL总结双亲委派模型的核心价值是沙箱安全和类唯一性。但它不是一个银弹——SPI、Tomcat、OSGI 都需要打破它。如果你想真正掌握类加载机制建议做三件事在ClassLoader.loadClass()里打断点跑一个简单的 main 方法看调用栈写一个自定义 ClassLoader从加密 jar 中加载类很多商业框架用这个做防反编译用-XX:TraceClassLoading启动应用看类的加载日志文中引用的 OpenJDK 源码路径java/lang/ClassLoader.java — loadClass 核心方法jdk/internal/loader/ClassLoaders.java — JDK 9 的加载器分层java/lang/Thread.java — 线程上下文类加载器Tomcat WebappClassLoader 源码github.com/apache/tomcat