为什么应用会频繁发生FullGC

71 阅读15分钟

原始日志

[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]

逐段解析

  1. [GC (CMS Final Remark)

    • 表示这是一次 CMS 收集器的 Final Remark 阶段
    • CMS 是一种低停顿的老年代垃圾回收器,Final Remark 是它的第二次 Stop-The-World(STW)阶段,用来完成并发标记阶段未处理完的对象标记。
  2. [YG occupancy: 392069 K (737280 K)]

    • YG = Young Generation(新生代)。
    • 新生代当前占用 392,069 KB,总容量 737,280 KB
  3. 2025-09-18T10:32:57.565+0800: [Rescan (parallel) , 0.0767771 secs]

    • Rescan:重新扫描 GC Roots 以及在并发标记阶段发生变化的引用。
    • parallel:多线程并行执行。
    • 耗时 0.0768 秒
  4. [weak refs processing, 0.0064534 secs]

    • 处理弱引用(WeakReference、SoftReference、PhantomReference 等)。
    • 耗时 0.00645 秒
  5. [class unloading, 0.0936137 secs]

    • 卸载无用的类(Class Unloading),释放元空间(Metaspace)或永久代(PermGen)。
    • 耗时 0.0936 秒
  6. [scrub symbol table, 0.0177076 secs]

    • 清理符号表(Symbol Table),移除无用的符号信息。
    • 耗时 0.0177 秒
  7. [scrub string table, 0.0021874 secs]

    • 清理字符串常量池(String Table)。
    • 耗时 0.00219 秒
  8. [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 秒

  9. [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 阶段卸载这些类,释放元数据(方法、字段、常量池等)。

  • 卸载类的过程需要:

    1. 遍历所有类加载器及其加载的类
    2. 清理方法区的元数据
    3. 更新符号表、字符串表
    4. 释放本地内存(Metaspace)

1.2 为什么会耗时高

class unloading 耗时高,通常是因为:

  1. 类加载/卸载频繁

    • 例如:

      • Web 容器(Tomcat、Jetty)频繁热部署应用
      • OSGi 动态模块加载/卸载
      • Groovy、JRuby、Scala REPL 等动态生成类
      • 反射 + 动态代理(CGLIB、Javassist、ByteBuddy)频繁生成临时类
  2. 类数量多

    • 卸载时需要遍历大量类元数据,类越多耗时越长。
  3. 类加载器层级复杂

    • 自定义 ClassLoader 多且相互引用,GC 需要更多时间分析可达性。
  4. Metaspace 碎片化

    • 类卸载时需要整理内存碎片,可能增加停顿时间。
  5. 符号表/字符串表很大

    • 卸载类时会触发符号表和字符串表的清理(你日志里 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 的标记阶段分为:

  1. Initial Mark(STW)

    • 标记 GC Roots 直接可达的对象
  2. Concurrent Mark(并发)

    • 和应用线程一起运行,标记可达对象
  3. Remark(STW)

    • 重新扫描(Rescan)并处理并发标记阶段遗漏的对象
  4. 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

  1. 减少类加载/卸载频率

    • 避免频繁创建新的 ClassLoader
    • 对动态生成的类做缓存
  2. 禁用类卸载(仅在必要时)

    • JVM 参数:

      -Xnoclassgc
      

      这样类不会卸载,但会占用更多 Metaspace

  3. 增大 Metaspace

    • 减少触发类卸载的频率:

      -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
      

针对 rescan

  1. 减少跨代引用

    • 尽量避免老年代对象频繁引用新生代对象
  2. 减少 GC Roots 数量

    • 关闭不必要的线程
    • 清理 ThreadLocal
  3. 减少新生代大小(如果新生代太大,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. 你的现象可能的根本原因

  1. 压测触发了大量动态类加载

    • 例如:

      • JSP 编译生成的类
      • 动态代理类(CGLIB、Javassist、ByteBuddy)
      • 脚本引擎(Groovy、Nashorn)
      • 热加载模块(OSGi、Spring DevTools)
  2. Metaspace 空间不足

    • 压测期间加载的类占满了 Metaspace
    • 下一次 GC 时必须卸载大量类
  3. 跨代引用多

    • 压测期间产生了很多老年代对象引用新生代对象
    • 导致 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)是 并发标记-清除 算法:

  1. Initial Mark(STW)
    标记 GC Roots 直接可达的对象
  2. Concurrent Mark(并发)
    和应用线程一起运行,标记可达对象
  3. Remark(STW)
    处理并发标记阶段遗漏的对象
  4. Concurrent Sweep(并发)
    清理不可达对象,释放内存

1.2 CMS 回收不干净的原因

  1. 浮动垃圾(Floating Garbage)

    • 在 Concurrent Sweep 阶段,应用线程还在运行
    • 可能会产生新的垃圾对象
    • 这些对象不会在本次 GC 中被清理,只能等下一次 GC
    • 结果就是老年代里总有一部分“清不掉”的垃圾
  2. 碎片化(Fragmentation)

    • CMS 是“标记-清除”算法,不会压缩内存
    • 清理后会留下很多小碎片
    • 即使老年代总空闲空间够,但可能没有足够大的连续空间分配大对象
    • 这会导致 晋升失败(promotion failed)  或 并发模式失败(concurrent mode failure) ,触发 Full GC(STW,耗时更长)
  3. 引用链复杂 / 跨代引用多

    • 如果老年代对象引用了很多新生代对象,CMS 在标记阶段需要扫描更多引用卡片(Dirty Card),耗时增加
    • 复杂的对象图也会让标记阶段变慢
  4. 类卸载和元数据清理

    • 如果 Metaspace 压力大,CMS Final Remark 会做大量 class unloading 和 symbol/string table 清理
    • 这会让整个 GC 周期变长

2. 为什么压测后更明显

结合你说的“晚上压测 → 第二天 GC 变慢”:

  • 压测期间:

    • 老年代被塞满了更多长生命周期对象(缓存、线程本地变量、静态集合等)
    • 动态类加载多,Metaspace 占用高
    • 对象引用关系更复杂
  • 压测结束:

    • 老年代里混合了:

      • 真正的长生命周期对象
      • 浮动垃圾
      • 碎片
    • CMS 清理一次也不能完全释放空间

  • 结果:

    • 老年代可用空间减少
    • CMS 触发频率增加
    • 每次 GC 扫描和清理的工作量变大 → 整体 GC 时间变长

3. 如何确认是“回收不干净”

你可以:

  1. 开启详细 GC 日志:

    -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution
    
  2. 观察 CMS GC 前后老年代的使用量:

    [CMS-concurrent-sweep: 3500M->3200M(3800M)]
    

    如果每次 GC 后老年代使用量下降不多(比如只降了 200M),说明有大量对象没被回收。

  3. 关注是否有:

    • concurrent mode failure
    • promotion 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 时间还是会变长

结合你的现象和参数,我的判断是:

  1. 压测后老年代中浮动垃圾多

    • CMS 并发清理阶段无法回收这些对象,只能等下一次 GC。
    • 导致老年代可用空间减少,GC 触发频率增加。
  2. 老年代碎片化

    • 虽然你有 UseCMSCompactAtFullCollection,但 Full GC 触发频率低,平时的 CMS GC 不会压缩。
    • 碎片化会导致分配大对象时失败,从而触发 Full GC(STW 时间更长)。
  3. Class Unloading 开销大

    • 压测期间加载了大量类,下一次 CMS Final Remark 卸载这些类时会增加 STW 时间。
    • 你现在的日志里 class unloading 就是最大耗时点之一。
  4. 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加载的所有类的元数据

调用过程:

  1. 创建了一个新的 GroovyClassLoader(记作 loader1)
  2. 用 loader1 编译并加载了一个类 ScriptClass1
  3. 返回 ScriptClass1 的 Class 对象,并放到 cache 里

关键点:

  1. ScriptClass1 的 Class 对象内部有一个字段 classLoader,指向 loader1
  2. 你的 cache 持有 ScriptClass1 的引用
  3. 因为 ScriptClass1 引用了 loader1,所以 loader1 也被间接引用
  4. 只要 cache 不清理这个 Class 对象,loader1 永远不会被 GC
  5. 只要 loader1 不被 GC,它加载的所有类的元数据(Metaspace/PermGen)也不会被释放

解决方案

  1. 复用一个 GroovyClassLoader,避免创建大量 ClassLoader
  2. 复用一个 GroovyShell,避免创建大量 ClassLoader
  3. 关键在于复用

其他原因

  1. MetaspaceSize太小,如果应用内有动态加载机制那么MetaSapce 很快就会占满
  2. MetaspaceSize(初始阈值):通常是 21M 左右
  3. MaxMetaspaceSize(最大值):默认无限制(受系统内存限制)
  4. CMS垃圾回收算法的一个缺点:回收不干净
  5. 动态加载机制有问题,导致了一些东西一直回收不掉

我的建议优先级

  1. 先加监控参数,确认是浮动垃圾 + 碎片化 + class unloading 三个问题叠加。
  2. 调低 CMS 启动阈值,让 GC 更早发生,减少一次性清理量。
  3. 增大 Metaspace 或禁用类卸载,减少 Final Remark 停顿。
  4. 如果问题依然严重,考虑切换到 G1 GC