GC 频繁排查

2023年春节假期后第一天上班,某社交平台的客服系统收到了大量投诉:接口响应时间从正常的50ms飙升到500ms,用户体验严重下降。

技术团队排查后发现:服务每天凌晨3点会执行一次全量数据同步,同步过程中会产生大量临时对象。这些对象触发了频繁的Minor GC,但因为堆内存设置偏小(只有4G),Minor GC回收不充分,导致每隔10分钟就触发一次Full GC。

Full GC停顿时间约500ms,累计影响了约10万次请求。

这是一个典型的"GC参数设置不合理 + 业务逻辑问题"导致的性能问题。

【面试官手记】

GC频繁是Java服务最常见的性能问题之一。我面试过的候选人里,能说出"G1和CMS区别"的不超过40%,能说出"怎么通过GC日志定位问题"的不超过20%。GC问题的关键不是背参数,而是理解GC的原理和调优思路

一、GC频繁的常见原因 🔴

1.1 五大原因

GC频繁的五大原因:

1. 内存设置不合理
   - 堆内存太小,对象无法在Young区分配
   - Survivor区太小,对象直接进入Old区
   - 典型症状:Full GC频繁

2. 对象分配过快
   - 瞬时创建大量对象
   - 典型场景:批处理、定时任务、数据同步
   - 典型症状:Minor GC频繁,耗时增加

3. 内存泄漏
   - 无用对象无法被回收
   - 导致Old区逐渐填满
   - 典型症状:Old区持续增长,Full GC后不下降

4. GC参数不合理
   - 选择错误的GC算法
   - 参数设置不当
   - 典型症状:GC停顿时间过长

5. 大对象直接进入Old区
   - 大对象分配失败进入Old区
   - 典型场景:大数组、大集合
   - 典型症状:Old区突然增长

1.2 GC监控指标

GC日志关键指标:

YoungGC:
- 频率:正常约几秒到几十秒一次
- 耗时:正常 < 50ms
- 回收量:应该大于Eden区大小

FullGC:
- 频率:正常约几分钟到几小时一次
- 耗时:CMS约200-500ms,G1约100-300ms
- 回收量:应该接近Old区使用量

停顿时间目标:
- P99 < 200ms
- 最大停顿 < 500ms
- 用户无感知 < 50ms

1.3 面试追问

面试官:GC频繁怎么排查?

候选人:首先看GC日志,分析是Minor GC还是Full GC频繁。

面试官:Minor GC频繁怎么处理?

候选人:Minor GC频繁说明对象创建太快,可能是Young区太小或者Eden区太小。需要调整 -Xmn 和 Survivor区比例。

面试官:Full GC频繁怎么处理?

候选人:先看是老年代满了还是MetaSpace满了。老年代满了要排查内存泄漏;MetaSpace满了要排查类加载问题。

【面试官心理】

GC调优的追问通常很深入。能回答出"Minor GC频繁调Young区"的候选人,说明知道Young/Old的分工;能回答出"Full GC看Old区还是MetaSpace"的候选人,说明知道GC分类排查的思路。

二、排查流程 🔴

2.1 第一步:开启GC日志

# JVM参数开启GC日志
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-XX:+PrintHeapAtGC \
-XX:+PrintTenuringDistribution \
-Xloggc:/path/to/gc.log \
-XX:NumberOfGCLogFiles=5 \
-XX:GCLogFileSize=10M

2.2 第二步:分析GC日志

# 实时分析GC日志
tail -f gc.log | grep -E "(GC|Allocation Failure|Full GC)"

# GC统计分析
cat gc.log | grep -E "Full GC" | wc -l  # Full GC次数
cat gc.log | grep -E "Full GC" | awk '{sum+=$NF} END {print sum}'  # Full GC总耗时

2.3 GC日志解读

GC日志示例(CMS):
[GC (Allocation Failure) [ParNew: 1234567K->12345K(1382400K), 0.0456789 secs] 2345678K->1123456K(3456000K), 0.0458901 secs] [Times: user=0.12 sys=0.03, real=0.05 secs]

GC日志解读:
- Allocation Failure:分配失败,触发GC
- ParNew:ParNew垃圾收集器(Young区)
- 1234567K->12345K:GC前->GC后(Young区)
- 1382400K:Young区总大小
- 0.0456789 secs:GC耗时
- 2345678K->1123456K:GC前->GC后(堆内存)
- 3456000K:堆总大小

