Region · 记忆集 · SATB · 混合回收 · 停顿目标 · 染色指针 · 读屏障 · 并发搬迁 · 分代 ZGC

G1 和 ZGC:一边干活,一边收垃圾

收垃圾要做三件事:找出活对象,把它们挪到一起,再改掉指向它们的引用。老的收集器做这些事时,要让业务线程全部停下。G1 把堆切成小块,每次只挪垃圾最多的几块,把停顿压在目标以内;ZGC 连挪对象都和业务线程同时做,靠读屏障让线程总能拿到新地址,停顿不到 1 毫秒。代价也不同:G1 仍有几十到几百毫秒的停顿,ZGC 要多花 CPU、多留内存余量。

假设场景订单服务从 JDK 8 升到 17,默认 GC 从 Parallel 变成了 G1。大促时 p99 偶尔冲到几百毫秒,有人提议换 ZGC。要判断该不该换、换了要注意什么;面试也常问“G1 和 ZGC 有什么区别”。上一页 JVM 内存结构 讲了堆、新生代、老年代和 Region,这页接着讲垃圾是怎么被收走的。

①为什么需要:GC 停顿会直接变成接口的慢请求

GC 要挪对象时,最简单的办法是让所有业务线程停下来,这叫 Stop-The-World(STW)停顿。停顿期间进来的请求只能排队,停多久,它们就慢多久。

一个接口的响应时间(示意) 0100 ms200300 超时线 100 ms Full GC 停了 300 ms 停顿期间到的请求全在排队,越早到的等得越久 Young GC 停 30 ms,没过线 时间 →
蓝点是平时的请求,红点是撞上 Full GC 的请求。GC 停顿的长短直接决定了 p99、p999;所以选 GC、调 GC,看的首先是停顿最长多久、多久来一次。

停顿要短

最长一次停顿要比超时时间短得多,否则一停就是一批超时。

停顿要可预期

不能堆越大停得越久,也不能偶尔来一次几秒的 Full GC。

别换太多东西

为了停顿短,多花的 CPU 和内存要算得过账。

②没有它会怎样:老收集器要么全停,要么不挪

JDK 8 默认的 Parallel,和后来被删掉的 CMS,分别代表两条老路。

同一段时间里,业务线程(蓝)被 GC 停顿(红)打断的样子 ParallelJDK 8 默认 Young Full GC 标记 + 整理整个堆:活对象越多停越久 CMS9 废弃,14 删除 并发标记、并发清除(不挪对象) 退化 Full GC 老年代碎了,放不下新对象 改用单线程整理,更慢 想要的G1 / ZGC 的目标 停顿很短、有上限,不随堆变大而变长
⑤ 里在本机用 1 GB 堆、400 MB 活对象实测:Parallel 的 Full GC 一次停 84~104 ms。活对象到几个 GB 时,按同样的速度估算,一次就是秒级。

不懂这一点时的典型误用:以为“堆调大就能少停”。对 Parallel,堆大了 Full GC 次数少了,但每次更长;对 G1,停顿主要看每次要复制多少活对象,和堆多大关系不大;对 ZGC,堆调大反而是对的,因为它要靠余量撑过并发回收那段时间(见 ④ ZGC)。同一个参数,换了 GC,效果正好相反。

③相邻方案:HotSpot 里的六种收集器

区别就在一件事上:找活对象、挪对象、改引用,这三步里哪些要停下业务线程来做。

收集器怎么做停顿代价现状
Serial单线程,全部 STW长几乎没有额外开销JDK 9~26 在 1 个 CPU 或内存不到 1792 MB 的机器上默认用它;27 起默认一律 G1
Parallel多线程,全部 STWYoung 短,Full GC 长(大堆秒级)吞吐最好之一JDK 8 默认;适合只看吞吐的批处理
CMS并发标记、并发清除,不整理平时短,退化时很长碎片9 废弃,14 删除
G1堆切成 Region,并发标记;在停顿里只挪一部分 Region有目标,默认 200 ms,常见几十 ms写屏障、记忆集占一些内存和 CPUJDK 9 起默认
ZGC标记、挪对象、改引用都并发做,靠读屏障不到 1 ms,和堆大小无关多耗 CPU;要多留内存余量;不能用压缩指针15 正式可用;21 加入分代;23 默认分代;24 删掉不分代
Shenandoah也是并发挪对象,思路和 ZGC 接近很短和 ZGC 类似12 加入;Oracle JDK 不带,用前确认发行版

