概述
G1和ZGC是现代Java应用的首选垃圾收集器,代表了GC技术的演进方向。G1通过Region分区和可预测停顿,在JDK 9后成为默认收集器,适合4GB以上堆内存、追求平衡性能的应用;ZGC则通过染色指针和并发重定位技术,实现接近零停顿(< 10ms),专为超大堆(TB级)和极低延迟场景设计。本文从理论到实战,深度剖析两者的核心原理(Region设计、RSet、SATB、染色指针、多重映射)、GC流程、参数调优,并通过生产案例对比分析,帮助读者在实际项目中选择合适的收集器,掌握性能调优技巧。
一、理论背景与演进
1.1 传统GC的痛点
在G1和ZGC出现之前,Java应用主要使用Serial、Parallel和CMS收集器。这些传统GC存在显著痛点:
Serial GC (串行收集器):
- 单线程收集,Stop-The-World时间长
- 适用场景: Client模式,单核CPU环境
- 痛点: 生产环境不可接受,停顿时间达秒级
Parallel GC (并行收集器):
- 多线程并行,吞吐量优先
- 适用场景: 后台计算,批处理任务
- 痛点: Full GC时STW时间长,堆越大停顿越久(8GB堆可达2-5秒)
CMS GC (并发标记清除):
- 低延迟,大部分阶段并发执行
- 适用场景: 互联网应用,要求低停顿
- 痛点:
- CPU敏感: 并发阶段占用CPU,降低吞吐量
- 浮动垃圾: 并发清除时产生的新垃圾无法回收
- 内存碎片: 标记-清除算法,不整理碎片,可能提前触发Full GC
- Concurrent Mode Failure: 并发标记未完成老年代已满,退化为Serial Old,停顿时间更长
核心矛盾:
传统GC面临"不可能三角":
- 吞吐量 (Throughput): 运行用户代码时间占比
- 延迟 (Latency): GC停顿时间
- 堆大小 (Heap Size): 支持的最大堆内存
传统GC只能在三者中选择两个,无法兼顾。例如:
- Parallel GC: 高吞吐量 + 支持大堆,但延迟高
- CMS: 低延迟 + 支持中等堆,但吞吐量下降且有碎片问题
1.2 G1的诞生背景
G1 (Garbage-First) 于JDK 7引入,JDK 9成为默认收集器,目标是打破传统GC的限制:
设计目标:
- 可预测的停顿时间: 通过
-XX:MaxGCPauseMillis设置停顿目标(软目标) - 适应大堆: 支持几十GB堆,Full GC时间可控
- 平衡吞吐量和延迟: 不牺牲太多吞吐量的前提下,降低停顿时间
核心创新:
- Region分区: 将堆划分为多个大小相等的Region,不再固定分代边界
- Garbage-First策略: 优先回收垃圾最多的Region,在停顿时间允许范围内最大化回收效果
- 增量回收: 每次只回收部分Region,避免全堆扫描
1.3 ZGC的诞生背景
ZGC (Z Garbage Collector) 于JDK 11引入(实验性),JDK 15转正,目标是实现超低延迟:
设计目标:
- 极低停顿: 停顿时间 < 10ms,与堆大小无关
- 支持超大堆: 4TB以上堆,停顿时间依然在10ms以内
- 高吞吐量: 停顿时间降低的同时,吞吐量损失控制在15%以内
核心创新:
- 染色指针 (Colored Pointers): 将GC信息存储在指针中,而不是对象头
- 读屏障 (Load Barrier): 对象访问时通过读屏障自动处理重定位
- 并发重定位: 对象移动与应用线程并发执行,无需STW
- 多重映射: 同一物理内存映射到多个虚拟地址,实现并发安全
适用场景:
- 低延迟要求极高的应用 (金融交易、实时系统)
- 超大堆内存应用 (几十GB到TB级)
- 追求极致用户体验的互联网应用
二、G1垃圾收集器原理
2.1 G1的整体架构
G1的核心设计思想是"化整为零",将堆划分为多个Region,打破传统分代GC的固定边界。
2.1.1 Region分区设计
Region是G1的基本单位:
- 将堆划分为多个大小相等的Region(默认2048个)
- Region大小 = 堆大小 / 2048 (向上取2的幂)
- 范围: 1MB ~ 32MB,必须是2的幂
Region大小计算示例:
堆8GB: Region大小 = 8GB / 2048 ≈ 4MB
堆16GB: Region大小 = 16GB / 2048 = 8MB
堆32GB: Region大小 = 32GB / 2048 = 16MB
手动设置Region大小:
-XX:G1HeapRegionSize=8m # 设置Region大小为8MB
Region类型:
G1中的Region可以动态分配为不同类型:
-
Eden Region (Eden区):
- 新对象分配的区域
- Young GC时全部清空
-
Survivor Region (Survivor区):
- Young GC后存活对象的区域
- 采用复制算法,From/To角色互换
-
Old Region (老年代区):
- 存放晋升对象
- Mixed GC时部分回收
-
Humongous Region (大对象区):
- 存放大对象(≥ Region大小的50%)
- 大对象直接分配到老年代
- 可能跨越多个连续Region
-
Free Region (空闲区):
- 未分配的Region
- 可随时分配为任何类型
动态分配的优势:
传统分代GC中,新生代和老年代有固定边界:
传统GC: [新生代 | 老年代] ← 固定边界,无法动态调整
G1中,Region可以动态分配:
G1: [E][E][S][O][O][O][F][F] ← E/S/O可以动态调整
- 根据
MaxGCPauseMillis目标,G1自动调整新生代大小 - 新生代Region数量在5%-60%之间动态变化
- 避免固定边界导致的空间浪费
2.1.2 Remembered Set (RSet)
为什么需要RSet?
Young GC时,需要知道哪些老年代对象引用了年轻代对象。传统做法是全堆扫描,效率极低。G1通过RSet记录外部Region对本Region的引用。
RSet结构:
每个Region维护一个RSet,记录哪些Region引用了本Region中的对象:
Region A的RSet: {Region B, Region C}
// 表示Region B和Region C中的对象引用了Region A中的对象
示例:
假设有3个Region:
- Region A (Old): 包含对象A1、A2
- Region B (Eden): 包含对象B1、B2
- Region C (Old): 包含对象C1、C2
引用关系:
- A1 → B1 (老年代引用年轻代)
- A2 → B2
则Region B的RSet记录: {Region A}
Young GC时使用RSet:
1. 要回收Region B (Eden区)
2. 查看Region B的RSet: {Region A}
3. 只需扫描Region A中的对象,找出对Region B的引用
4. 无需扫描整个老年代,大幅提升效率
写屏障维护RSet:
每次引用更新时,JVM插入写屏障代码,维护RSet:
// 伪代码: G1的写屏障
void oop_field_store(oop* field, oop new_value) {
// 1. 前置写屏障 (SATB,记录旧值,下文详述)
pre_write_barrier(field);
// 2. 实际的引用更新
*field = new_value;
// 3. 后置写屏障 (维护RSet)
if (is_in_young(new_value) && is_in_old(field)) {
// 老年代对象引用年轻代对象
Region* young_region = get_region(new_value);
Region* old_region = get_region(field);
// 将old_region添加到young_region的RSet
young_region->rset->add(old_region);
}
}
卡表(Card Table)实现细节:
RSet的底层实现使用卡表(Card Table):
- 将堆划分为多个Card(默认512字节)
- 每个Card对应一个字节的标记位
- 引用变化时,标记对应Card为"脏"
- RSet记录的是Card的集合,而不是具体对象
- Young GC时,只需扫描RSet中标记为"脏"的Card
示例:
Region内的卡表: [0][1][0][1][0]
↑ ↑ ↑ ↑ ↑
干净 脏 干净 脏 干净
RSet的优势与代价:
优势:
- Young GC无需全堆扫描,只扫描RSet记录的Region
- 大幅提升Young GC速度
- 支持部分回收(只回收部分Region)
代价:
- RSet占用额外内存(约堆大小的5-10%)
- 写屏障带来额外CPU开销
- 每次引用更新都需要维护RSet
2.2 G1的垃圾回收流程
G1的GC流程包括三种类型:Young GC、Mixed GC和Full GC。
2.2.1 Young GC (纯年轻代回收)
触发条件: Eden Region满
回收过程 (STW停顿):
- 暂停应用线程 (STW开始)
- 扫描GC Roots: 线程栈、全局变量、JNI句柄等
- 扫描RSet: 找出老年代Region对年轻代的引用
- 复制存活对象:
- Eden和Survivor中的存活对象复制到新的Survivor Region
- 年龄达到阈值(默认15)的对象晋升到Old Region
- 清空Eden: 所有Eden Region标记为空闲
- 恢复应用线程 (STW结束)
停顿时间: 20-100ms (取决于堆大小和存活对象数量)
示例:
GC前:
[E][E][E][E][S][S][O][O][F][F]
↑ ↑ ↑ ↑ ↑ ↑
Eden满,触发Young GC
GC后:
[F][F][F][F][S][S][O][O][F][F]
↑ ↑ ↑
新的Survivor, 部分晋升到Old
2.2.2 Mixed GC (混合回收)
触发条件:
- 并发标记周期完成
- 老年代使用率 ≥
InitiatingHeapOccupancyPercent(默认45%)
回收范围:
- 年轻代(全部Eden + Survivor)
- 部分老年代Region (垃圾最多的)
核心策略: Garbage-First
G1根据MaxGCPauseMillis目标,选择垃圾最多的老年代Region回收:
假设有10个Old Region,垃圾占比分别为:
Region1: 90%
Region2: 85%
Region3: 70%
Region4: 50%
...
G1优先选择Region1、Region2、Region3回收,
在停顿时间允许范围内,尽可能多回收Region。
停顿控制:
G1通过MaxGCPauseMillis控制停顿时间:
-XX:MaxGCPauseMillis=200 # 期望最大停顿200ms
注意: 这是软目标,G1会尽力达成但不保证。
停顿时间: 50-200ms (可通过参数控制)
多次Mixed GC:
一个并发标记周期后,可能触发多次Mixed GC,逐步回收老年代垃圾。
2.2.3 并发标记周期 (Concurrent Marking Cycle)
并发标记周期是Mixed GC的前置阶段,用于标记老年代中的存活对象,确定哪些Region需要回收。
阶段1: 初始标记 (Initial Mark, STW)
- STW停顿: 几毫秒
- 工作: 标记GC Roots直接可达对象
- 优化: 借用Young GC的STW,无额外停顿
阶段2: 并发标记 (Concurrent Mark, 并发)
- 并发执行: 应用线程继续运行
- 工作: 遍历整个对象图,标记所有存活对象
- 耗时: 几百毫秒到几秒
阶段3: 最终标记 (Final Mark, STW)
- STW停顿: 几十毫秒
- 工作: 处理SATB队列,修正并发标记期间的引用变化
阶段4: 清理 (Cleanup, 部分STW)
- STW部分: 统计Region存活率 (几毫秒)
- 并发部分: 清理完全空的Region (几十毫秒)
完整流程:
初始标记 → 并发标记 → 最终标记 → 清理 → 触发Mixed GC
(STW) (并发) (STW) (部分STW)
2.3 SATB算法详解
2.3.1 三色标记算法
G1使用三色标记算法进行并发标记:
- 白色对象: 未标记,可能是垃圾
- 灰色对象: 已标记,但子对象未扫描
- 黑色对象: 已标记,且子对象已扫描
标记流程:
- 初始: 所有对象为白色
- GC Roots标记为灰色
- 从灰色对象队列取出对象,扫描其引用:
- 引用对象标记为灰色
- 当前对象标记为黑色
- 重复步骤3,直到灰色对象队列为空
- 剩余白色对象 = 垃圾,回收
不变式: 黑色对象不能直接引用白色对象
若违反,可能导致对象漏标:
- 黑色对象已扫描完成
- 新增白色引用不会被发现
- 白色对象被误认为垃圾
- 存活对象被错误回收!
2.3.2 并发标记的问题
并发标记期间,应用线程可能修改对象引用,导致对象漏标。
对象漏标的两个充要条件:
- 黑色对象新增对白色对象的引用
- 灰色对象删除对白色对象的引用
示例:
初始状态:
GC Root (黑) → 对象A (灰) → 对象B (白)
并发期间,应用线程执行:
root.fieldB = objectB; // 黑→白引用
objectA.fieldB = null; // 删除灰→白引用
结果:
GC Root (黑) → 对象B (白)
问题:
- GC Root(黑)已扫描完,不会再扫描
- 对象A(灰)删除引用,不会访问B
- 对象B(白)没有其他引用路径
- 对象B被误认为垃圾,错误回收!
2.3.3 SATB (Snapshot-At-The-Beginning) 解决方案
核心思想: 标记开始时的对象快照(逻辑快照,非物理快照)
写屏障实现:
// G1的SATB写屏障 (前置屏障)
void oop_field_store(oop* field, oop new_value) {
oop old_value = *field; // 1. 读取旧值
if (old_value != null && is_marking_active()) {
satb_enqueue(old_value); // 2. 保存旧值到SATB队列
}
*field = new_value; // 3. 执行实际的引用更新
}
为什么记录旧值而不是新值?
- SATB目标: 保证标记开始时的快照中的所有存活对象都被标记
- 旧值可能是唯一引用路径,删除后对象会孤立
- 新值会在并发标记中正常扫描到
SATB队列处理:
- 每个线程维护一个本地SATB队列
- 队列满时,转移到全局队列
- 最终标记阶段,GC线程处理所有SATB队列
- 从队列中的对象重新扫描,标记为灰色
SATB的保守性:
- 可能标记一些已经死亡的对象(浮动垃圾)
- 牺牲部分精度,换取不漏标的保证
- 浮动垃圾在下一次GC会被回收
2.4 G1的参数调优
2.4.1 核心参数
# 启用G1
-XX:+UseG1GC
# 期望最大停顿时间 (软目标)
-XX:MaxGCPauseMillis=200 # 默认200ms
# 堆大小 (建议Xms = Xmx)
-Xms4g -Xmx4g
# Region大小 (1MB-32MB,必须是2的幂)
-XX:G1HeapRegionSize=8m
# 老年代占用率达到此阈值时触发并发标记
-XX:InitiatingHeapOccupancyPercent=45 # 默认45%
# 保留空间,防止晋升失败
-XX:G1ReservePercent=10 # 默认10%
# 新生代大小范围
-XX:G1NewSizePercent=5 # 新生代最小占比5%
-XX:G1MaxNewSizePercent=60 # 新生代最大占比60%
# 并发标记线程数 (默认ParallelGCThreads/4)
-XX:ConcGCThreads=4
# 并行GC线程数 (默认等于CPU核心数)
-XX:ParallelGCThreads=8
2.4.2 完整配置示例
Web应用 (4核8GB,堆4GB):
java -Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=8m \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:G1ReservePercent=10 \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heapdump.hprof \
-Xloggc:/var/log/gc.log \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-jar application.jar
大内存应用 (16核32GB,堆24GB):
java -Xms24g -Xmx24g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:G1HeapRegionSize=16m \
-XX:InitiatingHeapOccupancyPercent=40 \
-XX:G1ReservePercent=15 \
-XX:ConcGCThreads=4 \
-XX:ParallelGCThreads=16 \
-XX:MetaspaceSize=512m \
-XX:MaxMetaspaceSize=1g \
-Xloggc:/var/log/gc.log \
-jar application.jar
三、ZGC垃圾收集器原理
3.1 ZGC的整体架构
ZGC是JDK 11引入的超低延迟垃圾收集器,目标是实现停顿时间 < 10ms,与堆大小无关。
3.1.1 核心技术
ZGC的三大核心技术:
- 染色指针 (Colored Pointers): 将GC信息存储在指针中
- 读屏障 (Load Barrier): 对象访问时自动处理重定位
- 并发重定位: 对象移动与应用线程并发执行
3.2 染色指针 (Colored Pointers)
3.2.1 64位指针布局
ZGC利用64位指针的高位存储GC信息:
63 47 46 45 44 43 42 0
┌────────────────┬──┬──┬──┬──┬────┬──────────────────────────────────────┐
│ Reserved │Fi│Re│M1│M0│Res │ Object Address (42位) │
│ (16位) │na│ma│ar│ar│erv │ │
│ │li│pp│ke│ke│ed │ │
│ │za│ed│d1│d0│(2) │ │
└────────────────┴──┴──┴──┴──┴────┴──────────────────────────────────────┘
元数据位 [47:42]:
- Marked0 (位44): 奇数轮并发标记,标记为1表示对象存活
- Marked1 (位45): 偶数轮并发标记,标记为1表示对象存活
- Remapped (位46): 标记对象是否已重定位,1表示已移动到新地址
- Finalizable (位47): 标记对象是否可终结,1表示有finalize方法
对象地址 [41:0]:
- 42位地址可寻址 4TB (2^42 = 4TB)
- Linux x86-64仅使用低48位
3.2.2 为什么需要两个Marked位?
ZGC每轮GC交替使用Marked0和Marked1:
- 第1轮GC: 使用Marked0 (Marked0=1表示存活)
- 第2轮GC: 使用Marked1 (Marked1=1表示存活)
好处: 无需重置所有对象的标记位,直接切换使用哪个位即可。
例如:
- 第1轮GC后,Marked0=1的对象是存活的
- 第2轮GC开始时,直接使用Marked1,无需清除Marked0
3.2.3 染色指针的优势
1. 一次性标记
无需遍历对象图,直接通过指针颜色判断对象状态:
// 检查对象是否已标记
boolean is_marked(Object* ptr) {
return (ptr & MARKED_BIT) != 0;
}
2. 并发重定位
对象移动后,旧指针通过读屏障自动重定向到新地址,无需STW更新所有引用。
3. 多代视图
同一物理地址,通过不同颜色的指针,可以同时表示旧对象和新对象。
4. 低延迟
配合读屏障和多重映射,实现接近零停顿的GC (STW通常 < 10ms)。
3.3 读屏障 (Load Barrier)
ZGC使用读屏障(而不是写屏障)来实现并发重定位。
读屏障伪代码:
Object load(Object* addr) {
Object ptr = *addr; // 1. 读取指针
// 2. 检查指针的颜色位
if (is_good_color(ptr)) {
return ptr; // 颜色正确,直接返回
} else {
return slow_path(ptr); // 颜色不对,进入慢速路径 (重定位/标记)
}
}
快速路径: 颜色正确,直接返回指针 (几乎无开销)
慢速路径: 颜色不对,进行重定位或标记,然后更新指针颜色
优势:
- 对象移动后,旧指针通过读屏障自动重定向到新地址
- 应用线程访问时自动完成重定位,无需STW
缺点:
- 读操作有额外开销 (但现代CPU分支预测优化后影响很小)
3.4 ZGC的垃圾回收流程
ZGC的GC流程包括7个阶段,只有3个极短的STW阶段。
3.4.1 阶段1: Pause Mark Start (初始标记 - STW)
- STW停顿: ~1ms
- 工作: 标记GC Roots直接可达对象
- 颜色指针: 设置当前使用的Marked位 (Marked0或Marked1)
3.4.2 阶段2: Concurrent Mark (并发标记)
- 并发执行: 应用线程继续运行
- 工作: 遍历整个对象图,标记所有可达对象
- 颜色指针:
- 已标记对象: Marked位 = 1
- 未标记对象: Marked位 = 0 (垃圾候选)
3.4.3 阶段3: Pause Mark End (最终标记 - STW)
- STW停顿: ~1ms
- 工作: 处理并发标记期间的弱引用、软引用等
- 选择回收对象: Marked位 = 0 的对象为垃圾
3.4.4 阶段4: Concurrent Process Non-Strong References (处理弱引用)
- 并发执行: 处理软引用、弱引用、虚引用、Finalizer
- 工作:
- 处理软引用: 根据内存情况决定是否回收
- 处理弱引用: 标记阶段未标记的对象,弱引用指向的对象会被回收
- 处理虚引用
- 处理Finalizer: 调用finalize()方法
3.4.5 阶段5: Concurrent Reset Relocation Set (重置重定位集)
- 并发执行: 准备重定位,重置上一次GC的重定位映射表
- 工作: 清理上一次GC的转发表 (Forwarding Table)
3.4.6 阶段6: Pause Relocate Start (开始重定位 - STW)
- STW停顿: ~1ms
- 工作: 重定位GC Roots直接引用的对象,更新GC Roots中的引用指针
- 颜色指针:
- 已重定位对象: Remapped位 = 1
- 更新指针指向新地址
3.4.7 阶段7: Concurrent Relocate (并发重定位)
- 并发执行: 移动对象,更新引用指针
- 工作:
- 遍历Relocation Set中的Page,将存活对象移动到新Page
- 建立转发表 (Forwarding Table): 记录旧地址 → 新地址映射
- 通过读屏障,应用线程访问旧地址时自动转发到新地址
读屏障的作用:
并发重定位期间,应用线程如何访问已移动的对象?
场景: GC线程已将对象A从地址0x1000移动到0x2000,但应用线程持有旧指针0x1000
- 应用线程访问对象A:
obj = *ptr;(触发读屏障) - 读屏障检查颜色位:
- Remapped = 0: 对象还在旧地址,直接返回
- Remapped = 1: 对象已移动,查询转发表获取新地址
- 转发到新地址:
- 从转发表查询: 0x1000 → 0x2000
- 返回新地址的对象
- 更新指针为新地址 (可选优化)
3.5 多重映射内存管理
3.5.1 核心思想
ZGC将同一块物理内存映射到3个不同的虚拟地址空间:
- Marked0 View: Marked0=1 的指针访问此视图
- Marked1 View: Marked1=1 的指针访问此视图
- Remapped View: Remapped=1 的指针访问此视图
示例:
虚拟地址空间:
┌─────────────────┐
│ Marked0 View │ 0x0000_0000_0000 - 0x0000_03FF_FFFF
│ (虚拟地址) │
└────────┬────────┘
│ 映射
↓
┌─────────────────┐
│ 物理内存 │ 0x1000_0000 - 0x13FF_FFFF (64MB)
│ (对象A, B, C...)│
└────────┬────────┘
↑ 映射
│
┌─────────────────┐
│ Remapped View │ 0x0000_0800_0000 - 0x0000_0BFF_FFFF
│ (虚拟地址) │
└─────────────────┘
3.5.2 工作原理
场景: 将对象A从物理地址0x1000_1000 重定位到 0x1000_5000
步骤1: 并发标记阶段 (使用Marked0视图)
- 对象A的指针: 0x0001_0000_1000 (Marked0=1)
- 通过Marked0视图访问物理地址 0x1000_1000
- 虚拟地址: 0x0000_0000_1000 → 物理地址: 0x1000_1000
步骤2: 并发重定位阶段 (切换到Remapped视图)
- GC线程将对象A复制到新物理地址 0x1000_5000
- 创建转发表: 0x1000_1000 → 0x1000_5000
- 新指针: 0x0004_0000_5000 (Remapped=1)
- 通过Remapped视图访问新物理地址 0x1000_5000
- 虚拟地址: 0x0000_0800_5000 → 物理地址: 0x1000_5000
步骤3: 读屏障处理旧指针
- 应用线程持有旧指针: 0x0001_0000_1000
- 读屏障检查Marked0=1, Remapped=0
- 查询转发表: 0x1000_1000 → 0x1000_5000
- 返回新地址对象
- 可选: 更新指针为 0x0004_0000_5000
3.5.3 多重映射的优势
✅ 无需物理复制
- 对象重定位只需切换指针颜色位
- 无需复制对象数据
- 极大减少GC开销
✅ 并发安全
- 旧视图和新视图同时有效
- 应用线程通过读屏障自动转发
- 无需STW更新所有引用
✅ 多代切换
- Marked0/Marked1 交替使用
- 无需清除上一轮标记位
- 节省标记重置时间
✅ 支持大堆
- 42位地址支持4TB堆
- 停顿时间与堆大小无关
❌ 缺点: 虚拟地址空间开销
- 需要3倍虚拟地址空间
- Linux x86-64: 128TB虚拟地址空间,足够使用
❌ 缺点: 读屏障开销
- 每次读取对象引用都需要检查
- 现代CPU分支预测优化后影响较小
3.6 ZGC的参数调优
3.6.1 核心参数
# 启用ZGC
-XX:+UseZGC
# 堆大小 (建议Xms = Xmx)
-Xms16g -Xmx16g
# 并发GC线程数 (默认ParallelGCThreads/4)
-XX:ConcGCThreads=4
# 并行GC线程数 (默认等于CPU核心数)
-XX:ParallelGCThreads=8
# 软最大堆大小 (ZGC会尽力不超过此值)
-XX:SoftMaxHeapSize=14g
# 元空间
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
3.6.2 完整配置示例
大内存应用 (16核32GB,堆24GB):
java -Xms24g -Xmx24g \
-XX:+UseZGC \
-XX:ConcGCThreads=4 \
-XX:ParallelGCThreads=16 \
-XX:SoftMaxHeapSize=20g \
-XX:MetaspaceSize=512m \
-XX:MaxMetaspaceSize=1g \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heapdump.hprof \
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags \
-jar application.jar
超大堆应用 (32核128GB,堆96GB):
java -Xms96g -Xmx96g \
-XX:+UseZGC \
-XX:ConcGCThreads=8 \
-XX:ParallelGCThreads=32 \
-XX:SoftMaxHeapSize=80g \
-XX:MetaspaceSize=1g \
-XX:MaxMetaspaceSize=2g \
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags \
-jar application.jar
四、G1 vs ZGC对比分析
4.1 核心技术对比
| 维度 | G1 | ZGC |
|---|---|---|
| 分区设计 | Region分区 (1MB-32MB) | Page分区 (2MB, 32KB, 256KB三种) |
| 标记算法 | 三色标记 + SATB | 三色标记 + 染色指针 |
| 屏障类型 | 写屏障 (维护RSet) + 前置写屏障 (SATB) | 读屏障 (Load Barrier) |
| 重定位 | 复制算法 (STW) | 并发重定位 (读屏障 + 转发表) |
| 内存管理 | 单一视图 | 多重映射 (3个视图) |
| GC信息存储 | 对象头 (Mark Word) | 指针元数据位 |
4.2 性能对比
| 指标 | G1 | ZGC |
|---|---|---|
| 停顿时间 | 50-200ms | < 10ms (通常 < 1ms) |
| 吞吐量 | 95-98% | 85-95% |
| 堆大小 | 4GB - 64GB (最佳) | 8GB - 16TB |
| 适用场景 | 通用场景,平衡性能 | 超大堆,极低延迟 |
| CPU开销 | 中等 | 较高 (读屏障) |
| 内存开销 | RSet占5-10% | 虚拟地址空间3倍 (物理内存不增加) |
4.3 GC流程对比
| GC阶段 | G1 | ZGC |
|---|---|---|
| Young GC | STW,20-100ms | 无独立Young GC,统一流程 |
| 并发标记 | 4阶段,2个STW (初始/最终标记) | 7阶段,3个STW (每个 < 1ms) |
| Mixed GC | STW,50-200ms,回收部分老年代 | 无Mixed GC概念 |
| 重定位 | STW,复制对象 | 并发,无STW |
| Full GC | 可能发生,停顿几秒 | 几乎不发生 |
4.4 参数复杂度对比
| 维度 | G1 | ZGC |
|---|---|---|
| 核心参数 | ~10个 | ~5个 |
| 调优难度 | 中等 | 低 |
| 自适应能力 | 较强 | 极强 |
| 默认配置 | 适用大多数场景 | 开箱即用 |
4.5 选择建议
选择G1的场景:
✅ 堆内存 4GB - 64GB ✅ 停顿时间要求 50-200ms ✅ 追求吞吐量和延迟的平衡 ✅ 通用互联网应用,电商,社交,内容平台 ✅ 微服务架构
选择ZGC的场景:
✅ 堆内存 ≥ 16GB,甚至TB级 ✅ 停顿时间要求 < 10ms ✅ 低延迟优先级高于吞吐量 ✅ 金融交易系统,实时竞价,游戏服务器 ✅ 大数据实时处理,流计算
五、生产环境应用场景
5.1 电商秒杀系统 (选择G1)
业务特点:
- 流量峰值: 秒杀活动瞬时QPS从1000飙升至10万
- 堆内存: 8GB
- 对象生命周期: 大部分对象短生命周期(HTTP请求对象、缓存对象)
- 延迟要求: TP99 < 200ms
为什么选择G1:
- 堆内存8GB,G1适用范围
- 动态调整新生代大小,应对流量峰值
- Young GC频繁但停顿时间短(50ms)
- Mixed GC可控制在100ms内
G1配置:
java -Xms8g -Xmx8g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:G1HeapRegionSize=8m \
-XX:InitiatingHeapOccupancyPercent=40 \
-XX:G1ReservePercent=15 \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \
-Xloggc:/var/log/gc.log \
-jar seckill-service.jar
效果:
| 指标 | 调优前 (Parallel GC) | 调优后 (G1) |
|---|---|---|
| TP99延迟 | 500ms | 150ms |
| GC停顿 | 200-500ms | 50-100ms |
| 吞吐量 | 95% | 96% |
| Full GC频率 | 每小时2次 | 基本无Full GC |
5.2 金融支付系统 (选择ZGC)
业务特点:
- 流量: 稳定QPS 5000,峰值1万
- 堆内存: 32GB
- 延迟要求: 极低延迟,TP99 < 50ms,任何超过100ms的停顿都可能导致交易失败
- 监管要求: 支付响应时间 < 100ms
为什么选择ZGC:
- 堆内存32GB,G1的Full GC可能达到1-2秒,不可接受
- ZGC停顿时间 < 10ms,与堆大小无关
- 金融系统对延迟敏感,愿意牺牲5-10%吞吐量换取极低停顿
ZGC配置:
java -Xms32g -Xmx32g \
-XX:+UseZGC \
-XX:ConcGCThreads=8 \
-XX:ParallelGCThreads=16 \
-XX:SoftMaxHeapSize=28g \
-XX:MetaspaceSize=512m \
-XX:MaxMetaspaceSize=1g \
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags \
-jar payment-service.jar
效果:
| 指标 | 调优前 (G1) | 调优后 (ZGC) |
|---|---|---|
| TP99延迟 | 120ms | 45ms |
| GC停顿 | 50-150ms | < 5ms |
| 吞吐量 | 97% | 92% |
| Full GC停顿 | 偶尔1-2秒 | 无Full GC |
5.3 大数据实时计算 (选择ZGC)
业务特点:
- 实时流处理: Flink任务,处理10亿条/天的日志数据
- 堆内存: 64GB
- 对象生命周期: 中长生命周期(状态数据、窗口数据)
- 延迟要求: 处理延迟 < 1秒,GC停顿不能影响实时性
为什么选择ZGC:
- 堆内存64GB,G1的Mixed GC可能达到200-500ms
- 实时计算对延迟敏感,长时间GC导致数据积压
- ZGC并发重定位,对计算线程影响最小
ZGC配置:
java -Xms64g -Xmx64g \
-XX:+UseZGC \
-XX:ConcGCThreads=16 \
-XX:ParallelGCThreads=32 \
-XX:SoftMaxHeapSize=56g \
-XX:MetaspaceSize=1g \
-XX:MaxMetaspaceSize=2g \
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags \
-jar flink-taskmanager.jar
效果:
| 指标 | 调优前 (G1) | 调优后 (ZGC) |
|---|---|---|
| 数据处理延迟 | 800ms | 400ms |
| GC停顿 | 100-300ms | < 8ms |
| 吞吐量 | 96% | 90% |
| 数据积压 | 偶尔发生 | 基本无积压 |
5.4 微服务网关 (选择G1)
业务特点:
- 高并发: QPS 2万,峰值5万
- 堆内存: 4GB
- 对象生命周期: 极短(HTTP请求/响应对象)
- 延迟要求: TP99 < 100ms
为什么选择G1:
- 堆内存4GB,G1够用
- 对象生命周期极短,大部分在Eden区回收
- Young GC频繁但停顿时间短
- G1的吞吐量优于ZGC
G1配置:
java -Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=50 \
-XX:G1HeapRegionSize=4m \
-XX:G1NewSizePercent=40 \
-XX:G1MaxNewSizePercent=60 \
-XX:MetaspaceSize=128m \
-XX:MaxMetaspaceSize=256m \
-Xloggc:/var/log/gc.log \
-jar gateway-service.jar
效果:
| 指标 | 调优前 (CMS) | 调优后 (G1) |
|---|---|---|
| TP99延迟 | 150ms | 80ms |
| GC停顿 | 20-100ms | 20-50ms |
| 吞吐量 | 93% | 97% |
| Full GC频率 | 每天2-3次 | 基本无Full GC |
六、生产案例与调优实战
6.1 案例1: G1频繁Full GC
故障现象:
某电商系统(堆8GB)使用G1,生产环境每小时触发5-10次Full GC,每次停顿2-3秒,导致接口超时。
排查过程:
1. 查看GC日志
grep "Full GC" gc.log | tail -20
发现Full GC频繁,回收效果差(只回收500MB)。
2. 分析触发原因
2024-01-15T14:23:45.123+0800: 7890.456: [Full GC (Allocation Failure) 7680M->7280M(8192M), 2.3456789 secs]
触发原因: Allocation Failure → 对象分配失败,晋升失败。
3. 查看堆使用情况
jstat -gc <pid> 1000 10
发现老年代使用率持续在90%以上。
4. dump内存分析
jmap -dump:live,format=b,file=heap.hprof <pid>
使用MAT分析,发现大量byte[]对象,占用4GB内存,引用链:
CacheManager.imageCache (static)
↓
ConcurrentHashMap
↓
SoftReference<byte[]> ← 缓存的图片数据
问题定位:
代码使用SoftReference缓存图片,但缓存没有上限,内存不足时集中回收导致频繁Full GC。
解决方案:
使用Guava Cache替代SoftReference:
// 修复前
Map<String, SoftReference<byte[]>> imageCache = new ConcurrentHashMap<>();
// 修复后
Cache<String, byte[]> imageCache = CacheBuilder.newBuilder()
.maximumSize(5000) // 限制5000个
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
调整G1参数:
# 降低并发标记触发阈值,提前触发Mixed GC
-XX:InitiatingHeapOccupancyPercent=35 # 从45%降至35%
# 增加保留空间,防止晋升失败
-XX:G1ReservePercent=15 # 从10%增至15%
效果:
| 指标 | 优化前 | 优化后 | 改善 |
|---|---|---|---|
| Full GC频率 | 每小时5-10次 | 基本无 | 99%↓ |
| 堆使用率 | 90% | 60% | 33%↓ |
| TP99延迟 | 2500ms | 120ms | 95%↓ |
6.2 案例2: ZGC内存泄漏
故障现象:
某支付系统(堆32GB)使用ZGC,运行3天后,堆使用率持续增长至95%,触发频繁GC,但回收效果差。
排查过程:
1. 查看GC日志
tail -1000 gc.log | grep "Heap: "
发现堆使用率从60%逐步增长到95%,GC无法有效回收。
2. 对比不同时间点的heap dump
# 第1天
jmap -dump:live,format=b,file=heap_day1.hprof <pid>
# 第3天
jmap -dump:live,format=b,file=heap_day3.hprof <pid>
3. MAT对比分析
使用MAT的Histogram对比功能,发现:
java.util.concurrent.ConcurrentHashMap$Node对象数量从10万增长到500万- 占用内存从500MB增长到15GB
4. 定位泄漏代码
通过Dominator Tree,找到引用链:
RequestContextHolder (static)
↓
ThreadLocal<RequestContext>
↓
ConcurrentHashMap<String, Object> (request attributes)
问题定位:
代码在每个请求中将大量数据存入ThreadLocal,但请求结束后未清理,导致线程池场景下ThreadLocal泄漏。
解决方案:
// 问题代码
public class RequestFilter implements Filter {
private static ThreadLocal<RequestContext> context = new ThreadLocal<>();
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
context.set(new RequestContext(request));
chain.doFilter(request, response);
// ❌ 忘记清理!
}
}
// 修复后
public class RequestFilter implements Filter {
private static ThreadLocal<RequestContext> context = new ThreadLocal<>();
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
try {
context.set(new RequestContext(request));
chain.doFilter(request, response);
} finally {
context.remove(); // ✅ 清理ThreadLocal
}
}
}
效果:
| 指标 | 优化前 | 优化后 | 改善 |
|---|---|---|---|
| 堆使用率 | 95% | 60% | 37%↓ |
| GC频率 | 每分钟10次 | 每10分钟1次 | 99%↓ |
| 内存泄漏 | 每天增长5GB | 无增长 | 100%修复 |
七、常见问题解答
7.1 G1和ZGC如何选择?
决策树:
堆内存大小?
├── < 4GB → G1 或 CMS
├── 4-16GB → G1
├── 16-64GB → G1 或 ZGC (根据延迟要求)
└── ≥ 64GB → ZGC
延迟要求?
├── TP99 < 10ms → ZGC
├── TP99 < 100ms → G1
└── 吞吐量优先 → G1
应用类型?
├── 金融交易、实时系统 → ZGC
├── 电商、社交、内容平台 → G1
├── 大数据实时计算 → ZGC
└── 微服务、网关 → G1
7.2 G1的MaxGCPauseMillis如何设置?
原则:
- 这是软目标,G1会尽力达成但不保证
- 设置过小(如10ms),G1无法达成,可能频繁触发Full GC
- 设置过大(如500ms),失去G1的低延迟优势
建议:
- 通用应用: 100-200ms
- 低延迟应用: 50-100ms
- 吞吐量优先: 200-500ms
验证方法:
查看GC日志,确认实际停顿时间:
grep "GC pause" gc.log | awk '{print $NF}' | sort -n | tail -100
如果实际停顿时间远超目标,考虑:
- 增大堆内存
- 降低
InitiatingHeapOccupancyPercent,提前触发并发标记 - 增加并发GC线程数
7.3 为什么G1仍然会触发Full GC?
Full GC的触发场景:
-
晋升失败 (Promotion Failure):
- Young GC时,老年代没有足够空间容纳晋升对象
- 解决: 增大堆内存,降低
InitiatingHeapOccupancyPercent
-
巨型对象分配失败 (Humongous Allocation Failure):
- 无法找到足够的连续Region存储大对象
- 解决: 增大
G1HeapRegionSize,避免大对象(优化代码,拆分对象)
-
并发模式失败 (Concurrent Mode Failure):
- 并发标记未完成,老年代已满
- 解决: 降低
InitiatingHeapOccupancyPercent,增加ConcGCThreads
-
元空间不足:
- Metaspace OOM
- 解决: 增大
MaxMetaspaceSize
7.4 ZGC有哪些限制?
ZGC的限制:
-
JDK版本:
- JDK 11: 实验性特性 (
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC) - JDK 15+: 正式特性 (
-XX:+UseZGC)
- JDK 11: 实验性特性 (
-
操作系统:
- Linux (x86-64): 完全支持
- macOS: JDK 14+支持
- Windows: JDK 14+支持
-
堆大小:
- 最小: 8GB (建议)
- 最大: 16TB (理论上)
- 实际生产: 16GB - 512GB
-
CPU开销:
- 读屏障带来额外CPU开销
- 并发GC线程占用CPU
- 吞吐量损失5-15%
-
不支持压缩类指针:
- ZGC不支持
-XX:+UseCompressedClassPointers - 元空间占用略高
- ZGC不支持
7.5 如何监控G1和ZGC的性能?
GC日志分析:
# JDK 8及之前 (G1)
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/gc.log
# JDK 9+ (G1/ZGC)
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags
在线分析工具:
- GCEasy: gceasy.io (免费,可视化GC日志)
- GCViewer: 开源GC日志分析工具
关键指标:
| 指标 | G1目标 | ZGC目标 |
|---|---|---|
| 吞吐量 | ≥ 95% | ≥ 90% |
| Young GC停顿 | < 100ms | N/A |
| Mixed GC停顿 | < 200ms | N/A |
| ZGC停顿 | N/A | < 10ms |
| Full GC频率 | 0 | 0 |
APM系统集成:
- Prometheus + Grafana
- SkyWalking
- Arthas dashboard
八、最佳实践与建议
8.1 G1最佳实践
8.1.1 参数配置Checklist
-
-Xms=-Xmx(避免动态扩容) - 堆大小 = 物理内存的60-80%
-
-XX:MaxGCPauseMillis=100-200(根据业务调整) -
-XX:G1HeapRegionSize手动设置(堆 ≥ 16GB时) -
-XX:InitiatingHeapOccupancyPercent=40-45 -
-XX:G1ReservePercent=10-15 - 开启GC日志,配置日志滚动
-
-XX:+HeapDumpOnOutOfMemoryError
8.1.2 代码层面优化
避免大对象:
// ❌ 错误: 创建大数组
byte[] buffer = new byte[10 * 1024 * 1024]; // 10MB,可能成为Humongous对象
// ✅ 正确: 使用对象池
ByteBuffer buffer = bufferPool.acquire();
及时释放资源:
// ❌ 错误: 资源泄漏
Connection conn = dataSource.getConnection();
// 忘记close()
// ✅ 正确: try-with-resources
try (Connection conn = dataSource.getConnection()) {
// 使用conn
} // 自动close()
使用本地变量:
// ❌ 错误: 静态集合持有对象
public class Cache {
private static Map<String, Object> cache = new HashMap<>();
// 永不清理,泄漏!
}
// ✅ 正确: 使用Guava Cache
Cache<String, Object> cache = CacheBuilder.newBuilder()
.maximumSize(10000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build();
8.2 ZGC最佳实践
8.2.1 参数配置Checklist
-
-Xms=-Xmx(避免动态扩容) - 堆大小 ≥ 16GB
-
-XX:SoftMaxHeapSize设置为Xmx的80-90% -
-XX:ConcGCThreads根据CPU核心数调整 - 元空间配置 (ZGC不支持压缩类指针)
- 开启GC日志 (使用JDK 9+统一日志格式)
-
-XX:+HeapDumpOnOutOfMemoryError
8.2.2 适用场景评估
ZGC适合:
✅ 堆内存 ≥ 16GB ✅ 延迟敏感应用 (TP99 < 10ms) ✅ 愿意牺牲5-15%吞吐量换取低延迟 ✅ JDK 15+
ZGC不适合:
❌ 小堆应用 (< 8GB) ❌ 吞吐量优先场景 ❌ JDK 8/11 (需要升级到JDK 15+) ❌ CPU资源紧张的环境
8.3 调优流程
8.3.1 性能基线建立
-
收集当前性能数据 (至少24小时):
- GC日志
- 接口RT/TP99
- 系统资源使用率 (CPU/内存/IO)
- 业务指标 (QPS/TPS/错误率)
-
分析当前瓶颈:
- GC频率过高?
- GC停顿过长?
- 堆使用率过高?
- Full GC频繁?
8.3.2 调优步骤
- 小步快跑: 每次只调整一个参数
- 压测验证: 模拟生产流量压测
- 灰度发布: 10% → 50% → 100%
- 持续监控: 观察7天,确保无异常
8.3.3 监控告警
设置告警阈值:
- Full GC频率 > 1次/小时
- 堆使用率 > 80%
- GC停顿 > 目标值 (G1: 200ms, ZGC: 10ms)
- 接口TP99 > 业务目标
8.4 常见陷阱
陷阱1: 盲目追求MaxGCPauseMillis
设置过小的停顿目标(如10ms),G1无法达成,可能频繁触发Full GC。
陷阱2: 忽略吞吐量
ZGC低延迟的代价是吞吐量下降5-15%,需要评估业务是否可接受。
陷阱3: 不监控GC日志
GC日志是调优的基础,必须开启并定期分析。
陷阱4: 代码问题靠GC解决
内存泄漏、大对象频繁创建等代码问题,调整GC参数无法根治,必须优化代码。
总结
G1和ZGC代表了Java GC技术的演进方向,从平衡性能到极致低延迟。G1通过Region分区、RSet和SATB算法,实现可预测的停顿时间,适用于4GB-64GB堆的通用场景;ZGC通过染色指针、读屏障和并发重定位,实现< 10ms的超低停顿,专为超大堆和极低延迟场景设计。
核心要点:
- Region设计: G1的核心创新,打破固定分代边界,支持动态调整
- RSet机制: 记录跨Region引用,避免全堆扫描,大幅提升Young GC效率
- SATB算法: G1的并发标记实现,保证不漏标对象,代价是浮动垃圾
- 染色指针: ZGC的核心创新,将GC信息存储在指针中,实现并发标记和重定位
- 读屏障: ZGC的关键技术,对象访问时自动处理重定位,无需STW
- 多重映射: ZGC的内存管理技术,同一物理内存映射到多个虚拟地址,实现并发安全
选择建议:
- 堆 < 16GB,延迟要求中等: 选择G1,平衡性能,吞吐量高
- 堆 ≥ 16GB,延迟要求极高: 选择ZGC,超低停顿,代价是吞吐量下降
- 通用互联网应用: G1是首选,开箱即用,调优简单
- 金融、实时系统: ZGC是首选,低延迟优先级高于吞吐量
调优原则:
- 建立性能基线,小步快跑,压测验证,灰度发布
- 代码优化优于参数调优,避免内存泄漏和大对象
- 持续监控GC日志,定期Review性能数据
- 根据业务场景选择合适的收集器,不要盲目追求新技术
掌握G1和ZGC的原理与调优,能够在生产环境中游刃有余地进行GC调优,提升系统性能,降低故障风险。
参考资料:
- 《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》- 周志明
- 《Java性能权威指南》- Scott Oaks
- Oracle官方JVM文档
- OpenJDK ZGC官方文档
- GCEasy: gceasy.io