线上排查过一次很诡异的 ClassCastException:两个模块的 com.example.config.AppConfig 同名同包、字节码完全一致,运行时却互相认为对方不是自己。根因不在代码,而在 JVM 类加载机制——两份类被两个不同的 ClassLoader 各自定义,双亲委派在这里被绕过了。类加载器是 JVM 里最容易被忽视又最容易出事的一层:它决定了类的身份、隔离边界、热部署可行性和 Metaspace 会不会爆。这篇把类加载的五个阶段、双亲委派的原理与打破方式、自定义 ClassLoader 实现热重载、类隔离方案、以及 Metaspace 泄漏的排查手法一次讲透,代码都可以直接跑。
一、类加载的五个阶段:类是怎么”活”起来的
JVM 把 .class 文件变成可用的类,要经过加载、验证、准备、解析、初始化五个阶段。很多人只记住”加载”,但真正的坑大多出在准备和初始化:静态变量到底什么时候有值、static final 常量为什么能在编译期就被内联、Class.forName 和 ClassLoader.loadClass 为什么行为不同,答案都在这里。
| 阶段 | 做什么 | 典型坑点 |
|---|---|---|
| 加载 Loading | 按全限定名取二进制字节流(文件/JAR/网络/动态生成),转为方法区运行时结构,在堆上生成 Class 对象作为访问入口 | 字节流来源不限于磁盘,ByteBuddy、CGLIB 都是”凭空造类” |
| 验证 Verification | 文件格式、元数据、字节码、符号引用四类校验 | 被字节码增强工具改坏的类在这里报 VerifyError |
| 准备 Preparation | 为静态变量分配内存并置零值(int 置 0、引用置 null) | static final 常量在此阶段就赋真值;static 普通变量仍是 0 |
| 解析 Resolution | 常量池符号引用转为直接引用(内存地址) | 解析可延迟到首次使用时进行,故有 NoSuchMethodError 运行时才暴露 |
| 初始化 Initialization | 执行 <clinit>:静态赋值 + 静态代码块,父类先于子类 | 初始化只执行一次,由 JVM 保证加锁;死锁常出现在此处 |
由此可以解释两个常见困惑。其一,Class.forName("X") 默认 initialize=true,会触发初始化(所以老式 JDBC 注册驱动用它);ClassLoader.loadClass("X") 默认只做加载,不初始化,适合只想知道”这个类在不在”。其二,static final int MAX = 100 在编译期就被内联进调用方字节码,改了常量值但没重新编译调用方,运行时依旧是旧值——这不是类加载 bug,是常量内联。
二、双亲委派:它到底在防什么
双亲委派(Parent Delegation)的规则一句话:收到加载请求先交给父加载器,父加载器加载不到,自己才动手。它的价值不是”省内存”,而是保证类的唯一性与安全性——java.lang.String 只能由 Bootstrap ClassLoader 定义,你写一个同名类放在 classpath 里也顶替不了它,否则任何人都能伪造核心类做越权操作。
2.1 从源码看 loadClass 的三步
// java.lang.ClassLoader#loadClass 逻辑(简化)
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name); // ① 已加载?直接返回
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false); // ② 先问父加载器
} else {
c = findBootstrapClassOrNull(name); // 顶层:问 Bootstrap
}
} catch (ClassNotFoundException ignored) {
// 父加载器找不到,不抛,继续往下走
}
if (c == null) {
c = findClass(name); // ③ 自己找字节码并 defineClass
}
}
if (resolve) resolveClass(c);
return c;
}
}
关键点在 findClass:父类 ClassLoader 里它是空实现,抛 ClassNotFoundException。所以自定义加载器标准姿势是重写 findClass,而不是重写 loadClass。一旦重写 loadClass,双亲委派就被你亲手破坏了——这也是很多”单例变成两个”事故的源头。
2.2 三层结构与 JDK 9 之后的变化
| 加载器 | 加载范围 | 说明 |
|---|---|---|
| Bootstrap ClassLoader | JAVA_HOME/lib/modules(JDK 9+ 为 jrt:/ 模块镜像) | C++ 实现,Java 里取到的是 null |
| Platform ClassLoader | Java SE 平台模块(原 ext 目录) | JDK 9 起替代 ExtClassLoader,且不再读 java.ext.dirs |
| App ClassLoader | classpath / 应用模块路径 | JDK 9 后不再是 URLClassLoader 子类,改 findClass 的旧代码要复核 |
JDK 9 之后还有一个容易踩的点:ClassLoader.getSystemClassLoader().getParent() 返回的是 Platform ClassLoader,不是 null。如果把自定义加载器的 parent 设成它,业务 JAR 反而加载不到——这是”自定义加载器写对了却报 ClassNotFoundException: com.fasterxml.jackson...“的常见原因。
三、什么时候必须打破双亲委派
三种真实场景几乎绕不开:SPI 扩展、容器隔离、插件热插拔。它们的共同诉求是”父加载器看不到的类,也得能被加载到”。
- SPI / JDBC:
java.sql.Driver接口由 Bootstrap 加载,实现类(如 mysql-connector-j)在 classpath 里,Bootstrap 看不见。解法是线程上下文加载器Thread.currentThread().getContextClassLoader(),由它去扫META-INF/services。站内 Java SPI 实战:用 ServiceLoader 构建可插拔架构 已经把这一层拆开讲过,这里不重复。 - Tomcat 等 Web 容器:每个 webapp 一个
WebappClassLoader,且顺序反过来——先自己找,再问父。目的是让应用自带的 Spring 版本优先于容器共享的版本,同时应用之间互不可见。 - 热部署 / 插件框架:想替换一份类,唯一办法是新建一个 ClassLoader 去定义新版本,再把旧加载器和它的所有实例丢掉。JVM 不支持”就地修改已加载的类”。
顺带说明:像 ByteBuddy 实战:字节码增强与 Java Agent 这类运行时改字节码的工具,走的是 Instrumentation.redefineClasses 通道,属于 JVM 特批的”重定义”,和新建 ClassLoader 是两条完全不同的路——前者改方法体不改结构,后者能换整个类但对象身份会变。
四、写一个能热重载的 ClassLoader
第一步,实现一个从指定目录读取字节码的加载器。注意 parent 保留系统加载器,否则连 java.lang.Object 都要自己想办法。
public class DiskClassLoader extends ClassLoader {
private final Path root;
public DiskClassLoader(Path root) {
super(ClassLoader.getSystemClassLoader()); // 父加载器:保证 JDK 与 classpath 可见
this.root = root;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Path file = root.resolve(name.replace('.', '/') + ".class");
try {
byte[] bytes = Files.readAllBytes(file);
return defineClass(name, bytes, 0, bytes.length); // name 必须与字节码内一致
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}
第二步做热重载。难点不在”加载新类”,而在”如何安全地调用它”。跨 ClassLoader 的对象不能强转成实现类,只能通过共享的接口或反射调用,否则必然 ClassCastException。下面用反射演示最小可用版本:
public class HotReloader {
private record Snapshot(DiskClassLoader loader, Class<?> type, Object instance) { }
private final Path pluginDir = Path.of("/opt/plugins");
private volatile Snapshot current;
public HotReloader() throws Exception {
reload();
}
/** 每次调用都换一个新加载器,旧加载器失去引用后才能被回收 */
public synchronized void reload() throws Exception {
DiskClassLoader loader = new DiskClassLoader(pluginDir);
Class<?> type = loader.loadClass("com.example.plugin.GreeterImpl");
Object instance = type.getDeclaredConstructor().newInstance();
Snapshot old = current;
current = new Snapshot(loader, type, instance);
System.out.printf("reload done, metaspace classes=%d%n",
ManagementFactory.getClassLoadingMXBean().getLoadedClassCount());
// old 在这里离开作用域,若无外部引用即可被 GC
}
public String greet(String who) throws Exception {
Snapshot snap = current; // 先取快照,避免 reload 中途被换掉
Method m = snap.type.getMethod("greet", String.class);
return (String) m.invoke(snap.instance, who);
}
}
这段代码真正要盯的是一个数字:getLoadedClassCount()。如果每次 reload 后它只增不减,说明旧 ClassLoader 没被回收,Metaspace 迟早 OOM。热重载的成败不取决于”新类加载成功”,而取决于”旧类能不能卸载”。
五、类隔离:同名冲突的根治方案
当一个 JVM 里要同时用 Jackson 2.13 和 2.17、或者两个插件都内置了 Netty,双亲委派下的”全局唯一”就成了枷锁。类隔离的本质是:让同一个全限定名在不同 ClassLoader 里各定义一次,两个 Class 对象不相等,自然互不干扰。
| 需求场景 | 手段 | 优点 | 代价 |
|---|---|---|---|
| 同一依赖多版本共存 | 每个版本一个 ClassLoader(parent 设为 Platform 或 null),跨加载器只传接口 / DTO | 彻底隔离,互不污染 | 对象不能强转,序列化要统一协议 |
| 插件热插拔 | 每次 new ClassLoader,丢弃旧引用 | 支持不重启更新 | 必须切断 ThreadLocal、线程、静态注册所有引用 |
| 框架自带依赖与业务冲突 | Maven Shade Plugin relocation 重命名包 | 运行期零成本 | 包体积变大,反射按类名找类的库会失效 |
| 普通版本对齐 | BOM 统一版本、dependency:tree 排冲突 | 最简单 | 解决不了刚性冲突 |
实践中最稳的组合是”接口下沉 + 加载器隔离“:把 greet() 这类契约放在由系统加载器加载的 API 包里,插件实现类放在独立加载器。这样父加载器加载的接口是所有加载器共享的同一个 Class,调用方可以用强类型,参数对象也不会跨加载器错乱。
六、Metaspace 泄漏:类加载器的另一张账单
类元数据在 JDK 8 之后从永久代搬到本地内存的 Metaspace。它默认不设上限(受物理内存约束),所以类加载器泄漏不会立刻炸,而是”监控图缓慢爬坡,重启后归零”。等到报 java.lang.OutOfMemoryError: Metaspace 时,通常已经跑了好几天。
| 泄漏来源 | 为什么留住 ClassLoader | 修法 |
|---|---|---|
| ThreadLocal 未 remove | 线程对象挂在 GC Root,其 threadLocals 引用业务对象,业务对象引用 Class | finally 中 remove();容器线程尤其危险 |
| 自建线程池 / 定时任务未关闭 | 线程的 contextClassLoader 指向应用加载器 | 关闭时 shutdown() 并显式置 null |
| JDBC 驱动自动注册 | DriverManager 静态列表持有驱动实例 | 卸载时 DriverManager.deregisterDriver() |
| 静态缓存 / 单例注册表 | 父加载器的静态字段引用子加载器对象 | 改为弱引用(WeakHashMap)或卸载时清理 |
| 日志框架 / JMX / ShutdownHook | 全局注册中心持有回调对象 | 卸载钩子显式反注册 |
排查分三步:确认现象、定位数量、找到引用链。下面这组命令按顺序执行即可,注意 GC.class_stats 需要先开 -XX:+UnlockDiagnosticVMOptions。
# 1) 看每个加载器加载了多少类、活了多久
jcmd <pid> VM.classloader_stats | sort -k6 -nr | head -20
# 2) 按类占用统计(需 -XX:+UnlockDiagnosticVMOptions 启动)
jcmd <pid> GC.class_stats | sort -k3 -nr | head -20
# 3) 打印类加载器摘要,观察同名加载器数量是否持续增长
jmap -clstats <pid>
# 4) 开启类卸载追踪,确认"该卸载的没卸载"
java -XX:+TraceClassUnloading -verbose:class -jar app.jar | grep -i unload
如果第 1 步看到同一个业务加载器的实例数随发布时间线性增长,基本可以确诊。此时用 MAT 打开堆转储,对那批 ClassLoader 做 “Merge Shortest Paths to GC Roots”,排除弱引用后剩下的路径就是元凶——十有八九是某个线程的 contextClassLoader,或者一个忘了清理的静态 Map。想更系统地看 JVM 侧指标与调优手法,可配合 JVM 调优实战:从 GC 日志到内存与吞吐优化 一起读。
七、线上问题的三步定位法
类加载相关故障的报错形态很固定:NoClassDefFoundError(编译时有、运行时无)、ClassCastException(同包同名但不是同一个 Class)、LinkageError: duplicate class definition(同一个加载器重复 define)、NoSuchMethodError(版本不一致)。定位时别猜,按下面三步走:
# ① 这个类到底是谁加载的
jcmd <pid> VM.class_hierarchy -i -s com.example.Foo
# ② 运行时打印真实加载器(临时排查代码,勿长期留在生产)
System.out.println(Foo.class.getClassLoader()
+ " -> " + Foo.class.getProtectionDomain().getCodeSource().getLocation());
# ③ 启动加 -verbose:class,grep 目标类,看加载来源 JAR
java -verbose:class -jar app.jar 2>&1 | grep "com.example.Foo"
# ④ 内部类 / 静态块的初始化异常会被包装成 NoClassDefFoundError
# 真实原因藏在 "Caused by: java.lang.ExceptionInInitializerError" 里
第 ④ 条值得单独强调:static 块抛异常后,类会进入 Erroneous 状态,后续任何访问都直接抛 NoClassDefFoundError,而首次失败的真实堆栈往往被日志淹没。看到一个”昨天还好今天突然找不到类”的问题,先搜 ExceptionInInitializerError,命中率极高。
八、六个高频坑清单
| 坑 | 现象 | 正确做法 |
|---|---|---|
自定义加载器 parent 传 null | 连 java.lang.String 都加载不了 | parent 用 getSystemClassLoader(),隔离时才考虑 Platform |
重写 loadClass 没重写 findClass | 单例变多个、静态变量不共享 | 只重写 findClass,保留委派链 |
| 同一加载器重复 defineClass | LinkageError: duplicate class definition | define 前先 findLoadedClass 判重,或复用加载器 |
| 跨加载器强转实现类 | ClassCastException 但类名一模一样 | 接口下沉到共享加载器,只做强转子类的父类型 |
| 热重载后旧加载器未释放 | Metaspace 阶梯式上涨 | 清理 ThreadLocal、线程、注册表,用 classloader-stats 验证 |
| Shade 重定位后反射失效 | ClassNotFoundException 找旧包名 | 同步改 Class.forName 的字符串常量或加 relocation 白名单 |
九、落地清单
- 自定义类加载器只重写
findClass,parent 默认用getSystemClassLoader();需要强隔离时明确注释”此处故意打破委派”。 - 对外暴露的契约(接口、DTO)必须放在父加载器可见的 API 包里,否则跨加载器调用只能反射。
- 热重载按”新建加载器 → 加载 → 反射调用 → 丢弃旧引用”四步走,并用
getLoadedClassCount()做回归验证。 - 插件卸载时成对清理:ThreadLocal、线程池、JDBC 驱动、静态注册表、ShutdownHook。
- 生产启动参数加上
-XX:MaxMetaspaceSize,别让泄漏拖成整机 OOM;配合-XX:+TraceClassUnloading在预发环境验证卸载是否生效。 - CI 里跑一次
mvn dependency:tree -Dverbose检查重复依赖,把能在构建期解决的冲突挡在上线前——把这类检查塞进 GitHub Actions 流水线 成本极低。
把类加载机制理解成”类的命名空间 + 生命周期管理”,前面所有怪现象都会归位:同名不同源是命名空间问题,热重载失败是生命周期问题,Metaspace 爆是命名空间没被回收。下次再遇到 ClassCastException 且类名完全一致,先打印两边的 getClassLoader(),通常一眼就能锁定。