选择规则:没有特别要求就用默认的 G1;p99 要压到 10 ms 以内,又能多给一些内存和 CPU,用分代 ZGC(JDK 21 起);离线批处理只看吞吐,用 Parallel。

本页学 G1 和 ZGC。一个是今天的默认,一个是低延迟的首选。放在一起学,才看得清“停顿短”是拿什么换来的。

④原理:一个“只挪一部分”,一个“挪的时候不停”

先看清回收要做哪三件事,再分别看 G1 和 ZGC 把哪几件挪出了停顿,最后看它们各自为此付了什么代价。

先知道这些

活对象怎么认

从 GC Roots(线程栈上的局部变量、类的静态变量等)出发,顺着引用能走到的就是活的,其余都是垃圾。

STW 和安全点

要停下业务线程,得等每个线程走到“安全点”。一次停顿 = 等线程到齐 + GC 在停顿里干的活。

复制和整理

复制是把活对象搬到一块空地,原来那块整块作废;整理是原地往一头挤。两种都会改变对象地址。新生代的复制见上一页。

屏障

JIT 在每次写引用字段(写屏障)或读引用字段(读屏障)时额外插进去的几条指令,让 GC 知道业务线程动了哪些引用。

0回收的三件事,谁要停下来做

红色 = 停下业务线程做,绿色 = 和业务线程同时做 ① 找活对象(标记) ② 挪对象(复制 / 整理) ③ 改引用到新地址 Parallel 停 停:整个堆一起挪 停 CMS 并发(另有两次短停) 不挪 → 越用越碎 不需要 G1 并发(另有几次短停) 停:只挪选中的 Region 停:挪完一起改 ZGC 并发 并发 并发:读屏障顺手改
整页就围着这张表转。G1 把“标记”挪出了停顿,又把“挪对象”切成小份;ZGC 把三件事全挪出了停顿。挪出停顿的那部分,要靠屏障来保证业务线程同时改引用时不出错。

1G1:堆切成 Region,一次只回收一部分G1 = Garbage-First

一次 Young GC(停顿):只处理新生代的 Region ① 之前 ② 停顿中:复制活对象 ③ 之后 E O O E E O O E H H E S O O E E O O E O E O O E E O O E H H E S O O E S E O O E O O O O O O H H O O S O O O O E、S、O 不必挨在一起 黑点 = 活对象,只复制它们 原来的 E、S 整块变空,没有碎片 E 新生代 Eden S Survivor O 老年代 H 大对象(≥ 半个 Region) 空闲
每个 Region 一样大,是 1~32 MB 之间的 2 的幂,默认按“堆分成约 2048 块”来定(JDK 17 源码 heapRegionBounds.hpp;18 起手动最大可设到 512 MB)。一块 Region 当 Eden 还是当老年代,随时可以换,所以新生代多大可以每次都重新定。
  • ➊复制完,原来那批 Region 整块变成空闲:挪对象顺便就整理了,不会像 CMS 那样越用越碎。
  • ➋停顿里要干的活 ≈ 复制多少活对象。新生代里的对象大多“朝生夕死”,活下来的很少,所以 Young GC 一般很快。
  • ➌还差一个问题:要判断 E 里哪个对象活着,光从 GC Roots 出发不够,老年代里的对象也可能引用它。总不能每次 Young GC 都把整个老年代扫一遍,下一节就解决这个问题。

2记忆集:不扫老年代,也知道谁引用了新生代

order.item = item 这一句之后 老年代 Region O3 order 每 512 字节一张“卡”,橙色这张被记脏了 新生代 Region E7 item order.item E7 的记忆集:{ O3 第 5 张卡 }
记忆集(Remembered Set,RSet)是每个 Region 记的一张小账:“老年代哪些卡里有指向我的引用”。卡表把堆按 512 字节一张卡来编号(Oracle G1 调优文档)。
  • ➊业务线程执行 order.item = item 时,JIT 插入的写屏障检查:这是不是跨 Region 的引用?是的话,就把 order 所在的那张卡记成“脏卡”。
  • ➋后台的优化线程(refinement)慢慢处理脏卡,把“O3 第 5 张卡”记进 E7 的记忆集。这部分不占停顿。
  • ➌Young GC 时,起点 = GC Roots + 新生代各 Region 记忆集里的卡。只扫这些卡,不扫整个老年代。代价是:每次写引用都多几条指令,记忆集本身也占内存(常见占堆的几个百分点)。

3老年代怎么收:并发标记 + 混合回收

