1. 项目背景
业务场景:某支付平台的"交易记录查询"微服务,部署在 K8s 集群中,每个 Pod 分配 4GB 堆内存,JVM 默认使用 Parallel GC。白天高峰期 QPS 约 5000,每 30 分钟左右出现一次 3~5 秒的 Full GC 停顿。该停顿导致 P99 响应延迟从正常的 50ms 飙升至 4000ms+,触发 K8s 健康检查超时(liveness probe 设 3 秒),产生级联 Pod 重启------每次重启又引发流量迁移到其他节点,形成"雪崩"效应。
痛点放大:
-
Parallel GC 的"甜蜜期"已过 :在 4GB 堆下,Parallel GC 的 Full GC 停顿随老年代大小线性增长。当老年代积累到 2~3GB 时,一次 Full GC 的 STW(Stop-The-World)时间轻松超过 3 秒。团队曾尝试调大
-XX:NewRatio让年轻代变大来减少 Minor GC 频率,反而导致 Survivor 区膨胀,晋升压力更大。 -
G1 切换的"盲目性" :运维团队知道 G1 是 JDK 9+ 的默认 GC,但切换时只把
-XX:+UseParallelGC改成-XX:+UseG1GC,其他参数全部沿用。结果更糟------G1 的并发标记阶段消耗了 15% CPU,业务吞吐反而下降了 10%,P99 延迟虽然降到 800ms,但 P50 延迟从 20ms 变成了 60ms。 -
GC 日志"看不懂" :G1 的日志比 Parallel GC 复杂得多------多了并发标记周期(Concurrent Mark Cycle)、混合回收(Mixed GC)、大对象分配(Humongous Allocation)等概念。运维拿到
-Xlog:gc*输出后,面对满屏的G1 Evacuation Pause、Concurrent Start、Remark、Cleanup、Mixed标签,完全不知道哪个阶段在占用业务线程,哪个阶段在并发进行。
本章从 G1 的"垃圾优先"设计哲学出发,逐层拆解 Region、RSet、SATB、CSet 等核心概念,通过日志全景解读和参数调优实战,建立一套可复用的 G1 生产化调优方法论。
2. 项目设计
(周一早会,小胖的微服务又在凌晨 3 点重启了 3 次。)
小胖(抱着半杯奶茶):大师,我受不了了。我那支付查询服务,每次高峰期必然 Full GC 3 秒起步,K8s 直接杀 Pod。我是不是把堆调到 8GB 就能解决问题?堆越大越不容易满嘛!
大师(头也不抬):你那 4GB 堆,Parallel GC 单次 Full GC 扫描 4GB 老年代,串行标记 + 串行压缩。调到 8GB,停顿时间从 3 秒变成 6 秒------死得更快。
小胖:那咋办?不是说 JDK 21 默认就是 G1 吗?我直接换 G1 是不是就万事大吉了?
大师 :G1 不是"更快的 Parallel GC"------它是完全不同的设计哲学。你先把"分代"这个概念在脑子里重塑一遍。传统的 Serial/Parallel GC 是物理分代 ------堆被硬切成"一块 Eden + 两块 Survivor + 一块 Old",边界固定。G1 是逻辑分代------整个堆被切成一堆大小相等的 Region,每个 Region 可以被标记为 Eden、Survivor、Old、Humongous 中的任何一种,角色动态切换。
r
Parallel GC 物理分代:
┌─────────────────────────────────────────────┐
│ Young Generation │
│ ┌──────┬───────┬───────┐ │
│ │ Eden │ S0 │ S1 │ │
│ └──────┴───────┴───────┘ │
├─────────────────────────────────────────────┤
│ Old Generation │
│ │
└─────────────────────────────────────────────┘
G1 GC 逻辑分代(Region-based):
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ E │ E │ E │ S │ S │ E │ O │ O │ H │ E │ F │ O │
├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤
│ O │ F │ E │ E │ S │ O │ H │ H │ O │ E │ E │ S │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
E=Eden S=Survivor O=Old H=Humongous F=Free
每个Region大小相同(1~32MB,默认根据堆大小自动计算)
小白:等一下。G1 号称"可预测的停顿时间",它是怎么做到的?难道它能预知未来------知道下一次 GC 要花多久?
大师 :好问题。G1 的核心思想是 "垃圾优先"(Garbage First) ------不追求一次回收所有垃圾,而是优先回收"垃圾占比最高"的 Region。
每次 Young GC 时,G1 不仅回收 Eden 区的死对象,还会选一小部分垃圾最多的 Old Region 一起回收------这就是 Mixed GC。老的 Region 中,那些"几乎全是垃圾(存活对象很少)"的 Region 回收起来(需要拷贝的存活对象少)最划算:用很小的拷贝成本换取大片连续空间。
G1 有一个暂停预测模型 (src/hotspot/share/gc/g1/g1Policy.hpp:119,predictor() 方法),根据历史 GC 数据(每个 Region 的存活字节数、RSet 扫描时间、拷贝时间)来预测一次 GC 需要多长时间。然后 G1 在预测的 slack 时间内,动态选择把多少个 Region 加入回收集合(CSet, Collection Set)------保证回收工作能在目标停顿时间内完成。
sql
G1 的"垃圾优先"策略:
┌─────────────────────────────────────────┐
│ Old Region 1: 存活率 2% │ 垃圾 98% ★★ │ ← 先回收这个!
│ Old Region 2: 存活率 10% │ 垃圾 90% ★★ │ ← 再回收这个
│ Old Region 3: 存活率 45% │ 垃圾 55% ★ │ ← 看时间够不够
│ Old Region 4: 存活率 80% │ 垃圾 20% ☆ │ ← 不划算,跳过
│ Old Region 5: 存活率 3% │ 垃圾 97% ★★ │ ← 下次回收
└─────────────────────────────────────────┘
小胖 :所以 G1 的 -XX:MaxGCPauseMillis 就是设一个"预算",然后 G1 在这个预算内尽量回收垃圾最多的区域?
大师 :对,但要小心------MaxGCPauseMillis 不是硬性保证,而是软目标(soft goal)。G1 会尽量把 GC 停顿控制在这个目标内,但当遇到以下情况时无法保证:
- Evacuation Failure(疏散失败):回收 Region 时,没有足够的空闲 Region 来存放拷贝出去的对象 → 被迫扩大停顿。
- Humongous Allocation 风暴:大量大对象分配导致老年代碎片化 → 触发 Full GC。
- Mixed GC 时选中的 Region 存活率比预期高:预测模型"猜错了"。
源码中 G1Policy::max_pause_time_ms()(g1Policy.hpp:171)会调用 _mmu_tracker->max_gc_time() 来获取配置的目标,然后 calculate_desired_num_eden_regions_by_pause() 会基于这个目标和预测模型计算出本次 GC 应该回收多少个 Eden Region(g1Policy.hpp:208)。
小白:那 G1 的"并发标记"是怎么回事?Mixed GC 怎么知道哪些 Old Region 里垃圾多?
大师 :这就涉及 G1 的并发标记周期 (Concurrent Mark Cycle)。G1 不像 Parallel GC 那样等老年代满了才做 Full GC------它在老年代占用达到一定比例(由 -XX:InitiatingHeapOccupancyPercent 控制)时,提前启动并发标记,在应用线程运行的同时标记存活对象。
G1 的一个完整并发周期包含以下阶段(源码见 g1CollectorState.hpp:39-55):
ini
G1 GC 完整周期(Young-only → Concurrent Mark → Mixed → Young-only):
Young-only Phase Concurrent Mark Space Reclamation
(纯Young GC) (并发标记周期) (空间回收 - Mixed GC)
┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─────┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
│Y│→│Y│→│Y│→│Y│→│CS│───→│Conc │───────────────→│M│→│M│→│M│→│Y│→...
└─┘ └─┘ └─┘ └─┘ └─┘ │Mark │ └─┘ └─┘ └─┘ └─┘
├─────┤
Y=Young GC │Remark│──────→ Cleanup
CS=Concurrent Start ├─────┤
M=Mixed GC │Cleanup│
└─────┘
STW(暂停)阶段:CS, Remark, Cleanup 有短暂STW
并发阶段:Concurrent Mark 主体部分是并发的,与应用线程同时运行
关键阶段的源码对应:
| 阶段 | 源码位置 | 说明 |
|---|---|---|
| Concurrent Start | g1CollectorState.hpp:47 YoungConcurrentStart |
STW,标记 GC Roots 直接可达的对象,设置 TAMS(Top At Mark Start) |
| Concurrent Mark | g1ConcurrentMark.hpp:50 G1ConcurrentMark |
并发遍历对象图,用 SATB 算法处理并发修改 |
| Remark | g1Policy.hpp:321 record_concurrent_mark_remark_end() |
STW,处理 SATB 缓冲区中的剩余引用,完成最终标记 |
| Cleanup | g1Policy.hpp:323 record_concurrent_mark_cleanup_end() |
STW,统计存活对象、构建 Mixed GC 候选 Region 列表、回收全空 Region |
| Mixed GC | g1CollectorState.hpp:52 Mixed |
STW,回收 Eden + 部分 Old Region |
小胖:刚刚提到"SATB",那是什么?跟 Parallel GC 的卡表有什么区别?
大师 :SATB(Snapshot-At-The-Beginning)是 G1 并发标记的核心算法。它的思想是:在并发标记开始时拍一个"逻辑快照",标记过程中新产生的垃圾暂时不管,但保证"在快照时刻是活的"对象一定不被漏标。
具体流程:
- Concurrent Start 阶段,把每个 Region 的
top(已分配边界)保存为 TAMS(Top At Mark Start)------TAMS 之上的对象都是快照后新分配的,视为活对象。 - 并发标记期间,如果应用线程要修改一个引用,先把修改前的旧引用记录到 SATB 缓冲区(
g1SATBMarkQueueSet.hpp:26)。 - Remark 阶段,处理 SATB 缓冲区中积压的引用,确保不漏标。
less
SATB 示例:
时间线:
T0: 对象 A → 对象 B(快照时刻,A引用B)
T1: 应用线程修改:对象 A → 对象 C(不再引用B了)
→ SATB 记录旧引用:B
T2: 并发标记线程遍历到B → 标记B为存活(即使A已不引用B)
结果:B在本次周期不会被回收,留到下一轮处理
这就是 G1 的 trade-off:宁可多留一些"浮动垃圾"(floating garbage),也不能漏标一个存活对象 。SATB 缓冲区大小由 -XX:G1SATBBufferSize 控制(g1_globals.hpp:157,默认 1KB)。
如果你看到日志中有 To-space exhausted 或 Evacuation Failure,说明老年代碎片化严重------没有足够的连续空闲 Region 来承接从 CSet 中拷贝出来的存活对象。这是一个紧急信号,表示 G1 的并发标记没有足够的时间完成,或者 IHOP 设得太大、并发标记启动太晚。
技术映射(全章汇总)
| 生活比喻 | 技术概念 | 源码位置 |
|---|---|---|
| Parallel GC 的分代 ↔ 办公室按"楼层"划分(一楼全是新人,二楼全是老员工);G1 的 Region ↔ 开放式办公区的"工位格"------今天坐新人(Eden),明天变成老员工工位(Old),后天空出来(Free),动态流转 | G1 Region 逻辑分代 vs Parallel GC 物理分代 | g1HeapRegionType.hpp, g1HeapRegion.hpp |
| 大扫除时,先收拾"最乱"的角落(从最容易清理出大片空间的地方下手),而不是按部就班一个角落一个角落地扫 | G1 "垃圾优先"策略 + CSet 选择 + 暂停预测模型 | src/hotspot/share/gc/g1/g1Policy.hpp:119 (predictor()) |
| 月薪预算------尽量在这个预算内过日子,但突然生病(Evacuation Failure)或房贷到期(Humongous 压力)就可能超支 | MaxGCPauseMillis 软目标 + G1Policy 回收量计算 |
g1Policy.hpp:171, g1Policy.hpp:208 |
| 物业公司在工作日期间(应用运行)派人挨家挨户统计"哪些房间空置"(标记垃圾),到下班后(STW)正式做清理 | G1 并发标记周期(Concurrent Mark Cycle)+ SATB 算法 | g1CollectorState.hpp:39-55, g1ConcurrentMark.hpp:50 |
3. 项目实战
3.1 环境准备
| 组件 | 版本 | 用途 |
|---|---|---|
| JDK | OpenJDK 21 | 运行测试程序和正式服务 |
| GC 日志分析 | GCViewer 1.38+ / gceasy.io | 可视化 GC 日志,生成停顿分布图 |
| 压测工具 | JMeter 5.5+ | 模拟 5000 QPS 支付查询负载 |
| 内存分析 | jconsole / VisualVM | 实时监控堆使用和 GC 活动 |
| 操作系统 | Linux x86_64 (4C/8G),cgroup 限制 | 模拟 K8s Pod 环境 |
3.2 分步实现
步骤一:G1 GC日志全景解读
目标:配置 G1 详细日志、运行高分配负载、逐行解析各类 GC 日志的含义。
bash
# 1. 启动一个 G1 GC 的 Spring Boot 服务,配置详细 GC 日志
java -XX:+UseG1GC \
-Xms4g -Xmx4g \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:G1HeapRegionSize=4m \
-XX:ConcGCThreads=2 \
-XX:ParallelGCThreads=4 \
-Xlog:gc*=info:file=gc-g1-detailed.log:time,level,tags:filecount=10,filesize=100M \
-jar payment-query-service.jar
Young GC 日志逐行解读:
less
[2026-01-15T14:30:12.345+0800][info][gc,start ] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
// ↑ GC编号42,Young GC暂停,类型为"Normal"(非Concurrent Start,非Prepare Mixed)
// 对应源码 g1CollectorState.hpp:43 YoungNormal
[2026-01-15T14:30:12.346+0800][info][gc,task ] GC(42) Using 4 workers of 4 for evacuation
// ↑ 并行回收使用了4个GC工作线程(由-XX:ParallelGCThreads决定)
[2026-01-15T14:30:12.390+0800][info][gc,heap ] GC(42) Eden regions: 512->0(448)
// ↑ Eden Region数量:回收前512个→回收后0个(已清空)→下次分配448个
// 每个Region 4MB → Eden 2GB→0B,下次分配1792MB
[2026-01-15T14:30:12.391+0800][info][gc,heap ] GC(42) Survivor regions: 64->64(64)
// ↑ Survivor Region:前后不变(存活对象已被复制到Survivor区)
[2026-01-15T14:30:12.391+0800][info][gc,heap ] GC(42) Old regions: 384->390
// ↑ Old Region数量增加6个(部分Survivor区对象晋升到Old)
[2026-01-15T14:30:12.391+0800][info][gc,heap ] GC(42) Humongous regions: 8->8
[2026-01-15T14:30:12.391+0800][info][gc,metaspace] GC(42) Metaspace: 48456K->48456K(109440K)
[2026-01-15T14:30:12.391+0800][info][gc ] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 3488M->1592M(4096M) 45.678ms
// ↑ 关键汇总:堆使用从3488MB降到1592MB,总堆4096MB,本次暂停45.678ms
[2026-01-15T14:30:12.391+0800][info][gc,cpu ] GC(42) User=0.15s Sys=0.01s Real=0.05s
// ↑ CPU时间:用户态0.15s(4线程并行 → 总CPU时间>实际时间),实际墙钟0.05s
Concurrent Start + Remark 日志解读:
scss
[2026-01-15T14:45:33.210+0800][info][gc,start ] GC(78) Pause Young (Concurrent Start) (G1 Evacuation Pause)
// ↑ 注意类型是"Concurrent Start"而非"Normal"
// 这意味着G1在该Young GC中同时执行了并发标记的根扫描
[2026-01-15T14:45:33.260+0800][info][gc ] GC(78) Pause Young (Concurrent Start) (G1 Evacuation Pause) 3700M->1750M(4096M) 50.123ms
[2026-01-15T14:45:33.261+0800][info][gc,marking ] GC(78) Concurrent Mark From Roots
// ↑ 并发标记开始------此阶段与业务线程并行执行,不暂停业务
[2026-01-15T14:45:34.012+0800][info][gc,marking ] GC(78) Concurrent Mark From Roots 751.234ms
// ↑ 并发标记耗时约751ms(STW暂停为零!)
// 对应源码 g1ConcurrentMark.hpp:G1ConcurrentMark 类
[2026-01-15T14:45:34.500+0800][info][gc,start ] GC(79) Pause Remark
// ↑ Remark阶段是STW的,处理SATB缓冲区积压
[2026-01-15T14:45:34.530+0800][info][gc ] GC(79) Pause Remark 1780M->1760M(4096M) 0.890ms
// ↑ Remark暂停仅0.89ms------说明SATB缓冲区积压很少,并发标记效率高
[2026-01-15T14:45:34.800+0800][info][gc,start ] GC(80) Pause Cleanup
// ↑ Cleanup阶段也是STW的,构建Mixed GC候选Region列表
[2026-01-15T14:45:34.802+0800][info][gc ] GC(80) Pause Cleanup 1760M->1740M(4096M) 0.350ms
// ↑ Cleanup暂停仅0.35ms------回收了20MB全空Region
Mixed GC 日志解读:
scss
[2026-01-15T14:48:12.100+0800][info][gc,start ] GC(85) Pause Young (Prepare Mixed) (G1 Evacuation Pause)
// ↑ Prepare Mixed:为Mixed GC做准备,只回收Eden,顺便准备Old Region候选集
[2026-01-15T14:48:25.340+0800][info][gc,start ] GC(86) Pause Young (Mixed) (G1 Evacuation Pause)
// ↑ "Mixed"字样出现------本次GC同时回收Eden和选中的Old Region
[2026-01-15T14:48:25.390+0800][info][gc,heap ] GC(86) Eden regions: 448->0(448)
[2026-01-15T14:48:25.390+0800][info][gc,heap ] GC(86) Survivor regions: 64->64(64)
[2026-01-15T14:48:25.390+0800][info][gc,heap ] GC(86) Old regions: 420->380
// ↑ 关键:Old Region从420个减少到380个------这是Mixed GC回收了40个Old Region!
// 每个Region 4MB → 回收了约160MB的老年代空间
[2026-01-15T14:48:25.390+0800][info][gc ] GC(86) Pause Young (Mixed) (G1 Evacuation Pause) 3660M->1810M(4096M) 52.340ms
// ↑ Mixed GC虽回收了更多内存,但暂停时间仅52ms,仍然在200ms目标内
Humongous 分配日志:
less
[2026-01-15T14:50:01.050+0800][info][gc,heap ] GC(90) Humongous regions: 8->10
// ↑ 巨型对象Region数增加了2个
[2026-01-15T14:50:01.052+0800][info][gc,alloc ] GC(90) Humongous allocation: 4194304 bytes (region size: 2097152 bytes)
// ↑ 分配了4MB的对象,但Region大小是2MB → 需要1个StartsHumongous + 1个ContinuesHumongous → 占用2个Region
// 规则:对象大小 ≥ Region大小的一半即为Humongous
// 源码:g1HeapRegion.hpp:66-69 定义了StartsHumongous和ContinuesHumongous
To-space Exhausted / Evacuation Failure 检测:
scss
[2026-01-15T15:10:52.000+0800][info][gc,start ] GC(120) Pause Young (Mixed) (G1 Evacuation Pause)
[2026-01-15T15:10:52.050+0800][info][gc,ergo ] GC(120) To-space exhausted
// ↑ 警报!疏散目标空间耗尽------没有足够空闲Region存放从CSet中拷贝的存活对象
[2026-01-15T15:10:52.130+0800][info][gc ] GC(120) Pause Young (Mixed) (G1 Evacuation Pause) (Evacuation Failure) 3950M->3900M(4096M) 130.234ms
// ↑ 堆使用几乎没降,说明无法完成回收。暂停时间从预期的50ms飙升至130ms
// 原因:并发标记启动太晚(IHOP设太高),空闲Region不足
通过 jstat 实时监控 G1 状态:
bash
# 实时查看G1 GC统计(每1秒刷新)
jstat -gcutil <pid> 1000
# 输出列含义:
# S0: Survivor0使用率 S1: Survivor1使用率 E: Eden使用率
# O: Old使用率 M: Metaspace使用率 CCS: 压缩类空间使用率
# YGC: Young GC次数 YGCT: Young GC总耗时
# FGC: Full GC次数 FGCT: Full GC总耗时 CGC: 并发GC周期数 CGCT: 并发GC总耗时
# GCT: GC总耗时
bash
# 输出示例(正常健康的G1):
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
0.00 82.50 35.20 48.65 95.32 93.40 236 12.345 0 0.000 15 4.567 16.912
# ↑ FGC=0 表示没有Full GC,CGC=15表示已经完成了15个并发标记周期
# O=48.65%表示老年代占用48.65%------接近IHOP阈值(45%)时会启动下一轮并发标记
# 异常状态(Evacuation Failure 后):
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
98.50 95.30 0.00 92.10 95.50 93.20 289 18.342 3 9.876 12 6.789 35.007
# ↑ O=92.10%说明老年代快满了,FGC=3说明已经发生了3次Full GC
# ↑ Survivor区接近满→说明晋升压力大,有Evacuation Failure风险
步骤二:G1参数调优实战
目标:从默认参数起步,逐步测量和调整关键参数,最终收敛到稳定配置。
bash
# === 第一阶段:默认G1参数基线测试 ===
java -XX:+UseG1GC \
-Xms4g -Xmx4g \
-Xlog:gc*=info:file=gc-baseline.log:time,level,tags \
-jar payment-query-service.jar &
# 压测5分钟(5000 QPS)
# 压测结束后分析关键指标:
基线测试结果(默认参数):
| 指标 | 值 | 评估 |
|---|---|---|
| Young GC 平均暂停 | 48ms | ✅ 可接受 |
| Young GC P99 暂停 | 120ms | ⚠️ 接近上限 |
| Mixed GC 平均暂停 | 65ms | ✅ 可接受 |
| Mixed GC 次数 (5min) | 8次 | ⚠️ 偏少,老年代回收不充分 |
| 并发标记周期耗时 | 820ms | ⚠️ 偏长,CPU占用大 |
| Full GC 次数 | 1次(1320ms) | ❌ 不可接受 |
| Humongous 分配 | 15次/min | ⚠️ 需要关注 |
| 堆使用峰值 | 3.6GB (90%) | ⚠️ 偏高 |
| 业务吞吐 | 4850 QPS | ✅ 接近目标 |
从日志中发现的问题:
- 并发标记启动太晚(IHOP默认45%,但4GB堆上,45%意味着老年代1.8GB才开始标记→标记完成时可能已到3.2GB)
- Mixed GC 回收效率低,老年代垃圾没有被充分回收
- 大对象频繁分配导致碎片化
bash
# === 第二阶段:针对性调优 ===
# 调优策略:
# 1. 降低MaxGCPauseMillis到150ms(让G1更频繁但更短的回收)
# 2. 降低IHOP到35%(提前启动并发标记)
# 3. 增大Region Size到8MB(减少Humongous碎片)
# 4. 增加ConcGCThreads到3(加速并发标记)
java -XX:+UseG1GC \
-Xms4g -Xmx4g \
-XX:MaxGCPauseMillis=150 \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:G1HeapRegionSize=8m \
-XX:ConcGCThreads=3 \
-XX:ParallelGCThreads=4 \
-XX:G1ReservePercent=10 \
-XX:G1MixedGCLiveThresholdPercent=85 \
-XX:G1HeapWastePercent=5 \
-Xlog:gc*=info:file=gc-tuned.log:time,level,tags \
-jar payment-query-service.jar &
调优后结果对比:
| 指标 | 调优前(默认) | 调优后 | 改善 |
|---|---|---|---|
| Young GC 平均暂停 | 48ms | 42ms | ✅ -12.5% |
| Young GC P99 暂停 | 120ms | 95ms | ✅ -20.8% |
| Mixed GC 平均暂停 | 65ms | 58ms | ✅ -10.8% |
| Mixed GC 次数 (5min) | 8次 | 12次 | ✅ 回收更充分 |
| 并发标记周期耗时 | 820ms | 550ms | ✅ -32.9% |
| Full GC 次数 | 1次 | 0次 | ✅ 消灭Full GC |
| Humongous 分配 | 15次/min | 4次/min | ✅ Regon增大→更少大对象 |
| 堆使用峰值 | 3.6GB (90%) | 2.8GB (70%) | ✅ 老年代更健康 |
| 业务吞吐 | 4850 QPS | 4920 QPS | ✅ +1.4% |
| P99 业务延迟 | 420ms | 98ms | ✅ -76.7% |
G1 参数模板(4GB~8GB 堆的生产级基线):
bash
# G1生产级基线参数清单(可直接用于线上部署)
-XX:+UseG1GC # 启用G1
-Xms4g -Xmx4g # 堆大小(建议ms=mx,避免动态调整)
-XX:MaxGCPauseMillis=150 # 停顿目标150ms(从200ms起步,根据业务SLA调整)
-XX:InitiatingHeapOccupancyPercent=35 # 老年代占35%时启动并发标记
-XX:G1HeapRegionSize=8m # Region大小8MB(根据堆大小选择:4GB→4MB, 8GB→8MB, 16GB→16MB)
-XX:ConcGCThreads=2 # 并发标记线程数(建议=CPU核数的1/4)
-XX:ParallelGCThreads=4 # STW并行GC线程数(建议=CPU核数)
-XX:G1ReservePercent=10 # 保留10%堆空间作为疏散备用
-XX:G1MixedGCLiveThresholdPercent=85 # 存活率>85%的Old Region不回收
-XX:G1HeapWastePercent=5 # 允许堆中5%空间不被回收(避免过度GC)
-XX:G1MixedGCCountTarget=8 # 一次并发标记后,计划8次Mixed GC回收完
-XX:+UseStringDeduplication # 启用字符串去重(G1特有)
-XX:StringDeduplicationAgeThreshold=3 # 经过3次GC后参与去重
-XX:+ParallelRefProcEnabled # 并行处理引用对象
-XX:+DisableExplicitGC # 禁止System.gc()触发Full GC
-XX:+AlwaysPreTouch # 启动时预触所有内存页(减少运行时缺页中断)
-Xlog:gc*=info:file=/var/log/app/gc.log:time,level,tags:filecount=10,filesize=100M
参数核心原理说明:
-
InitiatingHeapOccupancyPercent(IHOP) :控制"何时启动并发标记"。这是 G1 最重要的参数之一。设太低(20%以下)→ 并发标记频繁启动,CPU 消耗大;设太高(60%以上)→ 标记还没完成老年代就满了 → Evacuation Failure → Full GC。源码见g1IHOPControl.hpp:38,G1IHOPControl类实现了静态和自适应两种 IHOP 策略。当G1UseAdaptiveIHOP为 true(默认值,g1_globals.hpp:101),G1 会自动根据应用分配速率调整 IHOP 值。 -
G1HeapRegionSize:Region 大小决定了 Humongous 对象的阈值(>RegionSize/2)。设太小 → 正常大小的对象变成 Humongous → 碎片化;设太大 → Region 粒度粗,回收不够精确。有效值是 2 的幂(1MB/2MB/4MB/8MB/16MB/32MB)(g1_globals.hpp:267-270)。 -
G1ReservePercent:疏散保留空间(g1_globals.hpp:262-265)。当空闲 Region 低于此比例时,G1 会主动减少 Young Region 的分配(g1Policy.hpp:92-98,_reserve_regions),收敛老年代增长。这个参数相当于 G1 的"安全气囊"------给并发标记争取时间。
步骤三:大对象(Humongous)优化
目标:模拟 Humongous 对象分配场景,观察其对 G1 的影响,并实施优化。
java
// HumongousAllocDemo.java ------ 模拟大对象分配
import java.util.ArrayList;
import java.util.List;
public class HumongousAllocDemo {
// 假设 G1HeapRegionSize=4MB,则 >2MB 的对象为 Humongous
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Exception {
System.out.println("PID: " + ProcessHandle.current().pid());
System.out.println("模拟Humongous对象分配...");
System.out.println("Region大小由 -XX:G1HeapRegionSize 决定\n");
List<byte[]> humongousList = new ArrayList<>();
// 分配 3MB 对象(> 2MB = Region的一半 → Humongous)
for (int i = 0; i < 20; i++) {
byte[] huge = new byte[3 * _1MB]; // Humongous!
humongousList.add(huge);
System.out.println("分配第 " + (i + 1) + " 个 3MB Humongous对象");
Thread.sleep(500);
}
System.out.println("\n释放一半Humongous对象...");
for (int i = humongousList.size() - 1; i >= 10; i--) {
humongousList.remove(i);
}
Thread.sleep(2000);
System.out.println("仍有 " + humongousList.size() + " 个对象存活");
System.gc(); // 触发GC观察Humongous回收行为
Thread.sleep(3000);
}
}
bash
# 运行Humongous分配测试
java -XX:+UseG1GC \
-Xms512m -Xmx512m \
-XX:G1HeapRegionSize=4m \
-Xlog:gc+heap=info:file=gc-humongous.log:time,level,tags \
HumongousAllocDemo
Humongous 日志分析:
yaml
# 分配3MB对象(Region=4MB → 阈值=2MB → 触发Humongous)
[gc,alloc] Humongous allocation: 3145728 bytes (region size: 2097152 bytes)
# ↑ 3MB对象被判定为Humongous,占1个StartsHumongous Region + 1个ContinuesHumongous Region
[gc,heap] Humongous regions: 0->2
# ↑ Humongous Region数增加2个
# 问题:Humongous对象释放后,空间碎片化
# 每个Humongous对象占2个Region(StartsHumongous + ContinuesHumongous)
# 释放后留下的"空洞"难以被其他大小对象利用
Humongous 优化的三种策略:
-
增大 Region 大小 :最直接的方案。
-XX:G1HeapRegionSize=16m将 Humongous 阈值提升到 8MB → 原来 3MB 的对象不再是 Humongous,正常在 Eden 分配,正常回收。 -
对象池化 :对于频繁分配的大对象(如网络缓冲区、大 JSON 解析缓冲),用
ThreadLocal<byte[]>或对象池复用:
java
// 对象池避免频繁分配大数组
private static final ThreadLocal<byte[]> BUFFER_POOL =
ThreadLocal.withInitial(() -> new byte[4 * 1024 * 1024]); // 4MB缓冲区,一次分配重复使用
- 拆分大对象 :将一个大
byte[]拆成多个小块用ByteBuffer管理:
java
// 方案A(Bad):一个巨大的数组
byte[] bigBuffer = new byte[100 * 1024 * 1024]; // 100MB → 25个Humongous Region!
// 方案B(Good):分段管理
ByteBuffer[] segments = new ByteBuffer[100];
for (int i = 0; i < 100; i++) {
segments[i] = ByteBuffer.allocateDirect(1024 * 1024); // 1MB per segment, 堆外分配
}
步骤四:从 Parallel GC 到 G1 的迁移实战
目标:同一个 5000 QPS 支付查询服务,分别用 Parallel GC 和 G1 运行,量化对比差异。
bash
# === Parallel GC 基线测试 ===
java -XX:+UseParallelGC \
-Xms4g -Xmx4g \
-XX:ParallelGCThreads=4 \
-Xlog:gc*=info:file=gc-parallel.log:time,level,tags \
-jar payment-query-service.jar &
# JMeter 压测 10 分钟,5000 QPS,记录 GC 日志
# === G1 迁移测试 ===
java -XX:+UseG1GC \
-Xms4g -Xmx4g \
-XX:MaxGCPauseMillis=150 \
-XX:InitiatingHeapOccupancyPercent=35 \
-XX:G1HeapRegionSize=4m \
-XX:ConcGCThreads=2 \
-XX:ParallelGCThreads=4 \
-Xlog:gc*=info:file=gc-g1-migrate.log:time,level,tags \
-jar payment-query-service.jar &
# JMeter 压测 10 分钟,5000 QPS,记录 GC 日志
Parallel GC vs G1 对比(4GB 堆,5000 QPS,10 分钟压测):
| 对比维度 | Parallel GC | G1(调优后) | 差异分析 |
|---|---|---|---|
| Young GC 次数 | 48次 | 156次 | G1的Young GC更频繁(Eden更小),但单次时间短 |
| Young GC 平均暂停 | 85ms | 42ms | G1 暂停短 50.6% |
| Young GC P99 暂停 | 280ms | 95ms | G1 延迟可控性显著更好 |
| Full GC 次数 | 3次 | 0次 | G1 通过并发标记避免 Full GC |
| Full GC 最大暂停 | 3.2秒(单次) | N/A | Parallel 的 Full GC 是服务杀手 |
| STW 总时间 | 12.8秒 | 8.5秒 | G1 总 STW 时间更少 |
| 并发标记 CPU 开销 | N/A | 平均 8% | G1 的 trade-off:用并发 CPU 换低停顿 |
| P99 业务延迟 | 380ms | 90ms | G1 让 P99 延迟降了 76% |
| P50 业务延迟 | 18ms | 22ms | G1 P50 略高(CPU被并发标记占用) |
| 吞吐量(完成的请求数) | 298万 | 295万 | G1 吞吐低约 1%(并发标记的代价) |
| 最大堆使用率 | 92% | 72% | G1 堆使用更安全、余量更大 |
迁移检查清单:
ruby
□ 1. 将堆大小ms和mx设为相同值(-Xms4g -Xmx4g),避免堆动态调整
□ 2. 移除-Xmn/-XX:NewRatio/-XX:SurvivorRatio(G1动态管理,固定参数有害)
□ 3. 移除-XX:PretenureSizeThreshold(仅Serial/Parallel有效,G1忽略)
□ 4. 根据业务SLA设定MaxGCPauseMillis(默认200ms,交易类建议100-200)
□ 5. 压测验证IHOP自适应是否合适,必要时手动调整
□ 6. 确认GC日志格式兼容现有的日志分析工具
□ 7. 灰度发布:先10%→50%→100%流量切换
□ 8. 设置告警:FGC>0立即触发(Full GC=调优失败)
□ 9. 设置告警:单次GC暂停>500ms触发(超过MaxGCPauseMillis的3倍)
□ 10. 生产观察至少1个完整的流量周期(24小时,含高峰和低谷)
步骤五:G1 混合回收调优
目标:精细调整 Mixed GC 参数,在回收效率和停顿时间之间找到最优平衡。
Mixed GC(混合回收)的调优集中在三个参数上(源码见 g1_globals.hpp:289-308):
-
G1MixedGCLiveThresholdPercent(默认 85%):存活率超过此阈值的 Old Region 不被纳入 CSet。设太低(如 60%)→ 错过很多可回收 Region → 老年代积压;设太高(如 95%)→ 回收"几乎全是垃圾"的 Region → 回收效率低。 -
G1HeapWastePercent(默认 5%):允许堆中最多有多少比例的"浪费空间"不回收。设太小(1-2%)→ G1 会试图回收每一个 Region,导致 Mixed GC 持续时间过长;设太大(10%+)→ 堆利用率低,老年代很快再触发并发标记。 -
G1MixedGCCountTarget(默认 8):一次并发标记后,计划用几次 Mixed GC 回收完所有候选 Old Region。分散到更多 Mixed GC → 每次回收的 Region 少 → 单次暂停更短,但 Mixed GC 持续时间更长(应用在"降级"状态更久)。
bash
# 实验:对比不同 G1HeapWastePercent 的效果
for waste in 2 5 10 15; do
echo "=== G1HeapWastePercent=$waste ==="
java -XX:+UseG1GC -Xms4g -Xmx4g \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapWastePercent=$waste \
-Xlog:gc*=info:file=gc-waste-${waste}.log:time,level,tags \
-jar payment-query-service.jar &
PID=$!
sleep 300 # 运行5分钟
kill $PID
sleep 5
# 统计 Mixed GC 次数
echo "Mixed GC 次数: $(grep -c 'Mixed)' gc-waste-${waste}.log)"
echo "平均 Mixed GC 暂停: $(grep 'Mixed).*ms$' gc-waste-${waste}.log | awk '{print $(NF-1)}' | sed 's/ms//' | awk '{sum+=$1; count++} END {print sum/count}')ms"
echo ""
done
典型测试结果:
| G1HeapWastePercent | Mixed GC 次数/5min | 平均Mixed GC暂停 | 堆使用峰值 | 评价 |
|---|---|---|---|---|
| 2% | 22次 | 38ms | 65% | ⚠️ Mixed GC过于频繁,CPU开销大 |
| 5% | 12次 | 58ms | 72% | ✅ 推荐:效率和暂停的黄金平衡点 |
| 10% | 8次 | 72ms | 78% | ⚠️ 回收不够积极,堆使用偏高 |
| 15% | 5次 | 85ms | 85% | ❌ 接近老年代预期上限,有Full GC风险 |
Mixed GC 候选 Region 选择 :源码中 G1CollectionSetChooser 类负责从并发标记结果中挑选 Mixed GC 候选 Region。选择逻辑是:按"回收效率"排序(可回收垃圾多 + 存活对象需要拷贝少 = 效率高),优先选择效率最高的 Region 加入 CSet(见 g1Policy.hpp:251 的 predict_gc_efficiency)。
3.3 测试验证
JMeter 压测脚本核心片段:
xml
<!-- payment-query-test.jmx 核心线程组配置 -->
<ThreadGroup>
<stringProp name="ThreadGroup.num_threads">50</stringProp>
<stringProp name="ThreadGroup.ramp_time">30</stringProp>
<boolProp name="ThreadGroup.scheduler">true</boolProp>
<stringProp name="ThreadGroup.duration">600</stringProp>
<!-- 50并发,30秒爬坡,持续10分钟 -->
</ThreadGroup>
<!-- HTTP Request: 支付记录查询 -->
<HTTPSamplerProxy>
<stringProp name="HTTPSampler.path">/api/payment/query</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<elementProp name="HTTPsampler.Arguments">
<!-- 随机查询不同的交易ID -->
<stringProp name="tradeId">${__Random(10000000,99999999)}</stringProp>
</elementProp>
</HTTPSamplerProxy>
验证矩阵:
| 验证项 | 方法 | 通过标准 |
|---|---|---|
| P99 业务延迟 ≤ 100ms | JMeter 聚合报告 | 95% 采样点满足 |
| Full GC 次数 = 0 | grep -c "Full GC" gc.log |
结果为 0 |
| 单次 GC 暂停 ≤ 200ms | 解析 GC 日志 | 99% GC暂停满足 |
| 无 Evacuation Failure | grep "Evacuation Failure" gc.log |
无匹配 |
| 堆使用峰值 ≤ 80% | jstat -gc 持续监控 |
全程 O% ≤ 80 |
| Humongous 分配 < 10次/min | grep "Humongous" gc.log |
统计结果 |
| 并发标记时间 < 1s | grep "Concurrent Mark" gc.log |
标记时长 ≤ 1000ms |
bash
#!/bin/bash
# g1-verify.sh ------ G1调优验证一键脚本
echo "=== G1 GC 调优验证报告 ==="
echo ""
LOG_FILE="gc.log"
if [ ! -f "$LOG_FILE" ]; then
echo "错误:找不到GC日志文件 $LOG_FILE"
exit 1
fi
echo "1. 基础统计:"
echo " Young GC 次数: $(grep -c 'Pause Young.*(Normal)' $LOG_FILE)"
echo " Concurrent Start: $(grep -c 'Pause Young.*(Concurrent Start)' $LOG_FILE)"
echo " Mixed GC 次数: $(grep -c 'Pause Young.*(Mixed)' $LOG_FILE)"
echo " Full GC 次数: $(grep -c 'Pause Full' $LOG_FILE)"
echo ""
echo "2. 暂停时间统计:"
echo " 所有GC暂停(ms):"
grep -oP ' \d+\.\d+ms$' $LOG_FILE | sed 's/ms//' | awk '
BEGIN { min=9999; max=0; sum=0; count=0 }
{
count++; sum+=$1
if($1<min) min=$1
if($1>max) max=$1
}
END {
printf " 最小: %.2fms 平均: %.2fms 最大: %.2fms\n", min, sum/count, max
}'
echo ""
echo "3. Evacuation Failure:"
EVAC_FAIL=$(grep -c "Evacuation Failure\|To-space exhausted" $LOG_FILE)
if [ "$EVAC_FAIL" -eq "0" ]; then
echo " ✅ 无疏散失败 (Evacuation Failure)"
else
echo " ❌ 发现 $EVAC_FAIL 次疏散失败!"
fi
echo ""
echo "4. Humongous 分配峰值:"
grep "Humongous regions" $LOG_FILE | tail -5
echo ""
echo "5. Full GC 检查:"
FGC=$(grep -c "Pause Full" $LOG_FILE)
if [ "$FGC" -eq "0" ]; then
echo " ✅ 零 Full GC"
else
echo " ❌ 发生 $FGC 次 Full GC!"
grep "Pause Full" $LOG_FILE
fi
4. 项目总结
4.1 优点与缺点
| 维度 | G1 | Parallel GC | ZGC | Shenandoah |
|---|---|---|---|---|
| 停顿时间 | 目标可控(通常 50-200ms) | 随堆大小线性增长(大堆可达秒级) | 亚毫秒级(<1ms) | 亚毫秒级(<1ms) |
| 吞吐量 | 高(比 Parallel 低 5-10%) | 最高 | 中(比 G1 低 5-10%) | 中 |
| 堆大小适配 | 4GB~64GB 最佳,最大支持 TB | 最佳 1GB~8GB | 8GB~16TB | 4GB~32GB |
| 内存开销 | RSet 占堆 ~5% | 几乎没有额外开销 | 染色指针(无额外内存) | Brooks 转发指针(压缩Oops 下 ~3%) |
| CPU 开销 | 中等(并发标记 + RSet 维护) | 低(仅 STW 时使用 CPU) | 中等(并发重定位 + 染色指针) | 中等(并发标记 + 转发指针) |
| Full GC 概率 | 低(参数正确时几乎为零) | 低到中等 | 极低(不退化为 Full GC) | 极低(不退化为 Full GC) |
| 大对象处理 | ❌ Humongous 碎片化风险 | 直接进 Old Gen,无特殊处理 | ✅ 单对象可达堆大小一半 | ✅ 无 Humongous 概念 |
| JDK 版本兼容 | JDK 7+ (9+ 默认) | JDK 1.2+ | JDK 15+ (生产就绪) | JDK 12+ |
| 调优复杂度 | ⭐⭐⭐ (参数中等偏多) | ⭐ (参数极少) | ⭐ (几乎无需调优) | ⭐ (几乎无需调优) |
4.2 适用场景
G1 最适用的 5 个场景:
-
中等堆大小(4GB-32GB)的微服务:这是 G1 的甜蜜区------堆足够大需要并发标记,又不会大到 RSet 开销不可承受。绝大部分 Spring Boot 微服务在这个范围内。
-
对 P99 延迟有 SLA 要求的在线服务 :如支付、交易、用户登录。
MaxGCPauseMillis=100-200ms能有效控制 P99 延迟在可接受范围。 -
大对象(>1MB)较少的应用:如果应用极少分配超过 Region 一半大小的对象,G1 的 Humongous 碎片化风险可控。
-
从 JDK 8 Parallel GC 升级到 JDK 17/21 的保守迁移:G1 作为默认 GC,社区支持最丰富、bug 修复最及时,迁移风险最低。
-
容器化部署(cgroup 限制下的)微服务 :G1 对堆大小的自适应性比 Parallel GC 更好,配合
-XX:+UseContainerSupport(JDK 10+ 默认)使用。
不适合 G1 的 2 个场景:
-
超大堆(64GB+)或延迟要求 < 10ms 的服务:应选择 ZGC。G1 在超大堆下 RSet 开销线性增长,且 Mixed GC 很难在 10ms 内完成。ZGC 的染色指针 + 并发重定位几乎消除了 STW 随堆增长的关联。
-
批处理 / 离线计算 / 对 STW 完全不敏感的场景:应选择 Parallel GC。Parallel GC 的吞量更高(并发标记不占 CPU),且实现简单意味着更少的边缘 case。
4.3 注意事项
| 风险类别 | 详细说明 |
|---|---|
MaxGCPauseMillis 过低 |
设 10ms → G1 只能回收极少的 Region → 老年代永远得不到回收 → 最终 Full GC 螺旋。从 200ms 起步,每次降低 20-30ms 并充分测试。 |
G1HeapRegionSize 不当 |
过大 → 回收粒度粗,浪费空间;过小 → 正常对象变 Humongous。经验公式:堆大小/2048 ≈ 合适的Region大小(如 4GB/2048=2MB,取最近的 2 的幂 = 2MB 或 4MB) |
| Humongous 碎片化 | 频繁分配释放 >RegionSize/2 的对象 → Humongous Region 碎片 → Evacuation Failure。解法:增大 Region 或消除大对象分配。 |
| RSet 内存膨胀 | 大堆+大量跨Region引用 → RSet 占用可达堆的 10%+ → OOM。JDK 17/21 对 RSet 做了大量优化(Howl card set 容器,见 g1_globals.hpp:233-239)。 |
| JDK 版本差异 | JDK 8 的 G1 不成熟(Full GC 仍较常见),JDK 11 稳定但 RSet 优化不如 17/21,JDK 21 的 G1 几乎不会退化为单线程 Full GC。生产环境用 JDK 17+。 |
| 字符串去重启动时机 | StringDeduplicationAgeThreshold 设太低(1)→ 每个对象都去重 → CPU开销大;设太高(10+)→ 去重没作用。默认 3 是合理的。 |
JDK 版本 G1 改进要点:
| JDK 版本 | G1 关键改进 |
|---|---|
| JDK 8 | G1 从实验性变为正式支持,但 Full GC 仍是单线程的,大堆下 Full GC 可能耗时数十秒 |
| JDK 11 | G1 默认启用 NUMA 感知分配;改进了并发标记的终止协议,减少"标记风暴";Parallel Full GC(JEP 307) |
| JDK 17 | G1 默认 G1UseAdaptiveIHOP=true;RSet 改用 Howl card set 容器降低内存占用;改进了 Mixed GC 候选选择 |
| JDK 21 | G1 进一步优化了并发标记效率;Region pinning(JEP 423)支持虚拟线程的 object pinning 而不退化 Full GC;G1PeriodicGCInterval 支持定期 GC 垃圾回收 |
4.4 常见踩坑经验
案例 1:MaxGCPauseMillis=10ms 引发的"永不回收"灾难
某低延迟交易系统将 MaxGCPauseMillis 设为 10ms,期望毫秒级停顿。结果是 G1 每次 Young GC 只回收几个 Region(时间"预算"太低),Old Region 的垃圾永远不够时间处理。老年代持续增长,并发标记完成后即使检测到大量垃圾 Old Region,Mixed GC 在 10ms 预算内也只能回收 1~2 个 Region → 老年代越来越满,最终触发 Full GC 5.2 秒。
根因 :MaxGCPauseMillis 不是硬实时保证------它是软目标。设得太低反而让 G1"饿死"。
修复 :将 MaxGCPauseMillis 调回 120ms,同时降低 IHOP 到 35%,让并发标记更早启动。修复后 P99 延迟稳定在 90ms,零 Full GC。
技术原理 :源码中 G1Policy::calculate_desired_num_eden_regions_by_pause()(g1Policy.hpp:208)会根据 pause 目标反算能回收多少个 Eden Region。如果 pause 目标太低 → 算出的 Eden Region 数很少 → 每次 GC 间隔极短 → Young GC 频率爆炸。同时 Old Region 被 G1OldCSetRegionThresholdPercent 限制比例,加上 Mixed GC 有最低回收量要求(calc_min_old_cset_length)------如果预算不够回收最低量,Mixed GC 根本不启动。
案例 2:日志清洗服务------Humongous 引发 Full GC 风暴
某日志清洗服务使用 16GB 堆、G1 默认 Region(由 JVM 自动计算为 8MB)。业务代码中频繁分配和释放 5MB 的 byte[] 缓冲(Region=8MB → Humongous 阈值=4MB → 5MB 对象是 Humongous)。每分钟分配约 200 个这样的缓冲区。
日志现象:
csharp
[gc,heap] Humongous regions: 256->280 (持续增长)
...
[gc] Pause Full (G1 Compaction Pause) 14500M->10000M(16384M) 4800ms
// ↑ 每 10 分钟一次 Full GC,停顿 4.8 秒
// Humongous Region 碎片化 → 无法在 Mixed GC 中回收(Humongous 对象被判定为存活)
// → 老年代被 Humongous 对象撑满 → Full GC
根因 :G1 中 Humongous 对象直接分配在老年代(跳过 Eden/Survivor)。频繁分配释放大对象 → Humongous Region 碎片 → 老年代"虚高" → 并发标记过早触发 → Mixed GC 又无法回收碎片化的 Humongous Region → Full GC 螺旋。
修复(三管齐下):
- 增大 Region Size:
-XX:G1HeapRegionSize=16m→ Humongous 阈值提升到 8MB → 5MB 对象不再是 Humongous,正常走 Eden→Survivor→Old 晋升路径。 - 对象池化:用
ArrayBlockingQueue<byte[]>做缓冲区池,避免频繁 new/GC。 - 堆外替代:将 5MB 以上的缓冲区改用
ByteBuffer.allocateDirect()。
修复后 Humongous Region 从 280+ 降至 15~20,Full GC 消失。
案例 3:32GB 大堆 + 默认 Region Siz → RSet 吃掉 4GB
某大数据平台的数据查询服务使用 32GB 堆、G1 默认 Region(32GB 堆的默认 Region 是 2MB,导致 Region 数 = 32GB/2MB = 16384 个 Region)。业务模式是大量跨 Region 引用(HashMap、ConcurrentHashMap 散列的 key-value 跨多个 Region)。
JVM 运行几天后出现 OOM(Java heap space),但堆 dump 显示总对象仅占 18GB------剩余 14GB 去哪了?用 jhsdb jmap --heap 分析:RSet(Remembered Set)占用了约 4GB 内存。
根因:Region 太小 → Region 数量太多 → 每个 Region 的 RSet 需要记录多个引用来向的 card → RSet 总内存膨胀到 4GB。JDK 8-11 的 RSet 实现使用更占空间的数据结构;JDK 17/21 的 Howl card set 优化后可以节省 30-50% RSet 内存。
修复:
- 增大 Region:
-XX:G1HeapRegionSize=16m→ Region 数量从 16384 降到 2048 → RSet 内存降 75%。 - 升级 JDK 到 21:享受 Howl card set 优化(
g1_globals.hpp:234)。 - 如果升级 JDK 不可行,降低
G1RemSetArrayOfCardsEntries(g1_globals.hpp:227,默认自适应)来手动限制 RSet 大小。
4.5 思考题
-
进阶题 :G1 在并发标记阶段,应用线程修改了一个已经标记过的引用------将一个已标记为"存活"的对象 B 的引用改为指向一个新分配的对象 C。SATB 算法如何保证 C 不会被漏标?如果 C 在 Remark 阶段之后才被分配,又是如何处理的?(提示:研究 TAMS 和 Region 的
top指针,源码g1ConcurrentMark.hpp:55,G1ConcurrentMarkBitMap) -
实战题 :你的服务使用 G1、4GB 堆,观察到 GC 日志中 Mixed GC 的
Humongous regions数量持续增长(从 5 增长到 80),但Old regions保持稳定。请判断这是什么问题?如何在不重启服务的情况下缓解?长期解决方案是什么?
答案提示 :思考题 1 答案见本章 SATB 部分和
g1ConcurrentMark.hpp注释;思考题 2 的短期缓解方案是调高G1HeapRegionSize并重启,长期是消除大对象分配或使用堆外内存。
下一章预告:第21章将对比三大低延迟 GC------ZGC 的染色指针与亚毫秒暂停、Shenandoah 的 Brooks 转发指针与并发压缩,以及 G1 在低延迟场景下的极限在哪里。为延迟敏感型业务(交易、实时风控、游戏服务器)提供 GC 架构选型决策树。