第20章:JVM G1 GC原理、日志与调优实战

1. 项目背景

业务场景:某支付平台的"交易记录查询"微服务,部署在 K8s 集群中,每个 Pod 分配 4GB 堆内存,JVM 默认使用 Parallel GC。白天高峰期 QPS 约 5000,每 30 分钟左右出现一次 3~5 秒的 Full GC 停顿。该停顿导致 P99 响应延迟从正常的 50ms 飙升至 4000ms+,触发 K8s 健康检查超时(liveness probe 设 3 秒),产生级联 Pod 重启------每次重启又引发流量迁移到其他节点,形成"雪崩"效应。

痛点放大:

  1. Parallel GC 的"甜蜜期"已过 :在 4GB 堆下,Parallel GC 的 Full GC 停顿随老年代大小线性增长。当老年代积累到 2~3GB 时,一次 Full GC 的 STW(Stop-The-World)时间轻松超过 3 秒。团队曾尝试调大 -XX:NewRatio 让年轻代变大来减少 Minor GC 频率,反而导致 Survivor 区膨胀,晋升压力更大。

  2. G1 切换的"盲目性" :运维团队知道 G1 是 JDK 9+ 的默认 GC,但切换时只把 -XX:+UseParallelGC 改成 -XX:+UseG1GC,其他参数全部沿用。结果更糟------G1 的并发标记阶段消耗了 15% CPU,业务吞吐反而下降了 10%,P99 延迟虽然降到 800ms,但 P50 延迟从 20ms 变成了 60ms。

  3. 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 并发标记的核心算法。它的思想是:在并发标记开始时拍一个"逻辑快照",标记过程中新产生的垃圾暂时不管,但保证"在快照时刻是活的"对象一定不被漏标。

具体流程:

  1. Concurrent Start 阶段,把每个 Region 的 top(已分配边界)保存为 TAMS(Top At Mark Start)------TAMS 之上的对象都是快照后新分配的,视为活对象。
  2. 并发标记期间,如果应用线程要修改一个引用,先把修改前的旧引用记录到 SATB 缓冲区(g1SATBMarkQueueSet.hpp:26)。
  3. 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 ✅ 接近目标

从日志中发现的问题:

  1. 并发标记启动太晚(IHOP默认45%,但4GB堆上,45%意味着老年代1.8GB才开始标记→标记完成时可能已到3.2GB)
  2. Mixed GC 回收效率低,老年代垃圾没有被充分回收
  3. 大对象频繁分配导致碎片化
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 优化的三种策略:

  1. 增大 Region 大小 :最直接的方案。-XX:G1HeapRegionSize=16m 将 Humongous 阈值提升到 8MB → 原来 3MB 的对象不再是 Humongous,正常在 Eden 分配,正常回收。

  2. 对象池化 :对于频繁分配的大对象(如网络缓冲区、大 JSON 解析缓冲),用 ThreadLocal<byte[]> 或对象池复用:

java 复制代码
// 对象池避免频繁分配大数组
private static final ThreadLocal<byte[]> BUFFER_POOL =
    ThreadLocal.withInitial(() -> new byte[4 * 1024 * 1024]); // 4MB缓冲区,一次分配重复使用
  1. 拆分大对象 :将一个大 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 个场景:

  1. 中等堆大小(4GB-32GB)的微服务:这是 G1 的甜蜜区------堆足够大需要并发标记,又不会大到 RSet 开销不可承受。绝大部分 Spring Boot 微服务在这个范围内。

  2. 对 P99 延迟有 SLA 要求的在线服务 :如支付、交易、用户登录。MaxGCPauseMillis=100-200ms 能有效控制 P99 延迟在可接受范围。

  3. 大对象(>1MB)较少的应用:如果应用极少分配超过 Region 一半大小的对象,G1 的 Humongous 碎片化风险可控。

  4. 从 JDK 8 Parallel GC 升级到 JDK 17/21 的保守迁移:G1 作为默认 GC,社区支持最丰富、bug 修复最及时,迁移风险最低。

  5. 容器化部署(cgroup 限制下的)微服务 :G1 对堆大小的自适应性比 Parallel GC 更好,配合 -XX:+UseContainerSupport(JDK 10+ 默认)使用。