老年代占了堆的多少(示意) 只做 Young GC 并发标记 混合回收 ×(最多约 8 次) 只做 Young GC 045%100% IHOP:默认 45%,之后自适应 并发开始 (借一次 Young GC) 重新标记、清理 (两次短停顿) 每次 = 新生代 + 几块垃圾最多的老 Region 每次 Young GC 都有对象晋升
红竖线是这一圈里的停顿,中间那一大段并发标记不停业务线程。阶段名用的是 Oracle G1 调优文档里的叫法:Concurrent Start、Remark、Cleanup、Mixed。
标记完:每块老 Region 里活对象占多少 85%:超过不收 8% 15% 30% 45% 60% 70% 先收这两块 后面几次混合回收再收
“Garbage First”就是这个意思:垃圾越多的 Region 越先收,复制的活对象少,回收的空间多。
停顿 ≈ 固定开销 + 要复制的活对象 ÷ 复制速度

G1 记下每次停顿里各部分花了多久,用来预测下一次。目标是 200 ms(默认),它就把新生代调到“复制它活下来的对象大约要 200 ms”那么大;混合回收也一样,按预测一块块往回收集合里加老 Region,时间快用完就停。

所以 G1 的停顿和堆多大关系不大,和这一次要复制多少活对象有关。停顿目标是 G1 手里唯一的旋钮,它拧的是“一次挪多少”。

4ZGC:挪对象时不停业务线程Z Garbage Collector

难点在这里:GC 线程把对象 A 复制到新地址后,业务线程手里、别的对象字段里,还到处是 A 的旧地址。G1 的办法是在停顿里把它们一口气改完。ZGC 不停,它让每一次读引用都先检查一下。

染色指针 + 读屏障 对象地址 颜色位 一个 64 位的引用(分代 ZGC) 记着这个引用是在哪一轮标记、搬迁之后写下的 Object o = order.a; 读屏障:比颜色 颜色对:直接用 绝大多数情况,几条指令 颜色旧:慢路径,查转发表拿新地址(必要时自己搬) 再把新地址和新颜色写回 order.a,下次就走快路径
颜色位放在引用自己身上,不需要另查一张表,所以快路径只是几条比较指令。JDK 17 的不分代 ZGC 把颜色放在地址高位,并把同一块物理内存映射到三个虚拟地址,ps、top 看到的内存因此约是实际的 3 倍;21 起的分代 ZGC 把颜色挪到低位,由屏障剥掉,不再多重映射(JEP 439)。
① 选中 P1,要把它清空 页 P1 页 P2 A B ✕ order.a order.a 指着 P1 里的 A ② GC 线程把 A 复制到 P2 页 P1 页 P2 A B ✕ A′ 转发表 A→A′ order.a order.a 还是旧地址,颜色是旧的 ③ 业务线程读 order.a 页 P1 页 P2 A B ✕ A′ 转发表 A→A′ order.a 读屏障:颜色旧 → 查转发表 拿到 A′,顺手改写 order.a ④ P1 搬空,整页释放 页 P1 页 P2 A′ B′ 整页空闲 order.a 活对象都搬走了
第 ③ 步叫“自愈”:字段被读到一次就修好了,之后不再走慢路径。没被读到的旧引用也不要紧,下一轮标记顺着引用走一遍时会顺手改掉。如果业务线程先读到了一个还没搬的对象,它会自己把对象搬过去,不用等 GC 线程。
ZGC 一轮回收:三次极短停顿,其余全并发 业务线程 标记开始 标记结束 搬迁开始 GC 线程 并发标记 并发选页 并发搬迁 三次停顿只做和堆大小无关的一点点事(同步状态、处理少量根),⑤ 里本机实测每次不超过 0.19 ms 堆 活对象 回收期间新分配的 余量 这一轮还没收完,新对象只能往余量里放;余量用光,分配内存的线程就被卡住等 GC:Allocation Stall 分代 ZGC 大多数轮次只收新生代(活对象少,一轮很快),需要的余量小得多;老年代偶尔才做一次全量回收
ZGC 的停顿短,不等于业务线程从不等待:停顿换成了“回收要跟得上分配”这个约束。跟不上时的等待不叫停顿,GC 日志里写作 Allocation Stall,⑤ 里就撞上了。

5为什么这样设计:挪对象时,别人手里还拿着旧地址

G1:大家先停下,挪完再走停顿里搬家

复制改引用 停顿长度 ≈ 这次要挪的活对象
  • 读引用没有额外开销,屏障只加在写引用上
  • 能用 4 字节的压缩指针,同样的对象占内存少
  • 停顿和要复制的活对象成正比,只能“少挪一点”,压不到 1 ms