Full GC日志:
[Full GC (Allocation Failure) [CMS: 1234567K->12345K(3456000K), 2.345678 secs] 2345678K->1123456K(3456000K), [Metaspace: 123456K->12345K]

2.4 在线分析工具

GC日志分析网站:
1. GCEasy:https://gceasy.io
   - 上传GC日志,自动分析
   - 生成报告,标注问题

2. GCViewer
   - 本地工具
   - 离线分析

3. Google Garbage Cat
   - 开源工具
   - 支持实时监控

三、常见场景与优化 🟡

3.1 场景一:Minor GC频繁

原因:对象分配速率过高,Young区太小

症状:
- YoungGC频率 < 1秒
- YoungGC后 Eden区几乎被填满

优化方案:
1. 增加Young区大小
   -Xmn1024m -XX:NewRatio=2

2. 调整Survivor区比例
   -XX:SurvivorRatio=8  # Eden:Survivor = 8:1

3. 优化代码
   - 减少临时对象创建
   - 对象复用

3.2 场景二:Full GC频繁

原因:Old区对象过多

症状:
- Full GC频率 < 5分钟
- Full GC后Old区下降不明显

优化方案:
1. 增加堆内存
   -Xmx8g -Xms8g

2. 调整Old区比例
   -XX:NewRatio=2  # Old:Young = 2:1

3. 调整CMS触发时机
   -XX:CMSInitiatingOccupancyFraction=70  # 70%时开始回收

3.3 场景三:CMS GC中断

原因:CMS并发模式失败

症状:
- 日志出现 "concurrent mode failure"
- 触发一次STW的Full GC

优化方案:
1. 增加堆内存
2. 提前启动CMS
   -XX:CMSInitiatingOccupancyFraction=60
3. 使用G1代替CMS

四、GC调优参数 🟡

4.1 堆内存设置

# 通用推荐配置
-Xms4g          # 初始堆大小
-Xmx4g          # 最大堆大小
-Xmn2g          # Young区大小
-XX:NewRatio=1  # Old:Young = 1:1

# G1推荐配置
-Xms4g
-Xmx4g
-XX:MaxGCPauseMillis=200  # 最大GC停顿目标
-XX:+UseG1GC
-XX:G1HeapRegionSize=4m

4.2 G1调优

G1关键参数:
- MaxGCPauseMillis:最大停顿目标(默认200ms)
- G1HeapRegionSize:Region大小(1-32MB)
- InitiatingHeapOccupancyPercent:开始Mixed GC的阈值(默认45%)

G1调优思路:
1. 设置合理的停顿目标
   -XX:MaxGCPauseMillis=200

2. 观察停顿时间分布
   -XX:+PrintGCTimeStamps -XX:+PrintGCApplicationStoppedTime

3. 调整Region大小
   - 大数据量:Region=8m
   - 小数据量:Region=1m

五、生产避坑 🟡

5.1 GC排查的五大坑

坑1:只看GC次数不看耗时

问题:GC次数不多,但每次耗时很长
场景:Full GC耗时2秒
解决方案:
- 不仅看GC次数,还要看GC耗时
- 设置停顿时间告警

坑2:只看Full GC忽略Minor GC

问题:Minor GC耗时累积也很可观
场景:Minor GC每次50ms,每秒10次 = 500ms/秒
解决方案:
- 统计所有GC的总耗时
- GC总耗时/运行时间 < 5%

坑3:参数调优盲目

问题:网上找了参数直接套用
场景:4核8G机器用了16核的参数
解决方案:
- 根据机器配置调整参数
- 通过压测验证效果

坑4:不停重启掩盖问题

问题:重启后正常,过几天又出问题
场景:内存泄漏,重启只治标不治本
解决方案:
- 找到根本原因
- 修复代码问题

坑5:忽略了Metaspace

问题:只看Heap,忽略Metaspace
场景:大量动态类加载
JDK 8+解决方案:
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

5.2 GC调优目标

GC调优的三个层次:

层次1:没有Full GC
- 优化目标:避免Full GC
- 方法:合理设置Old区大小

层次2:GC停顿 < 200ms
- 优化目标:P99停顿 < 200ms
- 方法:选择合适的GC算法

层次3:GC停顿 < 50ms
- 优化目标:用户无感知
- 方法:极致的GC调优

建议:先达到层次1,再追求层次2,最后考虑层次3。

六、真实面试回放 🟡

面试官:G1和CMS的区别是什么?

候选人(小张):G1是CMS的升级版,主要区别:

  1. 内存布局不同。CMS是 Old + Young + Survivor,G1是把堆分成多个大小相等的Region。

  2. 回收方式不同。CMS是标记-清除,会产生内存碎片;G1是标记-整理,没有内存碎片。

  3. 停顿控制不同。CMS无法控制停顿时间;G1可以设置 MaxGCPauseMillis。

面试官:什么场景适合用G1?

小张:两个场景:

一是堆内存 > 4G。CMS在大堆下性能下降明显,G1更稳定。

二是需要控制停顿时间。G1可以设置停顿目标,CMS只能被动回收。

面试官:什么场景用CMS不用G1?

小张:大概两个场景:

一是JDK版本低。JDK 8默认是Parallel GC,如果要用CMS需要显式开启;G1需要JDK 9+。

二是旧系统已经稳定。CMS虽然有内存碎片,但已经优化好了,没必要换成G1。

【面试官手记】

小张这场面试的亮点:

  1. 知道G1和CMS的内存布局区别:连续 vs 分Region

  2. 知道G1和CMS的算法区别:标记-整理 vs 标记-清除

  3. 知道G1的停顿控制能力

  4. 知道选型依据:堆大小、JDK版本

GC调优是P6工程师必备技能,能回答出G1和CMS区别的候选人,说明理解了GC原理。

追问方向:会问"G1的Mixed GC是什么"和"怎么设置G1的参数",这些是更深入的追问。

GC频繁排查的核心是看日志 → 定原因 → 调参数。记住三个要点:

  1. Minor GC频繁:调Young区大小、减对象创建
  2. Full GC频繁:调Old区大小、排查内存泄漏
  3. CMS vs G1:大堆用G1,小堆用CMS

GC调优的目标:没有Full GC → 停顿 < 200ms → 停顿 < 50ms。