引言
在前面的系列文章中,我们聚焦于 Heap 内的对象回收、内存碎片和停顿时间。但很多开发者在排查 GC 耗时异常时,会发现一个令人费解的现象:堆里明明没多少存活对象,为什么 Full GC 或并发标记阶段的耗时依然极长?
这时候,你需要看一眼 GC 日志里经常被忽略的一个指标:Class Unloading。
类卸载不仅是 Metaspace 空间管理的重要环节,更是 GC 耗时的一个潜在瓶颈。今天我们就来聊聊 Class Unloading 是如何影响 GC 的,以及在生产环境中如何避坑。
一、Class Unloading 的触发机制
JVM 卸载一个类的前提条件非常苛刻,必须同时满足以下三点:
- 该类所有的实例都已被回收(Heap 中不存在该类的任何对象)。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的
java.lang.Class对象没有在任何地方被引用(不能通过反射随时实例化它)。
什么时候触发类卸载?
- Parallel GC / CMS:通常在 Full GC 时触发。
- G1 GC:在并发标记阶段结束时的清理阶段触发。
这就意味着,每一次 Full GC 或 G1 的并发标记周期,JVM 不仅要扫描 Heap,还要去扫描 Metaspace 中的类元数据,判断是否满足卸载条件。
二、Class Unloading 拖垮 GC 的两个维度
1. 时间维度:卸载过程本身的耗时
卸载类不是简单地释放内存,JVM 需要遍历所有的类加载器及其加载的类,检查它们的可达性。如果一个应用因为大量使用动态代理(如 CGLIB、Spring AOP)或动态脚本(如 Groovy、JSP),在运行时生成了成千上万个 Class。当 Full GC 触发时,JVM 去检查和清理这些巨量的 Class 元数据会消耗大量时间。
在 GC 日志中,你可能会看到这样的输出:
text
[Class Unloading: 0.5234540 secs]
如果你的系统动态类生成没控制好,这个时间可能高达数秒,直接叠加在 STW 时间上。很多时候,长 Full GC 不是因为 Heap 扫描慢,而是卡在了 Class Unloading。
2. 频率维度:Metaspace 空间不足引发的 GC 死循环
Metaspace 默认在本地内存中动态扩张。但如果不加限制,或者设置了 -XX:MaxMetaspaceSize,当 Metaspace 达到高水位线时,JVM 会被迫触发一次 Full GC(或 G1 的并发标记)来尝试卸载无用的类,以释放 Metaspace 空间。
如果应用存在 ClassLoader 泄漏(Class 对象虽然不用了,但 ClassLoader 还被强引用着,导致类无法卸载),那么无论触发多少次 Full GC,Metaspace 都无法回收。结果就是:Metaspace 涨一点 → 触发 Full GC → 无法回收 → Metaspace 再涨一点 → 再触发 Full GC。系统陷入频繁 Full GC 的死循环,而此时 Heap 的使用率可能还很低。
三、生产环境中的典型踩坑场景
1. 动态代理类爆炸
在使用 Spring AOP 或大量使用 RPC 动态代理时,框架会为 Bean 动态生成代理类。如果代理生成逻辑没有做缓存(或者缓存失效),每次调用都生成新的代理 Class,会导致 Metaspace 中充满了几乎一模一样的代理类。一旦触发 GC,清理这些海量 Class 极其耗时。
2. Groovy/JS 脚本动态编译
规则引擎或动态策略经常使用 Groovy 脚本。每次脚本变动都通过 GroovyClassLoader 编译生成新的 Class。如果脚本更新极其频繁,且没有复用 ClassLoader,Metaspace 会迅速膨胀。更可怕的是,如果旧的 ClassLoader 没有被正确释放,就会引发上述的"Metaspace 死循环 Full GC"。
3. ThreadLocal + 自定义 ClassLoader
在线程池环境下,如果线程通过自定义 ClassLoader 加载了类,且该 ClassLoader 被缓存在 ThreadLocal 中。即使任务执行完毕,由于线程复用且 ThreadLocal 未清理,ClassLoader 及其加载的所有类都无法被卸载,造成隐形泄漏。
四、排查与优化思路
1. 识别 GC 日志中的卸载耗时
关注 Full GC 或并发标记周期日志中的 Class Unloading 字段。如果该字段耗时占比异常高(比如 GC 总耗时 2 秒,Class Unloading 占了 1.5 秒),说明问题出在类卸载上。
2. 监控 Metaspace 与 Class 加载数量
通过 JMX 监控 ClassLoadingMXBean 的 getLoadedClassCount() 和 getTotalLoadedClassCount()。
- 如果
TotalLoadedClassCount(历史加载总数)呈阶梯式无限上涨,而LoadedClassCount(当前加载数)也居高不下,说明存在动态类生成且无法卸载的问题。 - 同时监控 Metaspace 的使用量趋势,看是否呈锯齿状上涨(每次 GC 降一点,随后快速涨回)。
3. 代码层面的根治
- 缓存代理/脚本对象:确保动态代理工厂或脚本引擎对相同的输入做了缓存,避免生成重复的 Class。
- 规范 ClassLoader 生命周期:对于自定义 ClassLoader,确保在使用完毕后断开所有强引用,让其能被 GC 回收。
4. JVM 参数调优辅助
- 合理设置 Metaspace 大小 :在生产环境,建议设置
-XX:MaxMetaspaceSize防止因类泄漏导致本地内存被吃光,进而引发 OOM Killer 杀进程。 - 开启类卸载日志 :在排查时,可以通过
-Xlog:class+unload=info(JDK 9+) 或-XX:+TraceClassUnloading(JDK 8) 观察到底有哪些类在卸载或无法卸载。 - G1 的优化:JDK 10 引入了并发类卸载,将部分类卸载的工作从 STW 阶段移到了并发阶段,大幅降低了类卸载对 STW 的影响。如果深受其害,升级 JDK 是最有效的手段。
小结
GC 不仅仅是 Heap 的游戏。随着系统动态性的增加,Metaspace 和 Class Unloading 往往成为 GC 性能的短板。
排查 GC 问题时,如果发现堆内存不算大、存活对象不算多,但 GC 耗时却居高不下,务必回头看一眼 GC 日志中的 Class Unloading 耗时和 Metaspace 的监控曲线。控制动态类的生成量,保证 ClassLoader 的正常消亡,才能避免这把"隐形杀手"拖垮你的系统。