ZGC:边跑边挪,每次读都检查并发搬家

紫色小块 = 读屏障;底下是并发搬迁的 GC 线程
  • 停顿和堆多大、活对象多少都无关
  • 每次读引用都多几条指令,吞吐会低一些
  • GC 线程和业务线程抢 CPU;回收期间还要内存余量
  • 指针里要放颜色,不能用 4 字节的压缩指针

决定胜负的约束:对象被挪走的那一刻,别的线程手里可能还拿着旧地址。要么让所有人停下,一次改完(G1,停顿 ∝ 挪了多少);要么让每一次读引用都先检查一下(ZGC,停顿和堆无关,但每次读都要付一点代价)。再往前一步的两种设计都被放弃了:一次挪整个堆,停顿随堆变长,就是 Parallel;干脆不挪,就会碎,就是 CMS。

最少的用法

⌘启动参数参数怎么配、哪些别碰,放到第⑥步
# G1:JDK 9 起默认,可以不写;停顿目标默认 200 ms
java -XX:+UseG1GC -Xms4g -Xmx4g -XX:MaxGCPauseMillis=200 -jar app.jar

# ZGC:JDK 21、22 要加 ZGenerational 才是分代;23 起默认就是分代,不用再写
java -XX:+UseZGC -XX:+ZGenerational -Xms8g -Xmx8g -jar app.jar

# GC 日志(JDK 9 起的统一日志),两种 GC 通用
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20m

⑤亲眼看到:同一段程序,换四种配置跑

程序留住 400 MB 的“缓存”,不停制造临时对象,另起一个探针线程每 1 ms 醒一次:醒晚了多少,就是这段时间被停住了多久。GC 日志说的停顿和业务线程感受到的卡顿,可以对着看。

1GcDemo.javaJDK 8 写法,不需要任何依赖
import java.util.Arrays;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.locks.LockSupport;

/** 同一段程序换不同 GC 跑:留住一批“老对象”,不停制造“朝生夕死”的临时对象,
 *  另起一个线程每 1 ms 醒一次,醒晚了多少,就是这段时间被 GC 停住的时间。 */
public class GcDemo {
    static final int LIVE_MB = Integer.getInteger("liveMB", 400);   // 常驻的老对象
    static final int SECONDS = Integer.getInteger("seconds", 20);
    static volatile boolean running = true;
    static volatile Object sink;                                     // 防止临时对象被优化掉

    public static void main(String[] args) throws Exception {
        // 1. 常驻数据:LIVE_MB 个 1 MB,切成 1 KB 的小数组,像一个本地缓存
        final byte[][] cache = new byte[LIVE_MB * 1024][];
        for (int i = 0; i < cache.length; i++) cache[i] = new byte[1024];

        // 2. 探针线程:每 1 ms 醒一次,记下每次醒晚了多少
        final long[] late = new long[SECONDS * 2000];
        final int[] n = {0};
        Thread probe = new Thread(() -> {
            while (running && n[0] < late.length) {
                long t0 = System.nanoTime();
                LockSupport.parkNanos(1_000_000);
                late[n[0]++] = System.nanoTime() - t0 - 1_000_000;
            }
        });
        probe.setDaemon(true);
        probe.start();

        // 3. 业务线程:不停分配临时对象,偶尔替换一条缓存(老对象也会变成垃圾)
        long allocated = 0, end = System.currentTimeMillis() + SECONDS * 1000L;
        ThreadLocalRandom r = ThreadLocalRandom.current();
        while (System.currentTimeMillis() < end) {
            for (int k = 0; k < 1000; k++) {
                sink = new byte[r.nextInt(64, 512)];                     // 临时对象,马上没用
                allocated += 300;
            }
            cache[r.nextInt(cache.length)] = new byte[1024];             // 老对象换新
            allocated += 1024;
        }
        running = false;
        probe.join();

        long[] a = Arrays.copyOf(late, n[0]);
        Arrays.sort(a);
        System.out.printf("分配了 %.1f GB,探针醒来 %d 次%n", allocated / 1e9, a.length);
        System.out.printf("醒晚了:p50 %.2f ms,p99 %.2f ms,p99.9 %.2f ms,最长 %.1f ms%n",
            a[a.length / 2] / 1e6, a[(int) (a.length * 0.99)] / 1e6,
            a[(int) (a.length * 0.999)] / 1e6, a[a.length - 1] / 1e6);
    }
}
2编译、运行每次跑 20 秒;本机是 2 核 ARM 云主机、JDK 17.0.20.1
javac --release 8 -encoding UTF-8 GcDemo.java
java -XX:+UseParallelGC -Xms1g -Xmx1g -Xlog:gc*:file=Parallel.log GcDemo
java -XX:+UseG1GC       -Xms1g -Xmx1g -Xlog:gc*:file=G1.log       GcDemo
java -XX:+UseZGC        -Xms1g -Xmx1g -Xlog:gc*:file=Z.log        GcDemo
java -XX:+UseZGC        -Xms3g -Xmx3g -Xlog:gc*:file=Z3.log       GcDemo   # 只把堆调大

