类卸载失败导致元空间持续增长、频繁Full GC及停顿延长,根本原因是类加载器未被回收或卸载开关未生效;需通过GC日志、jmap -clstats、-XX:+TraceClassLoading等手段定位动态代理泄漏、多加载器双加载等问题。

类卸载停顿本身不是 JVM 的标准术语,真正发生的是:因类卸载失败或卸载条件不满足,导致元空间(Metaspace)持续增长、触发频繁 Full GC,而每次 GC 中“尝试卸载类”却几乎无效——这种反复无效的类卸载尝试,会拖慢 GC 过程,表现为 GC 停顿时间变长、日志中出现大量与类加载/卸载相关的线索。排查重点不在“停顿”本身,而在“为什么卸载不生效”。
看 GC 日志里的卸载实效性
启用关键参数后观察日志:
-
-XX:+PrintGCDetails -XX:+PrintClassLoaderStatistics:这是核心组合。每次 Full GC 后会打印类似这样的行:
Classes: 124567 loaded, 0 unloaded, 124567 live
如果 unloaded 长期为 0 或个位数,说明类卸载基本没发生;
如果 live 数量只减不增或缓慢下降,但 loaded 持续上涨,说明新类不断加载、旧类卡在内存里出不去。 -
关注 Metaspace 使用量变化:
日志中类似 Metaspace: 10240K->10240K(1056768K) 的字段,箭头后数值几乎不变,就是卸载失败的直接证据——GC 扫了,但没清掉多少元数据。
查类加载器是否在“悄悄繁殖”
类卸载的前提是类加载器被回收。只要加载器还活着,它加载的所有 Class 就都得留着。
- 用 jmap -clstats
查当前所有类加载器实例数量和各自加载的类数。
重点关注 非系统类加载器(比如 org.springframework.boot.loader.LaunchedURLClassLoader、net.sf.cglib.core.DefineClassClassLoader、自定义的 MyPluginClassLoader)是否数量持续增加。 - 配合 -XX:+TraceClassLoading,在问题复现窗口期内开启,过滤出高频加载的类名和对应加载器。如果看到大量 com.sun.proxy.$ProxyXX 或 EnhancerBySpringCGLIB$$XXXX 由不同加载器加载,基本锁定动态代理泄漏。
确认类卸载开关是否真正生效
不是加了参数就等于能卸载。很多团队误以为开了 -XX:+CMSClassUnloadingEnabled 就万事大吉,其实它依赖垃圾收集器类型:
-
G1 收集器:JDK 8u40+ 起才支持类卸载,且必须同时启用:
-XX:+UseG1GC -XX:+UnlockExperimentalVMOptions -XX:+ClassUnloading(部分老版本还需 -XX:+UnlockDiagnosticVMOptions)。 - CMS 收集器:需显式配 -XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled。
- Parallel GC(默认):不支持类卸载,加任何相关参数都无效。必须换 GC 策略。
验证类是否真被隔离或重复加载
类卸载失败的另一常见原因是:同一个类被多个加载器加载,但应用逻辑又强引用了其中某一个 Class 对象(比如缓存了 Class.forName("X") 的结果),导致加载器无法被回收。
- 遇到 ClassCastException 或怀疑类型不一致时,在出错位置加日志:
log.info("User class loader: {}", User.class.getClassLoader());
log.info("obj class loader: {}", obj.getClass().getClassLoader()); - 用 -verbose:class 启动,搜索关键类名,看是否多次出现、加载器是否不同。例如:
[Loaded com.example.User from file:/app/lib/my-common-1.2.jar]
[Loaded com.example.User from file:/app/plugins/feature-a.jar]
这就构成双加载风险,两个 Class 对象互不可转,也互不释放。

















