G1与ZGC垃圾收集器深度剖析

423 阅读33分钟

概述

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的限制:

设计目标:

  1. 可预测的停顿时间: 通过-XX:MaxGCPauseMillis设置停顿目标(软目标)
  2. 适应大堆: 支持几十GB堆,Full GC时间可控
  3. 平衡吞吐量和延迟: 不牺牲太多吞吐量的前提下,降低停顿时间

核心创新:

  • Region分区: 将堆划分为多个大小相等的Region,不再固定分代边界
  • Garbage-First策略: 优先回收垃圾最多的Region,在停顿时间允许范围内最大化回收效果
  • 增量回收: 每次只回收部分Region,避免全堆扫描

1.3 ZGC的诞生背景

ZGC (Z Garbage Collector) 于JDK 11引入(实验性),JDK 15转正,目标是实现超低延迟:

设计目标:

  1. 极低停顿: 停顿时间 < 10ms,与堆大小无关
  2. 支持超大堆: 4TB以上堆,停顿时间依然在10ms以内
  3. 高吞吐量: 停顿时间降低的同时,吞吐量损失控制在15%以内

核心创新:

  • 染色指针 (Colored Pointers): 将GC信息存储在指针中,而不是对象头
  • 读屏障 (Load Barrier): 对象访问时通过读屏障自动处理重定位
  • 并发重定位: 对象移动与应用线程并发执行,无需STW
  • 多重映射: 同一物理内存映射到多个虚拟地址,实现并发安全

适用场景:

  • 低延迟要求极高的应用 (金融交易、实时系统)
  • 超大堆内存应用 (几十GB到TB级)
  • 追求极致用户体验的互联网应用

二、G1垃圾收集器原理

2.1 G1的整体架构

G1的核心设计思想是"化整为零",将堆划分为多个Region,打破传统分代GC的固定边界。

2.1.1 Region分区设计

g1-region-structure.svg

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可以动态分配为不同类型:

  1. Eden Region (Eden区):

    • 新对象分配的区域
    • Young GC时全部清空
  2. Survivor Region (Survivor区):

    • Young GC后存活对象的区域
    • 采用复制算法,From/To角色互换
  3. Old Region (老年代区):

    • 存放晋升对象
    • Mixed GC时部分回收
  4. Humongous Region (大对象区):

    • 存放大对象(≥ Region大小的50%)
    • 大对象直接分配到老年代
    • 可能跨越多个连续Region
  5. 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)

g1-rset-structure.svg

为什么需要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-flow.svg

G1的GC流程包括三种类型:Young GC、Mixed GC和Full GC。

2.2.1 Young GC (纯年轻代回收)

触发条件: Eden Region满