# 从日志里挑停顿:
grep -E '\[gc *\] GC\([0-9]+\) Pause' G1.log | tail
grep -E 'Pause (Mark|Relocate)|Allocation Stall' Z.log | tail

Parallel · 1 GB

GC 日志里最长停顿 实测
104 ms
  • 394 次 Young GC,2 次 Full GC(84、104 ms)
  • 探针最长醒晚 103.5 ms
  • 20 秒分配 118 GB

G1 · 1 GB

GC 日志里最长停顿 实测
40 ms
  • 358 次停顿,最长的是混合回收
  • Young GC 中位数 4.6 ms;38 轮并发标记,重新标记、清理最长 1.2 ms
  • 探针最长 39.2 ms;分配 107 GB

ZGC · 1 GB

GC 停顿最长 0.19 ms,但是 实测
卡了 9.8 秒
  • 主线程 Allocation Stall 203 次,单次最长 86 ms
  • 198 轮回收,几乎一轮接一轮
  • 探针不分配内存,最长只晚 5.2 ms;分配只有 63 GB

ZGC · 3 GB

GC 停顿最长 0.023 ms 实测
0 次卡住
  • Allocation Stall 0 次
  • 95 轮回收
  • 探针最长 5.3 ms;分配 123 GB,四组里最多
[14.253s] GC(280) Pause Full (Ergonomics) 1006M->408M(1008M) 103.939ms           ← Parallel:整理整个堆
[6.602s]  GC(94) Pause Young (Mixed) (G1 Evacuation Pause) 510M->465M(1024M) 9.100ms  ← G1:新生代 + 几块老 Region
[5.413s]  GC(49) Pause Mark Start 0.005ms                                       ← ZGC 的三次停顿
[5.480s]  GC(49) Pause Mark End 0.011ms
[5.485s]  GC(49) Pause Relocate Start 0.009ms
[5.491s]  GC(49) Garbage Collection (Allocation Stall) 946M(92%)->600M(59%)       ← 1 GB 堆:这一轮是被“卡住”逼出来的
[14.043s] Allocation Stall (main) 84.801ms                                     ← 主线程分配时等了 85 ms

探针线程只睡觉、不分配内存,所以 ZGC 的 Allocation Stall 卡不到它;它晚醒的那几毫秒,主要是 2 个核被业务线程和 GC 线程占满、轮不到它。每组只跑了一次,数字会有波动,结论方向是稳定的。

到这里已经能交差了:会看 GC 日志里每次停顿多长、是哪种停顿;知道 G1 的停顿主要来自 Young GC 和混合回收,知道 ZGC 要盯的不是停顿,而是 Allocation Stall。
已知还没处理的细节
  • ZGC 要留多少余量、怎么发现 Allocation Stall → ⑥ 坑 1
  • G1 的停顿目标该不该调小、为什么别设 -Xmn → ⑥ 坑 2、3
  • 容器里没写 GC 参数,实际用的可能是 Serial → ⑥ 坑 4
  • ZGC 不能用压缩指针、ps 里内存显示 3 倍 → ⑥ 坑 5

⑥细节:坑、规范、更深一层

每个坑旁边的小标签点一下,就跳回 ④ 里决定它的那条原理。

第一圈

注意事项

