当前位置: 首页 > news >正文

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 ClassLoader(C++ 实现,加载 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 ClassLoader(JDK 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 ClassLoader
Application/Systemclasspath(-cp)sun.misc.Launcher$AppClassLoader默认加载器,URLClassLoader 子类

打破双亲委派:为什么要打破

双亲委派也不是万能药。以下几个场景都必须打破它:

1. SPI 机制(ServiceLoader)

// javax.sql.DriverManager — JDBC 驱动加载 // DriverManager 被 Bootstrap ClassLoader 加载 // 但 JDBC 驱动实现类(如 com.mysql.cj.jdbc.Driver)在 classpath 上 // Bootstrap 加载不到 → 需要 Thread Context ClassLoader(TCCL) public class DriverManager { static { // 使用 Thread.currentThread().getContextClassLoader() // 来加载 SPI 实现类 ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class); // ... } }

问题:DriverManagerrt.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 的 WebAppClassLoader

Tomcat 为什么要打破双亲委派?两个需求:

  1. 应用隔离:部署在同一个 Tomcat 的两个应用,可以使用不同版本的 Spring
  2. Web 容器优先: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;version="1.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:在agentmainpremain方法中注册 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 对象引用。

解决方案

  1. 不要从全局静态集合中强引用 ClassLoader 加载的对象
  2. 监听 ServletContextListener.contextDestroyed 事件,清理所有引用
  3. 用 ThreadLocal 时格外小心,Web 容器的线程池不会自动清 TTL

总结

双亲委派模型的核心价值是沙箱安全类唯一性。但它不是一个银弹——SPI、Tomcat、OSGI 都需要打破它。

如果你想真正掌握类加载机制,建议做三件事:

  1. ClassLoader.loadClass()里打断点,跑一个简单的 main 方法,看调用栈
  2. 写一个自定义 ClassLoader,从加密 jar 中加载类(很多商业框架用这个做防反编译)
  3. -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

http://www.jsqmd.com/news/1110946/

相关文章:

  • 干细胞研究获新突破 新规促规范
  • 451. Java 正则表达式 - Matcher 的 start(), end(), matches() 和 lookingAt()
  • 如何解决区域创新部门在政策资金投放中的“撒胡椒面”问题?
  • 港股AI新股成“韭菜镰刀”:上市拉高、配股、入港股通后暴跌,散户成最终买单者
  • 彻底解决 OpenClaw 杀毒拦截、路径报错、网关离线全套方案(含安装包)
  • 如何快速获取网盘直链:LinkSwift网盘下载助手完整指南
  • 从吃灰到生产力:用Armbian让旧电视盒子重获新生
  • GPT-4稀疏激活真相:万亿参数如何实现2%动态路由
  • Dify实战指南:从AI应用编排到企业级部署的30+核心模式解析
  • LU,大鼠脑定位仪 小鼠脑定位仪 大动物定位仪 小动物脑定位仪
  • 为什么专业机房都离不开防火门?一文讲透它的重要性
  • 大模型辅助搭建生产制造型企业排单助手
  • 经济周期与服饰品类匹配程序,区分繁荣期奢品,下行期平价服饰最优备货比例。
  • 索尼 PS6 将用 Zen 6 LP 低功耗核心,专为后台闲置工作降能耗
  • 分享:一站式 AI 工具全栈实验室|Chaos AI 研究室
  • 【Java课程设计/毕业设计】基于 SpringBoot 的智能瑜伽健身服务管理系统的设计与实现 基于 SpringBoot 的普拉提会馆会员权益与课程管理系统【附源码、数据库、万字文档】
  • A 股上市公司高管数字背景数据集
  • Whisky:在macOS上重构Windows应用运行边界的架构革命
  • 2026AI论文工具红黑榜出炉!教你选对工具,写作不踩坑
  • 67|技能治理:版本、禁用回滚与共享策略
  • TikTokDownload Cookie自动获取:告别手动烦恼的10分钟终极指南
  • 2026 年居家高温灼伤护理科普:热水烫伤应急处理与避坑实操指南
  • 面向对象设计在Java开发中的核心作用
  • AI教材写作必备:低查重AI工具,为教材编写保驾护航!
  • Artillery性能测试实战:从脚本编写到结果分析全流程指南
  • 69_Python时间日期处理
  • 全球公司集体反省:从“Token管够”到“小模型经济学”,省钱风潮来袭!
  • 如何3分钟搞定QQ空间数据备份:GetQzonehistory智能导出工具完整指南
  • STM32F439ZG与DS28EC20 1-Wire EEPROM嵌入式存储方案
  • 如何通过HWInfo插件实现FanControl智能风扇控制:完整配置指南