原始日志
[GC (CMS Final Remark) [YG occupancy: 392069 K (737280 K)]
2025-09-18T10:32:57.565+0800: [Rescan (parallel) , 0.0767771 secs]
2025-09-18T10:32:57.642+0800: [weak refs processing, 0.0064534 secs]
2025-09-18T10:32:57.649+0800: [class unloading, 0.0936137 secs]
2025-09-18T10:32:57.742+0800: [scrub symbol table, 0.0177076 secs]
2025-09-18T10:32:57.760+0800: [scrub string table, 0.0021874 secs]
[1 CMS-remark: 3590926K(3899392K)] 3982995K(4636672K), 0.2012558 secs]
[Times: user=0.48 sys=0.14, real=0.20 secs]
逐段解析
-
[GC (CMS Final Remark)- 表示这是一次 CMS 收集器的 Final Remark 阶段。
- CMS 是一种低停顿的老年代垃圾回收器,Final Remark 是它的第二次 Stop-The-World(STW)阶段,用来完成并发标记阶段未处理完的对象标记。
-
[YG occupancy: 392069 K (737280 K)]- YG = Young Generation(新生代)。
- 新生代当前占用 392,069 KB,总容量 737,280 KB。
-
2025-09-18T10:32:57.565+0800: [Rescan (parallel) , 0.0767771 secs]- Rescan:重新扫描 GC Roots 以及在并发标记阶段发生变化的引用。
- parallel:多线程并行执行。
- 耗时 0.0768 秒。
-
[weak refs processing, 0.0064534 secs]- 处理弱引用(WeakReference、SoftReference、PhantomReference 等)。
- 耗时 0.00645 秒。
-
[class unloading, 0.0936137 secs]- 卸载无用的类(Class Unloading),释放元空间(Metaspace)或永久代(PermGen)。
- 耗时 0.0936 秒。
-
[scrub symbol table, 0.0177076 secs]- 清理符号表(Symbol Table),移除无用的符号信息。
- 耗时 0.0177 秒。
-
[scrub string table, 0.0021874 secs]- 清理字符串常量池(String Table)。
- 耗时 0.00219 秒。
-
[1 CMS-remark: 3590926K(3899392K)] 3982995K(4636672K), 0.2012558 secs]-
1:这是第 1 次 CMS 周期(从 JVM 启动算起)。 -
CMS-remark: 3590926K(3899392K):- 老年代(CMS 管理的区域)使用了 3,590,926 KB,总容量 3,899,392 KB。
-
3982995K(4636672K):- 整个堆(新生代 + 老年代)使用了 3,982,995 KB,总容量 4,636,672 KB。
-
本次 Final Remark 阶段总耗时 0.2013 秒。
-
-
[Times: user=0.48 sys=0.14, real=0.20 secs]- user:用户态 CPU 时间 0.48 秒(多线程累加)。
- sys:内核态 CPU 时间 0.14 秒。
- real:实际挂钟时间 0.20 秒(STW 停顿时间)。
总结
- 这是一次 CMS Final Remark 阶段的 GC 日志。
- 停顿时间(STW)约 200 毫秒,对延迟敏感的系统可能需要关注。
- 新生代占用约 392 MB,老年代占用约 3.59 GB,堆总占用约 3.98 GB。
- 主要耗时点在 class unloading(93ms)和 rescan(77ms)。
- 如果 class unloading 耗时较长,可能是因为类加载/卸载频繁(例如动态加载类的应用:Tomcat、OSGi、Groovy 等)。
1. 为什么 class unloading 耗时高?
1.1 class unloading 是什么
-
在 JVM 中,类是由 类加载器(ClassLoader) 加载到 方法区(Java 8 之后是 Metaspace) 的。
-
当一个类及其关联的 ClassLoader 不再被引用 时,GC 会在 Stop-The-World 阶段卸载这些类,释放元数据(方法、字段、常量池等)。
-
卸载类的过程需要:
- 遍历所有类加载器及其加载的类
- 清理方法区的元数据
- 更新符号表、字符串表
- 释放本地内存(Metaspace)
1.2 为什么会耗时高
class unloading 耗时高,通常是因为:
-
类加载/卸载频繁
-
例如:
- Web 容器(Tomcat、Jetty)频繁热部署应用
- OSGi 动态模块加载/卸载
- Groovy、JRuby、Scala REPL 等动态生成类
- 反射 + 动态代理(CGLIB、Javassist、ByteBuddy)频繁生成临时类
-
-
类数量多
- 卸载时需要遍历大量类元数据,类越多耗时越长。
-
类加载器层级复杂
- 自定义 ClassLoader 多且相互引用,GC 需要更多时间分析可达性。
-
Metaspace 碎片化
- 类卸载时需要整理内存碎片,可能增加停顿时间。
-
符号表/字符串表很大
- 卸载类时会触发符号表和字符串表的清理(你日志里
scrub symbol table和scrub string table就是这个)。
- 卸载类时会触发符号表和字符串表的清理(你日志里
1.3 常见导致 class unloading 频繁的代码模式
-
频繁热加载类:
java复制代码 URLClassLoader loader = new URLClassLoader(new URL[]{...}); Class<?> clazz = loader.loadClass("com.example.MyClass"); // loader 不再引用,等待 GC 卸载 -
动态代理频繁生成类:
java复制代码 Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(MyService.class); enhancer.setCallback(new MyInterceptor()); MyService proxy = (MyService) enhancer.create(); -
脚本引擎频繁执行(Groovy、Nashorn、Jython 等)
-
模板引擎动态编译(JSP、Freemarker、Thymeleaf + 动态类生成)
-
反射生成类(Javassist、ByteBuddy)
2. rescan 是做什么的?
2.1 CMS 的标记过程回顾
CMS 的标记阶段分为:
-
Initial Mark(STW)
- 标记 GC Roots 直接可达的对象
-
Concurrent Mark(并发)
- 和应用线程一起运行,标记可达对象
-
Remark(STW)
- 重新扫描(Rescan)并处理并发标记阶段遗漏的对象
-
Concurrent Sweep(并发清理)
2.2 rescan 的作用
-
在 Concurrent Mark 阶段,应用线程还在运行,可能会:
- 新建对象
- 修改引用关系
-
这些变化可能导致:
- 有些对象在并发标记时没被标记到
-
Rescan 就是:
- 再次扫描 GC Roots
- 处理并发标记阶段记录下的“引用变更集”(Remembered Set / Dirty Card Queue)
- 确保标记结果准确
2.3 为什么 rescan 耗时可能高
- 新生代对象多(因为需要扫描新生代的引用)
- 跨代引用多(老年代对象引用新生代对象)
- 应用在并发标记阶段产生了大量对象或修改了大量引用
- GC Roots 数量大(线程多、类多、JNI 引用多)
3. 结合你的日志
你的日志里:
[Rescan (parallel) , 0.0767771 secs]
[class unloading, 0.0936137 secs]
class unloading占了 93ms,是整个 Final Remark 阶段的最大耗时点rescan占了 77ms,说明并发标记阶段应用产生了不少引用变更,或者 GC Roots 较多
4. 优化建议
针对 class unloading:
-
减少类加载/卸载频率
- 避免频繁创建新的 ClassLoader
- 对动态生成的类做缓存
-
禁用类卸载(仅在必要时)
-
JVM 参数:
-Xnoclassgc这样类不会卸载,但会占用更多 Metaspace
-
-
增大 Metaspace
-
减少触发类卸载的频率:
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
-
针对 rescan:
-
减少跨代引用
- 尽量避免老年代对象频繁引用新生代对象
-
减少 GC Roots 数量
- 关闭不必要的线程
- 清理 ThreadLocal
-
减少新生代大小(如果新生代太大,Rescan 会更慢)
有规律的现象
- 晚上压测(高并发、大流量)之后
- 第二天早上就会出现 CMS Final Remark 阶段 class unloading 和 rescan 耗时明显变高 的现象
1. 为什么是“压测过后第二天”才出现?
1.1 压测期间发生了什么
-
大量类被动态加载
- 压测可能触发了很多业务路径,加载了平时不常用的类(尤其是 JSP、Groovy、动态代理类等)
-
大量对象和引用关系被创建
- 并发高 → 新生代和老年代对象数量都暴增
- 跨代引用(老年代对象引用新生代对象)也会增加
-
Metaspace 占用飙升
- 类加载多 → 元空间(Metaspace)被占满得更快
1.2 压测结束后
- 压测结束,很多动态加载的类和对象不再被引用
- 这些类和对象不会立刻被回收,而是等到下一次 CMS 周期的 Final Remark 阶段才会被彻底清理
- 如果 Metaspace 占用接近上限,JVM 会触发 类卸载(class unloading)
- 由于压测期间加载的类数量巨大,卸载时需要遍历和清理的元数据非常多 → class unloading 耗时飙升
1.3 为什么是第二天才明显
- 压测结束后,系统可能进入低负载状态
- CMS 收集器在低负载时会有机会做一次完整的老年代回收(包括类卸载)
- 这次回收就会集中处理压测期间遗留的大量无用类和引用
- 所以第二天早上第一次触发 CMS Final Remark 时,
class unloading和rescan耗时就会特别高
2. 你的现象可能的根本原因
-
压测触发了大量动态类加载
-
例如:
- JSP 编译生成的类
- 动态代理类(CGLIB、Javassist、ByteBuddy)
- 脚本引擎(Groovy、Nashorn)
- 热加载模块(OSGi、Spring DevTools)
-
-
Metaspace 空间不足
- 压测期间加载的类占满了 Metaspace
- 下一次 GC 时必须卸载大量类
-
跨代引用多
- 压测期间产生了很多老年代对象引用新生代对象
- 导致
rescan阶段需要扫描更多引用卡片(Dirty Card)
3. 如何验证我的推测
你可以在压测后、第二天 GC 变慢前,执行以下命令:
jcmd <pid> VM.classloaders
jcmd <pid> GC.class_histogram
jcmd <pid> VM.native_memory summary
重点看:
- 类加载器数量(是否有大量临时 ClassLoader)
- 类总数(是否比平时多很多)
- Metaspace 占用(是否接近
MaxMetaspaceSize)
另外,可以在 JVM 启动参数加:
-XX:+PrintClassHistogramBeforeFullGC
-XX:+PrintClassHistogramAfterFullGC
-XX:+TraceClassUnloading
这样 GC 日志会打印卸载了哪些类,能直接看到是不是压测期间加载的类。
4. 优化建议
4.1 减少动态类加载
- 对动态代理类做缓存(CGLIB、ByteBuddy)
- JSP 预编译,避免运行时频繁编译
- 脚本引擎(Groovy、Nashorn)尽量复用 ClassLoader
4.2 调整 Metaspace
- 增大 Metaspace,减少类卸载频率:
复制代码
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
- 如果业务允许,可以临时禁用类卸载:
复制代码
-Xnoclassgc
(注意:会增加内存占用)
不好的现象
从GC日志看 像是 CMS也回收不掉一些垃圾,明显的现象就是整体GC时间变长了
不仅仅是 class unloading 耗时高,而是 整个 CMS GC 周期的时间都变长了,而且看起来 CMS 没有回收掉一些本该回收的垃圾。
这其实是 CMS 的经典“回收不干净”问题,我帮你拆开分析一下。
1. 为什么 CMS 会“回收不掉”一些垃圾
1.1 CMS 的工作方式
CMS(Concurrent Mark-Sweep)是 并发标记-清除 算法:
- Initial Mark(STW)
标记 GC Roots 直接可达的对象 - Concurrent Mark(并发)
和应用线程一起运行,标记可达对象 - Remark(STW)
处理并发标记阶段遗漏的对象 - Concurrent Sweep(并发)
清理不可达对象,释放内存
1.2 CMS 回收不干净的原因
-
浮动垃圾(Floating Garbage)
- 在 Concurrent Sweep 阶段,应用线程还在运行
- 可能会产生新的垃圾对象
- 这些对象不会在本次 GC 中被清理,只能等下一次 GC
- 结果就是老年代里总有一部分“清不掉”的垃圾
-
碎片化(Fragmentation)
- CMS 是“标记-清除”算法,不会压缩内存
- 清理后会留下很多小碎片
- 即使老年代总空闲空间够,但可能没有足够大的连续空间分配大对象
- 这会导致 晋升失败(promotion failed) 或 并发模式失败(concurrent mode failure) ,触发 Full GC(STW,耗时更长)
-
引用链复杂 / 跨代引用多
- 如果老年代对象引用了很多新生代对象,CMS 在标记阶段需要扫描更多引用卡片(Dirty Card),耗时增加
- 复杂的对象图也会让标记阶段变慢
-
类卸载和元数据清理
- 如果 Metaspace 压力大,CMS Final Remark 会做大量 class unloading 和 symbol/string table 清理
- 这会让整个 GC 周期变长
2. 为什么压测后更明显
结合你说的“晚上压测 → 第二天 GC 变慢”:
-
压测期间:
- 老年代被塞满了更多长生命周期对象(缓存、线程本地变量、静态集合等)
- 动态类加载多,Metaspace 占用高
- 对象引用关系更复杂
-
压测结束:
-
老年代里混合了:
- 真正的长生命周期对象
- 浮动垃圾
- 碎片
-
CMS 清理一次也不能完全释放空间
-
-
结果:
- 老年代可用空间减少
- CMS 触发频率增加
- 每次 GC 扫描和清理的工作量变大 → 整体 GC 时间变长
3. 如何确认是“回收不干净”
你可以:
-
开启详细 GC 日志:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -
观察 CMS GC 前后老年代的使用量:
[CMS-concurrent-sweep: 3500M->3200M(3800M)]如果每次 GC 后老年代使用量下降不多(比如只降了 200M),说明有大量对象没被回收。
-
关注是否有:
concurrent mode failurepromotion failed- 老年代使用量持续上升
4. 可能的优化方向
4.1 减少浮动垃圾
- 尽量减少老年代对象引用新生代对象
- 优化对象生命周期,让短命对象留在新生代
4.2 减少碎片化
-
调整 CMS 参数:
-XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=1让 CMS 在 Full GC 时做压缩,减少碎片
4.3 提前触发 CMS
-
避免老年代太满才触发:
-XX:CMSInitiatingOccupancyFraction=60 -XX:+UseCMSInitiatingOccupancyOnly
4.4 考虑换收集器
-
如果你的应用是 大堆 + 高并发 + 动态类加载多,CMS 的缺点会被放大
-
可以考虑换成 G1 GC(支持并发压缩,减少碎片):
-XX:+UseG1GC
5. 我的判断
你现在的现象很可能是:
- 压测后老年代里混合了大量浮动垃圾 + 碎片
- CMS 每次 GC 都清不干净
- 老年代可用空间越来越少
- GC 触发频率和耗时都增加
- 再加上 class unloading 和 rescan 的额外开销 → 整体 GC 时间变长
分析现有错误的JVM参数
-XX:+CMSClassUnloadingEnabled
-XX:+CMSParallelRemarkEnabled
-XX:+UseCMSCompactAtFullCollection
-XX:+UseCompressedOops
-XX:+PrintGCDateStamps
-XX:+PrintGCDetails
开始分析
-XX:+CMSClassUnloadingEnabled
- 允许 CMS 在 GC 时卸载无用的类(Class Unloading)。
- 好处:释放 Metaspace,防止元空间 OOM。
- 风险:如果动态类加载很多(压测场景),卸载过程会增加 Final Remark 阶段的 STW 时间(你现在就遇到了这个现象)。
-XX:+CMSParallelRemarkEnabled
- 让 CMS Final Remark 阶段的 Rescan 操作多线程并行执行。
- 好处:减少 STW 时间。
- 但如果引用关系复杂、跨代引用多,Rescan 依然会耗时较长(你日志里 Rescan 还是 77ms)。
-XX:+UseCMSCompactAtFullCollection
- 在 Full GC 时对老年代做压缩,减少碎片化。
- 但注意:CMS 的正常 GC(并发清理)是不会压缩的,只有 Full GC 才会压缩。
- 所以如果你很少触发 Full GC,老年代碎片化问题依然存在,可能导致 Promotion Failed / Concurrent Mode Failure。
-XX:+UseCompressedOops
- 压缩普通对象指针(OOP),减少 64 位 JVM 的内存占用。
- 对 GC 时间影响不大,但能节省堆空间。
-XX:+PrintGCDateStamps
-XX:+PrintGCDetails
- 打印 GC 时间戳和详细信息,方便分析。
- 但目前你还缺少
-XX:+PrintTenuringDistribution和-XX:+PrintGCApplicationStoppedTime,这两个可以帮助分析对象晋升和 STW 时间。
为什么 GC 时间还是会变长
结合你的现象和参数,我的判断是:
-
压测后老年代中浮动垃圾多
- CMS 并发清理阶段无法回收这些对象,只能等下一次 GC。
- 导致老年代可用空间减少,GC 触发频率增加。
-
老年代碎片化
- 虽然你有
UseCMSCompactAtFullCollection,但 Full GC 触发频率低,平时的 CMS GC 不会压缩。 - 碎片化会导致分配大对象时失败,从而触发 Full GC(STW 时间更长)。
- 虽然你有
-
Class Unloading 开销大
- 压测期间加载了大量类,下一次 CMS Final Remark 卸载这些类时会增加 STW 时间。
- 你现在的日志里
class unloading就是最大耗时点之一。
-
Rescan 阶段扫描量大
- 压测期间产生了大量跨代引用(老年代对象引用新生代对象)。
- 即使
CMSParallelRemarkEnabled开启了并行,扫描量大时耗时依然明显。
3. 下一步调优建议
3.1 监控和验证
-
增加以下参数,收集更多信息:
-XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+TraceClassUnloading -
这样可以看到:
- 对象晋升情况(是否有大量短命对象进入老年代)
- 每次 STW 的具体耗时
- 卸载了哪些类(是否是压测期间加载的)
根本原因
static Map<String, Class<?>> groovyClassCache = new ConcurrentHashMap<>();
public static void 你的业务逻辑() {
...
String groovyScriptId = "脚本ID";
String groovyScript = "需要动态加载的groovy";
Class<?> clazz = parseFunctionClass(groovyScript);
groovyClassCache.put(groovyScriptId, clazz);
...
}
public static Class parseFunctionClass(String groovyScript){
GroovyClassLoader groovyClassLoader = new GroovyClassLoader(Thread.currentThread().getContextClassLoader());
Class groovyClass = groovyClassLoader.parseClass(groovyScript);
return groovyClass;
}
调用链:
ConcurrentHashMap -> Class对象 -> ClassLoader -> 该ClassLoader加载的所有类的元数据
调用过程:
- 创建了一个新的 GroovyClassLoader(记作 loader1)
- 用 loader1 编译并加载了一个类 ScriptClass1
- 返回 ScriptClass1 的 Class 对象,并放到 cache 里
关键点:
- ScriptClass1 的 Class 对象内部有一个字段 classLoader,指向 loader1
- 你的 cache 持有 ScriptClass1 的引用
- 因为 ScriptClass1 引用了 loader1,所以 loader1 也被间接引用
- 只要 cache 不清理这个 Class 对象,loader1 永远不会被 GC
- 只要 loader1 不被 GC,它加载的所有类的元数据(Metaspace/PermGen)也不会被释放
解决方案
- 复用一个 GroovyClassLoader,避免创建大量 ClassLoader
- 复用一个 GroovyShell,避免创建大量 ClassLoader
- 关键在于复用
其他原因
- MetaspaceSize太小,如果应用内有动态加载机制那么MetaSapce 很快就会占满
- MetaspaceSize(初始阈值):通常是 21M 左右
- MaxMetaspaceSize(最大值):默认无限制(受系统内存限制)
- CMS垃圾回收算法的一个缺点:回收不干净
- 动态加载机制有问题,导致了一些东西一直回收不掉
我的建议优先级
- 先加监控参数,确认是浮动垃圾 + 碎片化 + class unloading 三个问题叠加。
- 调低 CMS 启动阈值,让 GC 更早发生,减少一次性清理量。
- 增大 Metaspace 或禁用类卸载,减少 Final Remark 停顿。
- 如果问题依然严重,考虑切换到 G1 GC。