一、概述
- G1:面向服务端、可预测停顿、基于 Region 的分代收集器。
- ZGC:JDK 11 引入的低延迟收集器,基于 Region、颜色指针与读屏障,停顿时间不随堆增大而增长。
- 选型:结合堆大小、停顿要求、吞吐要求综合判断。
二、G1 收集器核心要点
2.1 基本定位
G1(Garbage-First)是一款面向服务端的垃圾收集器,主要针对多核处理器与大容量内存机器,在满足极高概率的 GC 停顿时间要求的同时,兼顾较高的吞吐量。它是 JDK 1.7 以上版本 JVM 的重要进化特征。
2.2 内存布局:Region 划分
- G1 将 Java 堆划分为多个大小相等的独立区域(Region),JVM 最多可以有 2048 个 Region。
- Region 大小一般等于堆大小除以 2048,例如堆为 4096M 时 Region 为 2M;也可用
-XX:G1HeapRegionSize手动指定,但推荐默认计算方式。 - G1 保留年轻代和老年代概念,但不再是物理隔阂,它们都是(可以不连续的)Region 集合。
- 默认年轻代占堆内存 5%,可通过
-XX:G1NewSizePercent设置初始占比,运行中 JVM 会不断增加年轻代 Region,但最多不超过 60%,可用-XX:G1MaxNewSizePercent调整。 - 年轻代中 Eden 与 Survivor 的 Region 比例默认仍为 8:1:1。
- Region 的区域功能可以动态变化:某个 Region 之前是年轻代,回收后可能变成老年代。
2.3 Humongous 区:大对象处理
G1 对对象晋升老年代的原则与之前收集器基本一致,唯一不同的是大对象处理:G1 有专门分配大对象的 Region 叫 Humongous 区,而不是让大对象直接进入老年代 Region。
- 判定规则:一个大对象超过一个 Region 大小的 50%。例如 Region 为 2M 时,超过 1M 的对象就放入 Humongous 区。
- 一个超大对象可能横跨多个 Region 存放。
- Humongous 区专门存放短期巨型对象,不必直接进老年代,可节约老年代空间,避免因老年代空间不足引发的 GC 开销。
- Full GC 时除了收集年轻代和老年代,也会一并回收 Humongous 区。
2.4 一次 GC 的运作过程
- 初始标记(Initial Mark,STW):暂停所有其他线程,记录 GC Roots 直接能引用的对象,速度很快。
- 并发标记(Concurrent Marking):与 CMS 的并发标记相同。
- 最终标记(Remark,STW):与 CMS 的重新标记相同。
- 筛选回收(Cleanup,STW):先对各个 Region 的回收价值和成本排序,再根据用户期望的 GC 停顿时间(
-XX:MaxGCPauseMillis)制定回收计划,只回收 Collection Set 中的 Region。
无论年轻代还是老年代,G1 回收算法主要使用复制算法,把 Region 中的存活对象复制到另一个 Region,因此几乎不会像 CMS 那样产生大量内存碎片。需要注意:CMS 的回收阶段可与用户线程并发执行,而 G1 因内部实现复杂暂未实现并发回收,Shenandoah 则实现了并发收集,可看作 G1 的升级版本。
2.5 Garbage-First 的含义
G1 在后台维护一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region,这正是 Garbage-First 名字的由来。例如一个 Region 花 200ms 能回收 10M 垃圾,另一个花 50ms 能回收 20M 垃圾,在回收时间有限时 G1 会优先选择后者。这种 Region 划分加优先级回收的方式,保证了 G1 在有限时间内尽可能高的收集效率。
2.6 四大特点
- 并行与并发:充分利用多核 CPU 缩短 STW 停顿时间,部分阶段可通过并发方式让 Java 程序继续执行。
- 分代收集:可独立管理整个 GC 堆,但仍保留分代概念。
- 空间整合:整体基于“标记-整理”算法,局部基于“复制”算法,不会产生大量碎片。
- 可预测的停顿:可建立可预测的停顿时间模型,通过
-XX:MaxGCPauseMillis指定在 M 毫秒内完成垃圾收集。
2.7 停顿时间设置
G1 默认停顿目标为 200ms,回收阶段占到几十到一百甚至接近两百毫秒都很正常。如果把停顿时间调得过低(例如 20ms),每次选出的回收集只占堆内存很小一部分,收集速度可能跟不上分配速度,垃圾逐渐堆积,最终占满堆引发 Full GC,反而降低性能。通常把期望停顿时间设置为一两百毫秒或两三百毫秒比较合理。
三、G1 的 GC 分类
3.1 Young GC
Young GC 并不是 Eden 区一放满就立即触发。G1 会先计算当前 Eden 区回收大概需要多久,如果远小于-XX:MaxGCPauseMillis设定值,就增加年轻代 Region 继续存放新对象;直到下一次 Eden 放满且计算出的回收时间接近设定值,才触发 Young GC。
3.2 Mixed GC
Mixed GC 不是 Full GC。当老年代堆占有率达到-XX:InitiatingHeapOccupancyPercent设定值时触发,回收所有 Young 区、部分 Old 区(按期望停顿时间确定老年代垃圾收集优先顺序)以及大对象区。G1 正常情况先做 Mixed GC,主要使用复制算法,把各 Region 中存活对象拷贝到别的 Region;若拷贝过程中没有足够空闲 Region 承载对象,就会触发一次 Full GC。
3.3 Full GC
Full GC 会停止系统程序,采用单线程进行标记、清理和压缩整理,腾出一批 Region 供下一次 Mixed GC 使用,过程非常耗时(Shenandoah 已优化为多线程收集)。
四、G1 常用参数速查
| 参数 | 作用 |
|---|---|
-XX:+UseG1GC | 使用 G1 收集器 |
-XX:ParallelGCThreads | 指定 GC 工作线程数量 |
-XX:G1HeapRegionSize | 指定分区大小(1MB~32MB,且必须是 2 的 N 次幂),默认整堆划分为 2048 个分区 |
-XX:MaxGCPauseMillis | 目标暂停时间,默认 200ms |
-XX:G1NewSizePercent | 新生代内存初始空间,默认整堆 5% |
-XX:G1MaxNewSizePercent | 新生代内存最大空间 |
-XX:TargetSurvivorRatio | Survivor 区填充容量,默认 50%;超过后把年龄 n(含)以上对象放入老年代 |
-XX:MaxTenuringThreshold | 最大年龄阈值,默认 15 |
-XX:InitiatingHeapOccupancyPercent | 老年代占用达到整堆阈值(默认 45%)时执行 Mixed GC |
-XX:G1MixedGCLiveThresholdPercent | 默认 85%,Region 中存活对象低于该值才回收该 Region |
-XX:G1MixedGCCountTarget | 一次回收过程中做几次筛选回收,默认 8 次 |
-XX:G1HeapWastePercent | 默认 5%,空闲 Region 数量达到堆内存 5% 时立即停止混合回收 |
五、G1 调优建议与适用场景
5.1 调优核心
如果-XX:MaxGCPauseMillis设置过大,系统运行很久、年轻代可能已占用堆内存 60% 才触发 Young GC,存活对象过多会导致 Survivor 区放不下而进入老年代;或者触发动态年龄判定规则(达到 Survivor 区 50%)也会让对象快速进入老年代,进而频繁触发 Mixed GC。因此核心在于调节-XX:MaxGCPauseMillis:既保证 Young GC 不过于频繁,又控制每次 GC 后存活对象数量,避免其快速进入老年代。
5.2 适合使用 G1 的场景
- 50% 以上的堆被存活对象占用。
- 对象分配和晋升的速度变化非常大。
- 垃圾回收时间特别长,超过 1 秒。
- 8GB 以上的堆内存(建议值)。
- 停顿时间要求在 500ms 以内。
5.3 高并发大内存场景示例
以 Kafka 这类每秒处理几万甚至几十万消息的系统为例,通常部署在大内存机器(如 64G),可给年轻代分配三四十 G。此时 Eden 区放满可能只需一两分钟,而回收三四十 G 内存最快也要几秒钟,意味着系统每隔一两分钟就会因 Young GC 卡顿几秒,无法处理新消息。此时可改用 G1,设置-XX:MaxGCPauseMillis为 50ms,假设 50ms 能回收三到四个 G 内存,这种卡顿用户几乎无感知,系统即可一边处理业务一边收集垃圾。G1 天生适合大内存机器的 JVM 运行。
六、ZGC 收集器核心要点
6.1 基本定位与目标
ZGC 是 JDK 11 中新加入的具有实验性质的低延迟垃圾收集器,源自 Azul System 公司开发的 C4 收集器。其目标主要有四点:
- 支持 TB 量级的堆。
- 最大 GC 停顿时间不超过 10ms。停顿时间主要与 Root 扫描有关,而 Root 数量与堆大小无关。
- 奠定未来 GC 特性的基础。
- 最糟糕情况下吞吐量降低 15%,可通过扩容弥补。
Oracle 官方提到它最大的优点是:停顿时间不会随着堆的增大而增长。几十 G 堆的停顿在 10ms 以下,几百 G 甚至上 T 堆同样如此。
6.2 不分代(暂时)
ZGC 目前是单代,即没有分代。以往收集器分代源于“大部分对象朝生夕死”的假设,而 ZGC 不分代主要是因为分代实现起来麻烦,作者先实现一个简单可用的单代版本,后续再优化。
6.3 内存布局:三类 Region
ZGC 基于 Region 内存布局,暂时不设分代,使用读屏障、颜色指针等技术实现可并发的标记-整理算法,以低延迟为首要目标。
- 小型 Region(Small Region):容量固定 2MB,用于放置小于 256KB 的小对象。
- 中型 Region(Medium Region):容量固定 32MB,用于放置大于等于 256KB 但小于 4MB 的对象。
- 大型 Region(Large Region):容量不固定、可动态变化,但必须为 2MB 的整数倍,用于放置 4MB 及以上大对象。每个大型 Region 只存放一个大对象,实际容量可能小于中型 Region,最小可低至 4MB;大型 Region 不会被重分配,因为复制大对象代价非常高昂。
6.4 NUMA-aware
UMA(Uniform Memory Access Architecture)表示内存只有一块,所有 CPU 都访问同一块内存,存在竞争问题,CPU 核心越多竞争越激烈。NUMA(Non Uniform Memory Access Architecture)中每个 CPU 对应一块内存,且该内存在主板上离这个 CPU 最近,CPU 优先访问本地内存,效率更高。ZGC 能自动感知 NUMA 架构并充分利用其特性。
6.5 颜色指针(Colored Pointers)
以前收集器的 GC 信息保存在对象头中,而 ZGC 的 GC 信息保存在指针中。每个对象有一个 64 位指针,划分如下:
- 18 位:预留给以后使用。
- 1 位:Finalizable 标识,与并发引用处理有关,表示对象只能通过 finalizer 访问。
- 1 位:Remapped 标识,设置后表示对象未指向 relocation set(需要 GC 的 Region 集合)。
- 1 位:Marked1 标识。
- 1 位:Marked0 标识,与 Marked1 一起用于辅助 GC 标记对象。
- 42 位:对象地址,可支持 2^42 = 4T 内存。
为什么有两个 mark 标记?每个 GC 周期开始时交换使用的标记位,使上次周期修正的已标记状态失效,所有引用都变成未标记。周期 1 使用 mark0,结束时所有引用 mark 标记为 01;周期 2 使用 mark1,期待标记为 10,所有引用都能被重新标记。
由于对象指针必须是 64 位,ZGC 无法支持 32 位操作系统,也无法支持压缩指针(CompressedOops 是 32 位)。颜色指针的三大优势:
- 某个 Region 的存活对象被移走后,该 Region 可立即释放和重用,不必等待整个堆中所有指向它的引用都被修正,理论上只要还有一个空闲 Region,ZGC 就能完成收集。
- 大幅减少 GC 过程中内存屏障的使用数量,ZGC 只使用读屏障。
- 具备强大的扩展性,可作为可扩展存储结构记录更多与对象标记、重定位相关的数据。
6.6 读屏障(Load Barriers)
以往 GC 多采用 Write Barrier,ZGC 采用读屏障。在标记和移动对象阶段,每次从堆中对象的引用类型读取指针时都要加上 Load Barrier。若对象在 GC 时被移动,读屏障会把读出的指针更新到对象新地址,并把堆里的指针“修正”到原字段,应用代码始终持有有效指针,且不需要 STW。
JVM 判断对象是否被移动过,正是利用颜色指针:若指针是 Bad Color,程序不能继续执行,需要走 slow path 修正指针;若是 Good Color,则正常执行。这与 JDK 并发中的 CAS 自旋类似——读取的值失效后需要重新读取。注意Object p = o、o.doSomething()以及读取原子类型字段都不需要读屏障。正因为 Load Barriers 的存在,配置 ZGC 的应用吞吐量会变低,官方测试数据是额外多出约 4% 的开销。
关于内存上限:低 42 位指针可支持 4T,但受限于主板地址总线最宽 48 位,4 位是颜色位,只剩 44 位,因此 ZGC 最大只能支持 16T 内存,JDK 13 已把最大支持堆内存从 4T 扩大到 16T。
6.7 ZGC 运作过程
- 并发标记(Concurrent Mark):与 G1 一样遍历对象图做可达性分析,初始标记(Mark Start)和最终标记(Mark End)会有短暂停顿。与 G1 不同的是,ZGC 的标记在指针上而非对象上进行,标记阶段会更新染色指针中的 Marked0、Marked1 标志位。
- 并发预备重分配(Concurrent Prepare for Relocate):根据特定查询条件统计本次要清理哪些 Region,组成重分配集(Relocation Set)。ZGC 每次回收都扫描所有 Region,用更大的扫描成本换取省去 G1 中记忆集的维护成本。
- 并发重分配(Concurrent Relocate):核心阶段,把重分配集中的存活对象复制到新 Region,并为每个 Region 维护转发表(Forward Table),记录旧对象到新对象的转向关系。用户线程并发访问重分配集中的对象时,访问会被读屏障截获,根据转发表转发到新复制的对象,并修正引用值直接指向新对象,这就是指针的“自愈”(Self-Healing)能力。由于自愈能力,只有第一次访问旧对象会变慢;某个 Region 的存活对象复制完毕后即可立即释放用于新对象分配,但转发表需保留,因为可能还有访问在使用它。
- 并发重映射(Concurrent Remap):修正整个堆中指向重分配集中旧对象的所有引用。由于对象引用存在自愈功能,该操作并不迫切,ZGC 巧妙地把这一步合并到下一次 GC 循环的并发标记阶段完成,节省一次遍历对象图的开销。所有指针修正后,转发表即可释放。
6.8 ZGC 存在的问题与参数
ZGC 最大的问题是浮动垃圾。ZGC 停顿时间在 10ms 以下,但执行时间仍远大于此。假如全过程需执行 10 分钟,期间对象分配速率很高会创建大量新对象,这些对象很难进入当次 GC,只能等下次回收,即浮动垃圾。此外 ZGC 没有分代概念,每次都要全堆扫描,导致一些“朝生夕死”的对象未能及时回收。目前唯一的办法是增大堆容量让程序获得更多喘息时间,但治标不治本,根本解决仍需引入分代收集。
启用 ZGC 需设置-XX:+UnlockExperimentalVMOptions -XX:+UseZGC。ZGC 调优参数不多,大部分工作 JVM 能自动完成。
ZGC 目前有 4 种触发机制:
- 定时触发:默认不使用,可通过
ZCollectionInterval配置。 - 预热触发:最多三次,在堆内存达到 10%、20%、30% 时触发,主要用于统计 GC 时间供其他机制使用。
- 分配速率:基于正态分布统计,计算内存 99.9% 可能的最大分配速率及该速率下内存耗尽时间点,在耗尽前触发 GC(耗尽时间 - 一次 GC 最大持续时间 - 一次 GC 检测周期时间)。
- 主动触发:默认开启,可通过
ZProactive配置。距上次 GC 堆内存增长 10% 或超过 5 分钟时,对比距上次 GC 的间隔时间与(49 × 一次 GC 最大持续时间),超过则触发。
七、垃圾收集器选型
- 优先调整堆大小,让服务器自己选择。
- 内存小于 100M,使用串行收集器。
- 单核且无停顿时间要求,使用串行或由 JVM 自己选择。
- 允许停顿时间超过 1 秒,选择并行或由 JVM 自己选。
- 响应时间最重要且不能超过 1 秒,使用并发收集器。
- 4G 以下可用 Parallel,4-8G 可用 ParNew + CMS,8G 以上可用 G1,几百 G 以上用 ZGC。
下图有连线的可以搭配使用
JDK 1.8 默认使用 Parallel(年轻代和老年代都是),JDK 1.9 默认使用 G1。
八、安全点与安全区域
安全点指代码中一些特定位置,线程运行到这些位置时状态是确定的,JVM 可以安全地执行 GC 等操作。GC 不是想什么时候做就立即触发,需要等待所有线程运行到安全点后才能触发。常见安全点位置包括:方法返回之前、调用某个方法之后、抛出异常的位置、循环的末尾。
实现思想是:需要中断线程时,不直接对线程操作,仅设置一个标志位,各线程执行过程中不停主动轮询该标志,一旦发现中断标志为真,就在最近的安全点主动中断挂起。轮询标志的地方与安全点重合。
安全区域(Safe Region)针对的是处于 Sleep 或中断状态、无法响应 JVM 中断请求的线程。Safe Region 指一段代码片段,其中引用关系不会发生变化,在这个区域内任意地方开始 GC 都是安全的。
九、面试常见提问与解答
Q1:G1 与 CMS 的主要区别是什么?
参考回答:一是内存布局不同,G1 把堆划分为大小相等的 Region,年轻代和老年代是 Region 的动态集合,而 CMS 是物理连续的分代布局;二是回收算法不同,G1 整体是标记-整理、局部是复制算法,几乎不产生内存碎片,CMS 是标记-清除,需要额外整理;三是 G1 可建立可预测的停顿时间模型,通过-XX:MaxGCPauseMillis指定目标停顿;四是 G1 有 Humongous 区专门处理大对象;五是 G1 的回收阶段暂未实现与用户线程并发,而 CMS 的回收阶段可与用户线程并发执行。
Q2:G1 的 Region 是如何划分的?大小怎么确定?
参考回答:G1 把堆划分为最多 2048 个大小相等的 Region,Region 大小一般等于堆大小除以 2048,例如 4096M 堆对应 2M。也可通过-XX:G1HeapRegionSize手动指定,取值范围 1MB~32MB 且必须是 2 的 N 次幂,但推荐使用默认计算方式。Region 的功能是动态的,回收后可能从年轻代变为老年代。
Q3:什么是 Humongous 区?为什么要设计它?
参考回答:Humongous 区是 G1 专门用于分配大对象的 Region。判定规则是大对象超过一个 Region 大小的 50%,例如 Region 为 2M 时超过 1M 即进入 Humongous 区,超大对象可能横跨多个 Region。设计目的是让短期巨型对象不必直接进入老年代,节约老年代空间,避免因老年代空间不足引发 GC 开销。Full GC 时会一并回收 Humongous 区。
Q4:G1 的 Young GC 和 Mixed GC 分别在什么条件下触发?
参考回答:Young GC 不是 Eden 一满就触发,G1 会先估算 Eden 回收耗时,若远小于-XX:MaxGCPauseMillis就继续增加年轻代 Region;当估算时间接近设定值且 Eden 再次放满时才触发。Mixed GC 在老年代占用达到-XX:InitiatingHeapOccupancyPercent(默认 45%)时触发,回收全部 Young 区、部分 Old 区和大对象区。若复制过程中没有足够空闲 Region 承载存活对象,会退化为 Full GC。
Q5:G1 的“可预测停顿”是怎么实现的?停顿时间能随便调小吗?
参考回答:G1 在后台维护优先列表,每次根据允许的收集时间优先回收价值最大的 Region,并在筛选回收阶段对 Region 的回收价值和成本排序,按-XX:MaxGCPauseMillis制定回收计划,只回收 Collection Set 中的 Region,从而把停顿控制在目标范围内。停顿时间不能随便调小:设置过低会导致每次回收集只占堆内存很小一部分,收集速度跟不上分配速度,垃圾堆积最终引发 Full GC,反而降低性能。通常设置一两百毫秒或两三百毫秒比较合理。
Q6:ZGC 为什么能做到停顿时间不随堆增大而增长?
参考回答:ZGC 的停顿主要发生在初始标记和最终标记这类与 Root 扫描相关的短阶段,而 Root 数量与堆大小无关。其标记、重分配、重映射等主要工作都通过颜色指针和读屏障与用户线程并发完成,因此几十 G、几百 G 甚至上 T 的堆,停顿时间都能保持在 10ms 以下。
Q7:ZGC 的颜色指针是怎么划分的?为什么需要两个 Mark 标记?
参考回答:64 位指针中,18 位预留,1 位 Finalizable,1 位 Remapped,1 位 Marked1,1 位 Marked0,42 位为对象地址(可支持 4T)。需要两个 Mark 标记是因为每个 GC 周期开始时交换使用的标记位,使上次周期修正的已标记状态失效、所有引用变为未标记:周期 1 使用 mark0,结束时引用标记为 01;周期 2 使用 mark1,期待标记为 10,从而所有引用都能被重新标记。
Q8:什么是读屏障?ZGC 的“自愈”能力指什么?
参考回答:读屏障是在从堆中对象的引用类型读取指针时插入的屏障。若对象在 GC 时被移动,读屏障会把读出的指针更新到新地址,并修正堆中该字段的指针,使应用始终持有有效指针且无需 STW。自愈指用户线程并发访问重分配集中的对象时,访问被读屏障截获,根据 Region 的转发表转发到新复制的对象,并同时修正引用值直接指向新对象。因此只有第一次访问旧对象会变慢,之后都直接访问新对象。
Q9:ZGC 的运作过程分为哪几个阶段?
参考回答:分为四个阶段:并发标记(遍历对象图做可达性分析,初始标记和最终标记有短暂停顿,标记在指针上进行);并发预备重分配(统计要清理的 Region 组成重分配集,每次扫描所有 Region 以省去记忆集维护成本);并发重分配(把存活对象复制到新 Region,维护转发表,通过读屏障实现自愈);并发重映射(修正整个堆中指向旧对象的引用,因自愈能力不迫切,合并到下一次 GC 的并发标记阶段完成)。
Q10:ZGC 存在什么问题?如何解决?
参考回答:最大问题是浮动垃圾。ZGC 停顿虽短,但整体执行时间可能很长,期间高速分配的新对象难以进入当次 GC,只能等下次回收;同时 ZGC 不分代,每次全堆扫描,导致“朝生夕死”的对象不能及时回收。目前可通过增大堆容量缓解,但治标不治本,根本方案是引入分代收集,为新生对象设置专门区域并更频繁、更快地收集。
Q11:实际项目中如何选择垃圾收集器?
参考回答:优先调整堆大小让服务器自己选择;内存小于 100M 用串行收集器;单核且无停顿要求用串行或 JVM 自选;允许停顿超过 1 秒选并行或 JVM 自选;响应时间最重要且不能超过 1 秒用并发收集器。按堆规模粗略划分:4G 以下可用 Parallel,4-8G 可用 ParNew + CMS,8G 以上可用 G1,几百 G 以上用 ZGC。JDK 1.8 默认 Parallel,JDK 1.9 默认 G1。
Q12:什么是安全点和安全区域?为什么需要它们?
参考回答:安全点是代码中一些特定位置,线程运行到这些位置时状态确定,JVM 可安全执行 GC 等操作,常见于方法返回前、调用方法后、抛出异常处、循环末尾。GC 需要等待所有线程到达安全点才能触发,实现上通过设置标志位、由线程主动轮询并在最近安全点挂起。安全区域针对处于 Sleep 或中断状态、无法响应中断请求的线程,指一段引用关系不会变化的代码片段,在其中任意位置开始 GC 都是安全的。
Q13:每秒几十万并发的系统如何优化 JVM?
参考回答:以 Kafka 为例,通常部署在大内存机器(如 64G),年轻代可分配三四十 G。此时 Eden 放满可能只需一两分钟,而回收三四十 G 内存要几秒钟,系统会周期性卡顿。可改用 G1 并设置-XX:MaxGCPauseMillis为 50ms,假设 50ms 能回收三到四个 G,这种卡顿用户几乎无感知,系统即可一边处理业务一边收集垃圾。G1 天生适合大内存机器的 JVM 运行,能较好解决大内存垃圾回收时间过长的问题。