回收过程 (STW停顿):

  1. 暂停应用线程 (STW开始)
  2. 扫描GC Roots: 线程栈、全局变量、JNI句柄等
  3. 扫描RSet: 找出老年代Region对年轻代的引用
  4. 复制存活对象:
    • Eden和Survivor中的存活对象复制到新的Survivor Region
    • 年龄达到阈值(默认15)的对象晋升到Old Region
  5. 清空Eden: 所有Eden Region标记为空闲
  6. 恢复应用线程 (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 (混合回收)

触发条件:

  1. 并发标记周期完成
  2. 老年代使用率 ≥ 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算法详解

tricolor-satb.svg

2.3.1 三色标记算法

G1使用三色标记算法进行并发标记:

  • 白色对象: 未标记,可能是垃圾
  • 灰色对象: 已标记,但子对象未扫描
  • 黑色对象: 已标记,且子对象已扫描

标记流程:

  1. 初始: 所有对象为白色
  2. GC Roots标记为灰色
  3. 从灰色对象队列取出对象,扫描其引用:
    • 引用对象标记为灰色
    • 当前对象标记为黑色
  4. 重复步骤3,直到灰色对象队列为空
  5. 剩余白色对象 = 垃圾,回收

不变式: 黑色对象不能直接引用白色对象

若违反,可能导致对象漏标:

  • 黑色对象已扫描完成
  • 新增白色引用不会被发现
  • 白色对象被误认为垃圾
  • 存活对象被错误回收!

2.3.2 并发标记的问题

并发标记期间,应用线程可能修改对象引用,导致对象漏标。

对象漏标的两个充要条件:

  1. 黑色对象新增对白色对象的引用
  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的三大核心技术:

  1. 染色指针 (Colored Pointers): 将GC信息存储在指针中
  2. 读屏障 (Load Barrier): 对象访问时自动处理重定位
  3. 并发重定位: 对象移动与应用线程并发执行

3.2 染色指针 (Colored Pointers)

ZGC染色指针结构

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-flow.svg

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

  1. 应用线程访问对象A: obj = *ptr; (触发读屏障)
  2. 读屏障检查颜色位:
    • Remapped = 0: 对象还在旧地址,直接返回
    • Remapped = 1: 对象已移动,查询转发表获取新地址
  3. 转发到新地址:
    • 从转发表查询: 0x1000 → 0x2000
    • 返回新地址的对象
    • 更新指针为新地址 (可选优化)

3.5 多重映射内存管理

zgc-multi-mapping.svg

3.5.1 核心思想

ZGC将同一块物理内存映射到3个不同的虚拟地址空间:

  • Marked0 View: Marked0=1 的指针访问此视图
  • Marked1 View: Marked1=1 的指针访问此视图
  • Remapped View: Remapped=1 的指针访问此视图

示例:

虚拟地址空间:
┌─────────────────┐
│ Marked0 View0x0000_0000_0000 - 0x0000_03FF_FFFF
│ (虚拟地址)      │
└────────┬────────┘
         │ 映射
         ↓
┌─────────────────┐
│ 物理内存        │ 0x1000_0000 - 0x13FF_FFFF (64MB)
│ (对象A, B, C...)│
└────────┬────────┘
         ↑ 映射
         │
┌─────────────────┐
│ Remapped View0x0000_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 核心技术对比

维度G1ZGC
分区设计Region分区 (1MB-32MB)Page分区 (2MB, 32KB, 256KB三种)
标记算法三色标记 + SATB三色标记 + 染色指针
屏障类型写屏障 (维护RSet) + 前置写屏障 (SATB)读屏障 (Load Barrier)
重定位复制算法 (STW)并发重定位 (读屏障 + 转发表)
内存管理单一视图多重映射 (3个视图)
GC信息存储对象头 (Mark Word)指针元数据位

4.2 性能对比

指标G1ZGC
停顿时间50-200ms< 10ms (通常 < 1ms)
吞吐量95-98%85-95%
堆大小4GB - 64GB (最佳)8GB - 16TB
适用场景通用场景,平衡性能超大堆,极低延迟
CPU开销中等较高 (读屏障)
内存开销RSet占5-10%虚拟地址空间3倍 (物理内存不增加)

4.3 GC流程对比

GC阶段G1ZGC
Young GCSTW,20-100ms无独立Young GC,统一流程
并发标记4阶段,2个STW (初始/最终标记)7阶段,3个STW (每个 < 1ms)
Mixed GCSTW,50-200ms,回收部分老年代无Mixed GC概念
重定位STW,复制对象并发,无STW
Full GC可能发生,停顿几秒几乎不发生

4.4 参数复杂度对比

维度G1ZGC
核心参数~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延迟500ms150ms
GC停顿200-500ms50-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延迟120ms45ms
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)
数据处理延迟800ms400ms
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延迟150ms80ms
GC停顿20-100ms20-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延迟2500ms120ms95%↓

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的触发场景:

  1. 晋升失败 (Promotion Failure):

    • Young GC时,老年代没有足够空间容纳晋升对象
    • 解决: 增大堆内存,降低InitiatingHeapOccupancyPercent
  2. 巨型对象分配失败 (Humongous Allocation Failure):

    • 无法找到足够的连续Region存储大对象
    • 解决: 增大G1HeapRegionSize,避免大对象(优化代码,拆分对象)
  3. 并发模式失败 (Concurrent Mode Failure):

    • 并发标记未完成,老年代已满
    • 解决: 降低InitiatingHeapOccupancyPercent,增加ConcGCThreads
  4. 元空间不足:

    • Metaspace OOM
    • 解决: 增大MaxMetaspaceSize

7.4 ZGC有哪些限制?

ZGC的限制:

  1. JDK版本:

    • JDK 11: 实验性特性 (-XX:+UnlockExperimentalVMOptions -XX:+UseZGC)
    • JDK 15+: 正式特性 (-XX:+UseZGC)
  2. 操作系统:

    • Linux (x86-64): 完全支持
    • macOS: JDK 14+支持
    • Windows: JDK 14+支持
  3. 堆大小:

    • 最小: 8GB (建议)
    • 最大: 16TB (理论上)
    • 实际生产: 16GB - 512GB
  4. CPU开销:

    • 读屏障带来额外CPU开销
    • 并发GC线程占用CPU
    • 吞吐量损失5-15%
  5. 不支持压缩类指针:

    • ZGC不支持-XX:+UseCompressedClassPointers
    • 元空间占用略高

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停顿< 100msN/A
Mixed GC停顿< 200msN/A
ZGC停顿N/A< 10ms
Full GC频率00

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 性能基线建立

  1. 收集当前性能数据 (至少24小时):

    • GC日志
    • 接口RT/TP99
    • 系统资源使用率 (CPU/内存/IO)
    • 业务指标 (QPS/TPS/错误率)
  2. 分析当前瓶颈:

    • GC频率过高?
    • GC停顿过长?
    • 堆使用率过高?
    • Full GC频繁?

8.3.2 调优步骤

  1. 小步快跑: 每次只调整一个参数
  2. 压测验证: 模拟生产流量压测
  3. 灰度发布: 10% → 50% → 100%
  4. 持续监控: 观察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的超低停顿,专为超大堆和极低延迟场景设计。

核心要点:

  1. Region设计: G1的核心创新,打破固定分代边界,支持动态调整
  2. RSet机制: 记录跨Region引用,避免全堆扫描,大幅提升Young GC效率
  3. SATB算法: G1的并发标记实现,保证不漏标对象,代价是浮动垃圾
  4. 染色指针: ZGC的核心创新,将GC信息存储在指针中,实现并发标记和重定位
  5. 读屏障: ZGC的关键技术,对象访问时自动处理重定位,无需STW
  6. 多重映射: 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