坑 1ZGC 的堆给小了:GC 停顿很漂亮,接口却在卡← ④ 4 余量⑤ 实测
现象
GC 日志里停顿全是微秒级,接口却偶发几十到上百毫秒的慢请求;日志里有 Allocation Stall。
根源
并发回收期间,新对象只能放进余量;回收跟不上分配,分配内存的线程就被挂起等 GC。
做法
堆比用 G1 时给得宽一些;告警里盯 Allocation Stall(JFR 里是 jdk.ZAllocationStall 事件);能升 21 就用分代 ZGC,同样的堆能撑住高得多的分配速率。
坑 2G1 配了 -Xmn 或 NewRatio,停顿目标就不灵了← ④ 3 停顿预测
现象
设了 MaxGCPauseMillis=100,Young GC 还是经常两三百毫秒。
根源
G1 满足停顿目标,靠的是每次重新决定新生代用几块 Region。新生代大小被写死,它就没有旋钮可拧了。
做法
删掉 -Xmn、-XX:NewRatio、-XX:NewSize;只留 -Xmx 和停顿目标(Oracle G1 调优文档原话就是“避免用 -Xmn 限制新生代大小”)。
坑 3停顿目标设得很小,比如 10 ms← ④ 3 停顿预测
现象
Young GC 次数成倍增加,吞吐下降,老年代涨得更快,混合回收更频繁,甚至出现 Full GC。
根源
目标越小,每次能挪的越少,新生代就被压小:GC 更频繁,对象来不及死就被晋升到老年代。
做法
先用默认的 200 ms,按 SLA 最多调到几十毫秒;要 10 ms 以内,换 ZGC,而不是硬拧 G1。
坑 4小容器里没写 GC 参数,实际用的是 Serial历史原因本机核对
现象
升级到 17 以为默认是 G1,日志第一行却是 Using Serial,停顿很长。
根源
JDK 9~26 只在“服务器级机器”上默认 G1:至少 2 个 CPU、内存不少于 1792 MB(源码 os::is_server_class_machine)。容器只给 1 核时,JVM 就选 Serial。本机用 -XX:ActiveProcessorCount=1 核对过。JDK 27 起取消这个判断,默认一律 G1(JEP 523)。
做法
启动参数里显式写 -XX:+UseG1GC;上线后看一眼 GC 日志第一行 Using …。
坑 5换成 ZGC 后,内存比预想的多← ④ 4 染色指针本机核对
现象
同样的数据,堆用量明显变多;JDK 17 上 ps、top 显示的内存是堆的好几倍。
根源
颜色位要放在 64 位指针里,ZGC 不能用 4 字节的压缩指针(本机 17 上 UseCompressedOops 自动变成 false),引用多的对象图会胀;17 的不分代 ZGC 还把同一块内存映射了 3 次,ps 会重复计算。
做法
按“引用变成 8 字节”重新估堆;看内存以 NMT 和容器的内存统计为准,别信 ps 的 RSS;21 起的分代 ZGC 不再多重映射。
坑 6G1 里大对象多,Region 被浪费、GC 被频繁触发← ④ 1 Region
现象
堆没满就频繁 GC,日志里有 Humongous 字样。
根源
不小于半个 Region 的对象要占一整串连续的 Region,最后一块用不完的部分也浪费掉,详见上一页坑 4。
做法
避免一次性的大数组、大字符串;确实需要时调大 -XX:G1HeapRegionSize。
第二圈

使用规范

先别调参:只设 -Xms = -Xmx,必要时再加停顿目标

G1 的自适应就是为“少配参数”设计的。

从网上抄来的一堆参数互相打架,G1 丢了自适应。

GC 日志一律打开,滚动保存

参数见 ④ 最后的代码块,开销很小。

出了问题没有证据,只能猜。

容器里显式写 GC,用 -XX:MaxRAMPercentage 定堆

堆和容器上限按比例走,换规格不用改参数。

1 核小容器悄悄用了 Serial;堆和容器上限对不上被杀。

换 ZGC 前先压测,对比三样:p99、CPU、内存

拿同样的流量跑 G1 和 ZGC,日志里数 Allocation Stall。

停顿变短了,却没算多花的 CPU 和内存。

ZGC 的开关按版本写

21、22 要加 -XX:+ZGenerational;23 起默认分代,不用再写;24 起不分代模式已删除。

在 21 上用了老的不分代模式而不自知。

监控看最长停顿和次数,不看平均

盯三样:最长停顿、GC 时间占比、G1 的 Full GC 次数或 ZGC 的 Allocation Stall 次数。

平均停顿很好看,偶发的大停顿被平均掉了。

第三圈

更深一层

并发标记为什么不会漏掉活对象:SATB

A B C ② B.g = null ① A.f = C 黑 = 扫完不再回头,灰 = 正在扫,白 = 还没扫到
  • 两件事同时发生才会漏:黑对象新指向了白对象,而白对象原来的路径(从灰对象出发)被切断了。
  • SATB(标记开始时的快照):在“写引用”之前用写屏障记下被覆盖的旧值,这里是 B.g 原来指向的 C,把它当活的处理。代价是标记期间刚死的对象这一轮收不掉(浮动垃圾)。
  • CMS 用的是另一种:记下新写入的引用,重新标记阶段再扫一遍,所以 CMS 的重新标记停顿更长。G1 和分代 ZGC 用 SATB(分代 ZGC 见 JEP 439)。

