S05-X27 · GC 原因码与 CMS/G1 的实现细节
调 GC 参数之前,先读懂日志那行括号。-Xlog:gc 里的 (Allocation Failure)、(Metadata GC Threshold)、(GCLocker Initiated GC) 对应完全不同的触发路径:前者要动堆与晋升策略,后者得查 JNI 与直接内存。把原因码当成"GC 又发生了",参数只会越调越乱。
这篇按四层走:原因码从哪来 → CMS 与 G1 各自怎么算回收目标 → 发行版与线程视角的差异 → 什么时候该换工具。
1. 原因码长什么样:先建立日志读法
$ java -Xms4g -Xmx4g \
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=50m \
-jar app.jar
日志行按 JDK 17 的 -Xlog 格式对齐后大致如下:
[2026-11-09T09:12:03.117+0000][12.345s][info][gc] GC(37) Pause Young (Normal) (G1 Evacuation Pause) 512M->128M(4096M) 8.412ms
[2026-11-09T09:12:09.884+0000][19.112s][info][gc] GC(38) Pause Full (Metadata GC Threshold) 1024M->640M(4096M) 412.556ms
| 字段 | 含义 | 判据价值 |
|---|---|---|
GC(37) |
第几次回收 | 与 uptime 相除即回收频率 |
Pause Young (Normal) |
分代 + 回收类型 | 判断是不是混合回收 |
(G1 Evacuation Pause) |
G1 内部动作名 | 区分 Normal 与 Concurrent Start |
512M->128M(4096M) |
回收前→回收后(总容量) | 判断这次回收有没有收益 |
8.412ms |
STW 时长 | 直接对齐 SLA |
Pause Full 与 Pause Young 的停顿差一到两个数量级(典型量级)。看到 Pause Full 混在 Young 之间,先抓 cause 再谈参数。
2. GCCause:十几个原因码,各有各的处理方向
| 原因码 | 触发条件 | 是否正常 | 处理方向 |
|---|---|---|---|
Allocation Failure |
目标分区难以完成分配 | 正常,看频率 | 加大 Young 区或降低分配速率 |
Metadata GC Threshold |
Metaspace 水位触线 | 不正常 | 查动态类生成、类加载器泄漏 |
Ergonomics |
自适应策略起的首次回收 | 正常 | 只作为基线 |
GCLocker Initiated GC |
JNI 临界区退出后补一次回收 | 需关注 | 查 JNI 与直接内存占用 |
System.gc() |
显式调用(含三方库间接调用) | 不正常 | 查代码与依赖,必要时 -XX:+DisableExplicitGC |
Heap Dump Initiated GC |
jcmd GC.heap_dump 前做清理 |
人工触发 | 从统计里排除 |
Last ditch collection |
清软引用前的最后一次尝试 | 已到 OOM 边缘 | 立刻留堆快照 |
WhiteBox Initiated GC |
测试框架触发 | 仅测试环境 | 生产出现即异常 |
Last ditch collection 是最后一道闸:抛 OutOfMemoryError 前 HotSpot 会清掉所有软引用,见到它就该带 -XX:+HeapDumpOnOutOfMemoryError 拿现场。
3. CMSHeap:并发回收的代际结构
CMS 的代际结构是 ParNew(新生代)+ CMS(老年代,并发标记清除),堆在逻辑上仍是 Eden / Survivor / Old 三段,回收器线程与用户线程并发跑标记与清除。
$ java -Xms4g -Xmx4g -Xmn1g \
-XX:+UseConcMarkSweepGC -XX:+UseParNewGC \
-XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly \
-XX:+CMSParallelRemarkEnabled -XX:+CMSClassUnloadingEnabled \
-Xlog:gc* -jar app.jar
| 参数 | 作用 | 不配会怎样 |
|---|---|---|
-XX:+UseConcMarkSweepGC |
启用 CMS | JDK 14 起已移除,加不上 |
CMSInitiatingOccupancyFraction |
老年代占用到多少开始并发标记 | 默认阈值易触发并发模式失败 |
+UseCMSInitiatingOccupancyOnly |
固定用上面的比例 | 阈值被反复改,行为不可预测 |
CMSParallelRemarkEnabled |
并行重标记 | 重标记阶段停顿拉长 |
CMSClassUnloadingEnabled |
并发标记时顺带卸类 | Metaspace 只涨不落 |
CMS 最典型的坑是并发模式失败:并发回收没跑完老年代已满,退化成一次 Full GC,日志表现为 (concurrent mode failure) 与长停顿同时出现。方向两个:提前 CMSInitiatingOccupancyFraction,或压晋升速率(加大 Young 区、降低存活率)。
CMSHeap 的形态随版本变化:JDK 9 起废弃并打警告,JDK 14 移除。参数是否还存在,按对应版本文档核对。
4. G1MixedGCCountTarget:G1 怎么决定"摸几个老年代分区"
G1 把堆切成等大 Region;Young GC 后堆占用越过 IHOP(Initiating Heap Occupancy Percent)就启动并发标记,标记结束进入混合回收——回收全部新生代分区 + 一部分老年代分区,后者分批做完,批数由 G1MixedGCCountTarget 控制。
$ java -Xms8g -Xmx8g -XX:+UseG1GC \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:G1MixedGCCountTarget=8 \
-XX:G1HeapWastePercent=5 \
-XX:G1MixedGCLiveThresholdPercent=85 \
-XX:MaxGCPauseMillis=200 \
-Xlog:gc* -jar app.jar
| 参数 | 默认值方向 | 调大的后果 | 调小的后果 |
|---|---|---|---|
G1MixedGCCountTarget |
8 | 每批收回的分区更少,单次停顿更短,但混合回收阶段拖得更长 | 单次混合回收更重,停顿更陡 |
G1HeapWastePercent |
5 | 允许更多垃圾留在堆里不回收,混合阶段更早结束 | 回收更激进,混合阶段更长 |
G1MixedGCLiveThresholdPercent |
85 | 存活率高于阈值的分区不纳入本次回收 | 更多分区被回收,停顿更重 |
InitiatingHeapOccupancyPercent |
45 | 标记启动更早,并发失败风险更低,CPU 占用更高 | 启动更晚,容易退化成 Full GC |
MaxGCPauseMillis |
200 | G1 以此为软目标反推 Region 数量与批数 | 目标过小会导致回收追不上分配 |
G1MixedGCCountTarget 是上限式目标,不是每批必须回收的分区数:G1 在 MaxGCPauseMillis 约束下按"最划算优先"挑分区,总批数尽量不超过该值。
验证是否进入混合回收:看日志里的 Pause Young (Mixed),或 jcmd 5123 PerfCounter.print | grep -i mixed。
5. GCLocker:JNI 临界区如何把 GC 卡住
GCLocker Initiated GC 背后是 GCLocker 机制:线程经 JNI 取原始指针(GetPrimitiveArrayCritical / GetStringCritical)时进入临界区,区内 GC 不能移动对象,HotSpot 挂起请求并记一笔"待办",等最后一个临界区退出时补回收。
| 现象 | 机制 | 处理 |
|---|---|---|
日志里零星出现 GCLocker Initiated GC |
临界区退出后补回收 | 正常,观察频率 |
| 连续多条且伴随长停顿 | 多个线程长驻临界区 | 找 JNI 调用点,缩短临界区 |
出现 Failed to allocate + 该原因码 |
临界区内分配失败 | 查本地库与直接内存 |
| 堆外占用高但 GC 不动 | GCLocker 与直接内存释放互相等待 | 结合 BufferPool 与堆外统计 |
直接内存的释放依赖 Cleaner(DirectByteBuffer 的清理器),其引用处理需要能安全移动对象;临界区长期不退出就会拉长这条链。看到 GCLocker 顺手看直接内存:
$ jcmd 5123 VM.native_memory summary # 需启动时加 -XX:NativeMemoryTracking=summary
$ java -XX:MaxDirectMemorySize=512m -XX:+UseG1GC -jar app.jar
排查线程是否卡在临界区,用带锁信息的线程栈:
$ jcmd 5123 Thread.print -l > /tmp/th-$(date +%H%M).txt
栈里出现 GCLocker::stall_until_clear、jni_GetPrimitiveArrayCritical 之类的帧即这条路径,多来自本地库或加密原生实现。
6. 发行版与线程视角:Temurin-17.0.12、OSThread 与 NIO.2
同一 -XX 参数在不同发行版与大版本上可用性不一致,以 Temurin 为例:
$ java -version
openjdk version "17.0.12" 2026-07-16
OpenJDK Runtime Environment Temurin-17.0.12+7 (build 17.0.12+7)
OpenJDK 64-Bit Server VM Temurin-17.0.12+7 (build 17.0.12+7, mixed mode, sharing)
| 视角 | 观察方式 | 说明 |
|---|---|---|
| 发行版 | java -version 第三行是否带 Temurin |
发行版绑定补丁级别,Temurin-17.0.12 与 17.0.9 的 GC 修复不同 |
| 可用参数 | -XX:+PrintFlagsFinal 里搜参数名 |
JDK 17 已无 CMS 参数,G1 齐全 |
| 线程单元 | jstack 里的 GC Thread#N |
回收线程属 JVM 内部,HotSpot 以 OSThread/JavaThread 两类形态管理 |
| 语言级线程 | 栈里的虚拟线程(JDK 21+) | JDK 17 无此特性,别按 21 的文档核对 |
| 文件 IO | NIO.2 的 Files/Path/异步通道 |
异步文件通道由线程池承载,池满会表现为"GC 正常但吞吐不动" |
GC Thread# 不属于用户线程池,别在"线程数上限"里给它留位;真正抢 CPU 的是 -XX:ParallelGCThreads 与 -XX:ConcGCThreads。
NIO.2 在 GC 视角下意味着分配位置:Files.readAllBytes、ByteBuffer.allocateDirect 的字节不进 Java 堆,堆很健康而 RSS 在涨:
$ jcmd 5123 System.gc # 仅在取证时使用,触发软引用/清洁器处理,判断堆外是否可回收
7. 排错:六种日志读不通的情况
| 现象 | 原因 | 处理 |
|---|---|---|
| 查不到 cause | -Xlog 只开 gc 没开 gc* |
加 -Xlog:gc*,确认 tags 已输出 |
只有 Allocation Failure 却频繁 Full GC |
晋升失败记在别处 | 开 -Xlog:gc+ergo+phases 看晋升 |
| 混合回收从不出现 | 没过 IHOP,或 G1HeapWastePercent 过宽 |
先降 IHOP 再收紧阈值 |
| 加了 CMS 参数启动失败 | 该 JDK 已移除 CMS | PrintFlagsFinal 核对,改用 G1 |
| 停顿与日志对不上 | 时间戳是墙钟,STW 期间也计入 | 用 uptime 差值交叉验证 |
| 堆正常但 RSS 持续涨 | 堆外:直接内存、Metaspace、线程栈 | NMT + GC.heap_info + 容器指标三处对齐 |
8. 边界:什么情况这套读法不适用
| 场景 | 判断 | 该用什么 |
|---|---|---|
| 低延迟硬约束(P99 毫秒级) | MaxGCPauseMillis 只是软目标 |
ZGC / Shenandoah,或改架构削峰 |
| 堆小于 2G 且对象小 | G1 的分区管理与记忆集带来额外开销 | 老老实实用 G1 或回退到分代串行 |
| 容器内堆大于配额 | 要按容器配额而非宿主内存算 | 用 MaxRAMPercentage 核对 |
| 停顿来自 safepoint | GC 停顿短但应用卡顿 | 开 -Xlog:safepoint 定位去优化 |
| 需要毫米级归因 | 日志粒度不够 | JFR + async-profiler |
一句话:原因码定方向,回收器定手法,堆外定你看到的数字是否完整。
10. 自测
-Xlog:gc里只有Allocation Failure,却频繁出现 Full GC,该追加哪个标签去看?- 看到
Last ditch collection说明进程已经走到哪一步? - 只配了
CMSInitiatingOccupancyFraction而没配UseCMSInitiatingOccupancyOnly,行为为什么不可预测? G1MixedGCCountTarget调到 20 为什么不会让回收更快?GCLocker Initiated GC反复出现,应该往哪两个方向查?- 堆日志健康但进程 RSS 持续上涨,要取哪三处数据对齐?