不适合 G1 的 2 个场景:

  1. 超大堆(64GB+)或延迟要求 < 10ms 的服务:应选择 ZGC。G1 在超大堆下 RSet 开销线性增长,且 Mixed GC 很难在 10ms 内完成。ZGC 的染色指针 + 并发重定位几乎消除了 STW 随堆增长的关联。

  2. 批处理 / 离线计算 / 对 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 螺旋。

修复(三管齐下):

  1. 增大 Region Size:-XX:G1HeapRegionSize=16m → Humongous 阈值提升到 8MB → 5MB 对象不再是 Humongous,正常走 Eden→Survivor→Old 晋升路径。
  2. 对象池化:用 ArrayBlockingQueue<byte[]> 做缓冲区池,避免频繁 new/GC。
  3. 堆外替代:将 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 内存。

修复:

  1. 增大 Region:-XX:G1HeapRegionSize=16m → Region 数量从 16384 降到 2048 → RSet 内存降 75%。
  2. 升级 JDK 到 21:享受 Howl card set 优化(g1_globals.hpp:234)。
  3. 如果升级 JDK 不可行,降低 G1RemSetArrayOfCardsEntries(g1_globals.hpp:227,默认自适应)来手动限制 RSet 大小。

4.5 思考题

  1. 进阶题 :G1 在并发标记阶段,应用线程修改了一个已经标记过的引用------将一个已标记为"存活"的对象 B 的引用改为指向一个新分配的对象 C。SATB 算法如何保证 C 不会被漏标?如果 C 在 Remark 阶段之后才被分配,又是如何处理的?(提示:研究 TAMS 和 Region 的 top 指针,源码 g1ConcurrentMark.hpp:55, G1ConcurrentMarkBitMap)

  2. 实战题 :你的服务使用 G1、4GB 堆,观察到 GC 日志中 Mixed GC 的 Humongous regions 数量持续增长(从 5 增长到 80),但 Old regions 保持稳定。请判断这是什么问题?如何在不重启服务的情况下缓解?长期解决方案是什么?

答案提示 :思考题 1 答案见本章 SATB 部分和 g1ConcurrentMark.hpp 注释;思考题 2 的短期缓解方案是调高 G1HeapRegionSize 并重启,长期是消除大对象分配或使用堆外内存。


下一章预告:第21章将对比三大低延迟 GC------ZGC 的染色指针与亚毫秒暂停、Shenandoah 的 Brooks 转发指针与并发压缩,以及 G1 在低延迟场景下的极限在哪里。为延迟敏感型业务(交易、实时风控、游戏服务器)提供 GC 架构选型决策树。

延伸阅读与资源

Java 工程师进阶:从 JVM 生产排障到OpenJDK原理

相关推荐
打工仔折腾 AI2 小时前
把模型切换交给平台:用蓝耘智能路由搭建商品评论分析工具
java·服务器·前端·后端·python·性能优化·ai agent 实战
后端LV2 小时前
Caffeine 源码详解:为什么它是最快的 Java 本地缓存——一次压测引发的源码考古
java·后端
dadaobusi2 小时前
Trace-driven 建模(基于轨迹/踪迹的建模)
java·后端·spring
知守观2 小时前
OBS 同名文件互相覆盖,客户拿错了别人的报告:一次文件串号的排查与重构
java·后端
泡海椒3 小时前
JQuick-Excel 字段映射实战:用 MAPPING 固化 Excel 表头与业务字段契约
xml·java·开发语言·excel
梦幻通灵3 小时前
IDEA 实用快捷键【持续更新】
java·ide·intellij-idea
南归北隐3 小时前
Spring AI Alibaba Graph框架实现Tools工具调用
java·后端·spring·spring ai·spring ai tools
开开心心就好3 小时前
视频模糊怎么修复?免费工具支持批量处理
java·前端·人工智能·智能手机·pdf·excel
w_zero_one3 小时前
链表(3)
java·数据结构·算法