混合回收的几个参数,为什么是这几个值

  • G1MixedGCLiveThresholdPercent=85:活对象超过 85% 的 Region 不收,复制成本高、腾出的空间少。
  • G1HeapWastePercent=5:剩下的候选 Region 加起来能回收的空间不到堆的 5%,就提前结束混合回收,不值得再挪。
  • G1MixedGCCountTarget=8:候选 Region 大约分 8 次收完,每次只挪一部分,把停顿摊开。
  • G1ReservePercent=10:留 10% 的堆不用,给复制时的目标 Region 兜底,减少复制失败。
  • 以上默认值都来自 JDK 17 源码 g1_globals.hpp。它们都在回答同一个问题:多挪一块,值不值这点停顿。一般不用动。

分代 ZGC 到底改了什么(JEP 439)

  • 堆分成新生代和老年代,可以分别回收。新生代的对象大多很快死掉,小回收只需要处理活下来的那一点。
  • 颜色位从地址高位挪到低位,不再把同一块内存映射三次,ps 不再把内存算成 3 倍,也让堆能超过 16 TB。
  • 读屏障只负责剥掉颜色、修正指向已搬走对象的引用;标记和维护记忆集改由写屏障负责。
  • 记忆集是每个老年代区域一张精确的位图,每个可能存引用的字段对应一位,两张轮流用。
  • 搬迁可以就地进行,新生代回收不需要额外的空闲内存。

停顿不一定是 GC 在干活

  • 停顿 = 等所有线程走到安全点 + GC 真正干活。一个线程在跑一段很长、没有安全点检查的循环,其他线程就都得陪它等。
  • ZGC 把干活的部分压到了微秒级,等线程到齐的时间就成了大头。看到“GC 停顿很短,但应用还是卡”,可以用 -Xlog:safepoint 看到达安全点花了多久。
  • ⑤ 里 ZGC 的探针仍然晚了 5 ms,原因又是另一种:2 个核被业务线程和 GC 线程占满,探针线程排不上 CPU。

版本差异

JDK变化对你意味着什么
8默认 Parallel;G1 可用,但 Full GC 是单线程8 上用 G1,一旦 Full GC 会很慢
9G1 成为默认(JEP 248);CMS 被标记废弃(JEP 291)从 8 升上来,默认 GC 变了
10G1 的 Full GC 改成多线程(JEP 307)G1 的兜底没那么可怕了
11ZGC 加入,实验性,只支持 Linux x64(JEP 333)生产别用
12G1 空闲时把内存还给系统(JEP 346);混合回收可以中途停下(JEP 344);Shenandoah 加入(JEP 189)G1 进程占的内存会随负载回落
14CMS 删除(JEP 363)还写着 -XX:+UseConcMarkSweepGC 的要改掉
15ZGC 正式可用(JEP 377)17 上可以用,但是不分代
16ZGC 并发处理线程栈(JEP 376)停顿不再随线程数变长
18G1 的 Region 最大可设到 512 MB(自动选仍不超过 32 MB)大对象多时多一个选择
21分代 ZGC(JEP 439),要加 -XX:+ZGenerational想用 ZGC,从 21 起才划算
23ZGC 默认就是分代(JEP 474)不用再写 ZGenerational
24删除不分代 ZGC(JEP 490)只剩分代一种
26G1 用两张卡表,写屏障从约 50 条指令减到 12 条(x64),吞吐提升 5%~15%(JEP 522)和 ZGC 的吞吐差距再拉开一点
27没指定 GC 时一律用 G1,不再看 CPU 和内存(JEP 523)坑 4 从这一版起消失

G1 和 ZGC 实验台先猜,再运行

堆大小
活对象占堆
分配速率
新生代存活率
G1 停顿目标
选一个剧情先猜,再点“运行”;之后拖滑块,结果会跟着变。
先选一个剧情,猜一下,再点“运行”。

这是估算模型,只为看清趋势,数字别当真。假设:停顿里复制活对象 2 GB/s,Full GC 整理 2 GB/s,并发线程处理 2 GB/s 并占约四分之一的核,新生代存活对象约三成最终晋升。G1 把新生代调到“复制它的活对象正好用完停顿目标”,但最多用一半余量;ZGC 要求一轮回收期间的新分配放得进余量,而且 GC 线程忙得过来,不满足时就算出业务线程每秒被卡住多久。模型只看稳态,不算流量尖峰。

下一圈

  • Shenandoah 的并发搬迁和 ZGC 有什么不同
  • GC 日志和 JFR 深入:看停顿分布,找出最长的那一次是谁造成的
  • 安全点和“等线程到齐”的时间,为什么有时停顿不是 GC 造成的
  • 软、弱、虚引用和 Cleaner,在 GC 里是怎么被处理的
  • TLAB 和逃逸分析:为什么大多数分配不用加锁,有的对象根本不上堆
  • G1 源码:回收集合怎么挑、停顿怎么预测

↺回到第⑤步:改 GcDemo

预期是按 ④ 推出来的,没有在本机跑过,你跑出来的数字应该和预期方向一致。

练习 1 · 活对象少一点,ZGC 还卡吗

ZGC、1 GB 堆那组,加 -DliveMB=100 再跑。先猜 Allocation Stall 会怎样。

预期:大幅减少,很可能一次都没有:余量从 600 MB 变成约 900 MB,而且每轮要标记的活对象只剩四分之一,一轮做得更快。

用到:坑 1

练习 2 · 把 G1 的停顿目标拧到 10 ms

G1 那组加 -XX:MaxGCPauseMillis=10,数一下 Young GC 的次数,再看最长停顿和总分配量。

预期:Young GC 次数明显变多,大部分停顿变短,但混合回收不一定每次都压得到 10 ms;总分配量会少一些。

用到:坑 3

练习 3 · 假装在 1 核容器里

不写任何 GC 参数,加 -XX:ActiveProcessorCount=1 -Xlog:gc 跑,看日志第一行;再加上 -XX:+UseG1GC 跑一次。

预期:第一次是 Using Serial,最长停顿比 G1 长;显式写了以后是 Using G1。

用到:坑 4

自测

1. 用一句话分别说出 G1 和 ZGC 的精华。

G1 把堆切成 Region,在停顿里只挪垃圾最多的一部分,靠“一次挪多少”把停顿控制在目标以内;ZGC 用染色指针加读屏障,让挪对象也和业务线程并发,停顿和堆大小无关,代价是 CPU、内存余量和屏障开销。

从 ④ 0 的那张表来。

2. Parallel 和 CMS 分别疼在哪?

Parallel 三件事全停下来做,Full GC 要整理整个堆,停顿随活对象增多而变长;CMS 标记并发了但不挪对象,老年代会碎,碎到放不下就退化成单线程 Full GC。

见 ②。

3. ZGC 的停顿都不到 1 ms,为什么接口还会卡上百毫秒?

回收是并发的,回收期间新对象只能放进余量;余量用光、回收还没做完,分配内存的线程就被挂起(Allocation Stall)。这段等待不算 GC 停顿,但业务线程一样被卡住了。⑤ 里 1 GB 堆就卡了 203 次。

从 ④ 4 的“余量”推出来。

4. 预测:G1 的堆从 8 GB 加到 16 GB,活对象和流量都不变,最长停顿会翻倍吗?

不会。停顿约等于这次要复制的活对象除以复制速度,G1 按停顿目标来定新生代多大,不会因为堆大了就一次挪更多。堆大了以后 GC 次数更少、余量更多,混合回收更从容,最长停顿基本不变,甚至更稳。

从 ④ 3 的停顿公式推出来。

5. 预测:同样用不分代 ZGC,堆 8 GB 不变,活对象从 2 GB 涨到 6 GB,会发生什么?换成分代 ZGC 呢?

不分代时,每一轮都要把全部活对象标一遍,活对象变成 3 倍,一轮的时间也变长;同时余量从 6 GB 缩到 2 GB。一轮更慢、余量更小,两头一夹,很容易出现 Allocation Stall。分代 ZGC 的小回收只处理新生代里活下来的对象,一轮短得多,需要的余量也小,所以撑得住得多。可以在实验台里把堆设成 8 GB、分配 1 GB/s,再把活对象从 25% 拖到 75% 看看。

从 ④ 4 的余量,以及分代带来的“每轮只看新生代”推出来。

6. G1 为什么不也加上读屏障,做到并发搬迁?

读引用比写引用频繁得多,读屏障对吞吐的影响更大;要做并发搬迁,还得在指针里存状态,也就没法用 4 字节的压缩指针。G1 选的是“停顿可控、吞吐好、内存省”这个平衡点,把极致低延迟留给了 ZGC。

从 ④ 5 的设计对比推出来。