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)停顿。停顿期间进来的请求只能排队,停多久,它们就慢多久。
停顿要短
最长一次停顿要比超时时间短得多,否则一停就是一批超时。
停顿要可预期
不能堆越大停得越久,也不能偶尔来一次几秒的 Full GC。
别换太多东西
为了停顿短,多花的 CPU 和内存要算得过账。
②没有它会怎样:老收集器要么全停,要么不挪
JDK 8 默认的 Parallel,和后来被删掉的 CMS,分别代表两条老路。
- Parallel:回收时业务线程全停。Full GC 要把整个老年代标记、整理一遍,堆越大、活对象越多,停得越久。
- CMS:标记和清除都和业务线程并发了,但它不挪对象,老年代越用越碎。碎到放不下新对象时,就退化成单线程的 Full GC,比 Parallel 还慢。
- 缺的那一块:既要挪对象(不碎),又不能一次挪整个堆(停顿不随堆变大)。G1 的办法是“每次只挪一部分”,ZGC 的办法是“挪的时候不停”。
不懂这一点时的典型误用:以为“堆调大就能少停”。对 Parallel,堆大了 Full GC 次数少了,但每次更长;对 G1,停顿主要看每次要复制多少活对象,和堆多大关系不大;对 ZGC,堆调大反而是对的,因为它要靠余量撑过并发回收那段时间(见 ④ ZGC)。同一个参数,换了 GC,效果正好相反。
③相邻方案:HotSpot 里的六种收集器
区别就在一件事上:找活对象、挪对象、改引用,这三步里哪些要停下业务线程来做。
| 收集器 | 怎么做 | 停顿 | 代价 | 现状 |
|---|---|---|---|---|
| Serial | 单线程,全部 STW | 长 | 几乎没有额外开销 | JDK 9~26 在 1 个 CPU 或内存不到 1792 MB 的机器上默认用它;27 起默认一律 G1 |
| Parallel | 多线程,全部 STW | Young 短,Full GC 长(大堆秒级) | 吞吐最好之一 | JDK 8 默认;适合只看吞吐的批处理 |
| CMS | 并发标记、并发清除,不整理 | 平时短,退化时很长 | 碎片 | 9 废弃,14 删除 |
| G1 | 堆切成 Region,并发标记;在停顿里只挪一部分 Region | 有目标,默认 200 ms,常见几十 ms | 写屏障、记忆集占一些内存和 CPU | JDK 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回收的三件事,谁要停下来做
1G1:堆切成 Region,一次只回收一部分G1 = Garbage-First
heapRegionBounds.hpp;18 起手动最大可设到 512 MB)。一块 Region 当 Eden 还是当老年代,随时可以换,所以新生代多大可以每次都重新定。- ➊复制完,原来那批 Region 整块变成空闲:挪对象顺便就整理了,不会像 CMS 那样越用越碎。
- ➋停顿里要干的活 ≈ 复制多少活对象。新生代里的对象大多“朝生夕死”,活下来的很少,所以 Young GC 一般很快。
- ➌还差一个问题:要判断 E 里哪个对象活着,光从 GC Roots 出发不够,老年代里的对象也可能引用它。总不能每次 Young GC 都把整个老年代扫一遍,下一节就解决这个问题。
2记忆集:不扫老年代,也知道谁引用了新生代
- ➊业务线程执行
order.item = item时,JIT 插入的写屏障检查:这是不是跨 Region 的引用?是的话,就把order所在的那张卡记成“脏卡”。 - ➋后台的优化线程(refinement)慢慢处理脏卡,把“O3 第 5 张卡”记进 E7 的记忆集。这部分不占停顿。
- ➌Young GC 时,起点 = GC Roots + 新生代各 Region 记忆集里的卡。只扫这些卡,不扫整个老年代。代价是:每次写引用都多几条指令,记忆集本身也占内存(常见占堆的几个百分点)。
3老年代怎么收:并发标记 + 混合回收
G1 记下每次停顿里各部分花了多久,用来预测下一次。目标是 200 ms(默认),它就把新生代调到“复制它活下来的对象大约要 200 ms”那么大;混合回收也一样,按预测一块块往回收集合里加老 Region,时间快用完就停。
所以 G1 的停顿和堆多大关系不大,和这一次要复制多少活对象有关。停顿目标是 G1 手里唯一的旋钮,它拧的是“一次挪多少”。
4ZGC:挪对象时不停业务线程Z Garbage Collector
难点在这里:GC 线程把对象 A 复制到新地址后,业务线程手里、别的对象字段里,还到处是 A 的旧地址。G1 的办法是在停顿里把它们一口气改完。ZGC 不停,它让每一次读引用都先检查一下。
ps、top 看到的内存因此约是实际的 3 倍;21 起的分代 ZGC 把颜色挪到低位,由屏障剥掉,不再多重映射(JEP 439)。5为什么这样设计:挪对象时,别人手里还拿着旧地址
G1:大家先停下,挪完再走停顿里搬家
- 读引用没有额外开销,屏障只加在写引用上
- 能用 4 字节的压缩指针,同样的对象占内存少
- 停顿和要复制的活对象成正比,只能“少挪一点”,压不到 1 ms
ZGC:边跑边挪,每次读都检查并发搬家
- 停顿和堆多大、活对象多少都无关
- 每次读引用都多几条指令,吞吐会低一些
- 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 日志说的停顿和业务线程感受到的卡顿,可以对着看。
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);
}
}
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 日志里最长停顿 实测- 394 次 Young GC,2 次 Full GC(84、104 ms)
- 探针最长醒晚 103.5 ms
- 20 秒分配 118 GB
G1 · 1 GB
GC 日志里最长停顿 实测- 358 次停顿,最长的是混合回收
- Young GC 中位数 4.6 ms;38 轮并发标记,重新标记、清理最长 1.2 ms
- 探针最长 39.2 ms;分配 107 GB
ZGC · 1 GB
GC 停顿最长 0.19 ms,但是 实测- 主线程 Allocation Stall 203 次,单次最长 86 ms
- 198 轮回收,几乎一轮接一轮
- 探针不分配内存,最长只晚 5.2 ms;分配只有 63 GB
ZGC · 3 GB
GC 停顿最长 0.023 ms 实测- 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
- Parallel 的最长停顿就是 Full GC,探针也正好晚了 103.5 ms,两边对得上。对应 ②。
- G1 也有混合回收,最长 40 ms,Full GC 一次都没有;绝大多数停顿是几毫秒的 Young GC。对应 ④ 3。
- ZGC 在 1 GB 堆里,GC 自己的停顿只有几微秒,可主线程因为分配不到内存被卡了 203 次、共 9.8 秒,吞吐只剩一半左右。2 个核既要跑业务又要跑并发回收,400 MB 活对象之外只剩 600 MB 余量,回收跟不上分配。对应 ④ 4 的余量。
- 只把堆调到 3 GB,卡住次数变成 0,分配量反而是四组里最多的。对 ZGC,余量就是延迟。
探针线程只睡觉、不分配内存,所以 ZGC 的 Allocation Stall 卡不到它;它晚醒的那几毫秒,主要是 2 个核被业务线程和 GC 线程占满、轮不到它。每组只跑了一次,数字会有波动,结论方向是稳定的。
⑥细节:坑、规范、更深一层
每个坑旁边的小标签点一下,就跳回 ④ 里决定它的那条原理。
注意事项
- 现象
- GC 日志里停顿全是微秒级,接口却偶发几十到上百毫秒的慢请求;日志里有
Allocation Stall。 - 根源
- 并发回收期间,新对象只能放进余量;回收跟不上分配,分配内存的线程就被挂起等 GC。
- 做法
- 堆比用 G1 时给得宽一些;告警里盯
Allocation Stall(JFR 里是jdk.ZAllocationStall事件);能升 21 就用分代 ZGC,同样的堆能撑住高得多的分配速率。
-Xmn 或 NewRatio,停顿目标就不灵了← ④ 3 停顿预测
- 现象
- 设了
MaxGCPauseMillis=100,Young GC 还是经常两三百毫秒。 - 根源
- G1 满足停顿目标,靠的是每次重新决定新生代用几块 Region。新生代大小被写死,它就没有旋钮可拧了。
- 做法
- 删掉
-Xmn、-XX:NewRatio、-XX:NewSize;只留-Xmx和停顿目标(Oracle G1 调优文档原话就是“避免用 -Xmn 限制新生代大小”)。
- 现象
- Young GC 次数成倍增加,吞吐下降,老年代涨得更快,混合回收更频繁,甚至出现 Full GC。
- 根源
- 目标越小,每次能挪的越少,新生代就被压小:GC 更频繁,对象来不及死就被晋升到老年代。
- 做法
- 先用默认的 200 ms,按 SLA 最多调到几十毫秒;要 10 ms 以内,换 ZGC,而不是硬拧 G1。
- 现象
- 升级到 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 …。
- 现象
- 同样的数据,堆用量明显变多;JDK 17 上
ps、top显示的内存是堆的好几倍。 - 根源
- 颜色位要放在 64 位指针里,ZGC 不能用 4 字节的压缩指针(本机 17 上
UseCompressedOops自动变成 false),引用多的对象图会胀;17 的不分代 ZGC 还把同一块内存映射了 3 次,ps会重复计算。 - 做法
- 按“引用变成 8 字节”重新估堆;看内存以 NMT 和容器的内存统计为准,别信
ps的 RSS;21 起的分代 ZGC 不再多重映射。
- 现象
- 堆没满就频繁 GC,日志里有
Humongous字样。 - 根源
- 不小于半个 Region 的对象要占一整串连续的 Region,最后一块用不完的部分也浪费掉,详见上一页坑 4。
- 做法
- 避免一次性的大数组、大字符串;确实需要时调大
-XX:G1HeapRegionSize。
使用规范
-Xms = -Xmx,必要时再加停顿目标G1 的自适应就是为“少配参数”设计的。
从网上抄来的一堆参数互相打架,G1 丢了自适应。
参数见 ④ 最后的代码块,开销很小。
出了问题没有证据,只能猜。
-XX:MaxRAMPercentage 定堆堆和容器上限按比例走,换规格不用改参数。
1 核小容器悄悄用了 Serial;堆和容器上限对不上被杀。
拿同样的流量跑 G1 和 ZGC,日志里数 Allocation Stall。
停顿变短了,却没算多花的 CPU 和内存。
21、22 要加 -XX:+ZGenerational;23 起默认分代,不用再写;24 起不分代模式已删除。
在 21 上用了老的不分代模式而不自知。
盯三样:最长停顿、GC 时间占比、G1 的 Full GC 次数或 ZGC 的 Allocation Stall 次数。
平均停顿很好看,偶发的大停顿被平均掉了。
更深一层
并发标记为什么不会漏掉活对象:SATB
- 两件事同时发生才会漏:黑对象新指向了白对象,而白对象原来的路径(从灰对象出发)被切断了。
- 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 会很慢 |
| 9 | G1 成为默认(JEP 248);CMS 被标记废弃(JEP 291) | 从 8 升上来,默认 GC 变了 |
| 10 | G1 的 Full GC 改成多线程(JEP 307) | G1 的兜底没那么可怕了 |
| 11 | ZGC 加入,实验性,只支持 Linux x64(JEP 333) | 生产别用 |
| 12 | G1 空闲时把内存还给系统(JEP 346);混合回收可以中途停下(JEP 344);Shenandoah 加入(JEP 189) | G1 进程占的内存会随负载回落 |
| 14 | CMS 删除(JEP 363) | 还写着 -XX:+UseConcMarkSweepGC 的要改掉 |
| 15 | ZGC 正式可用(JEP 377) | 17 上可以用,但是不分代 |
| 16 | ZGC 并发处理线程栈(JEP 376) | 停顿不再随线程数变长 |
| 18 | G1 的 Region 最大可设到 512 MB(自动选仍不超过 32 MB) | 大对象多时多一个选择 |
| 21 | 分代 ZGC(JEP 439),要加 -XX:+ZGenerational | 想用 ZGC,从 21 起才划算 |
| 23 | ZGC 默认就是分代(JEP 474) | 不用再写 ZGenerational |
| 24 | 删除不分代 ZGC(JEP 490) | 只剩分代一种 |
| 26 | G1 用两张卡表,写屏障从约 50 条指令减到 12 条(x64),吞吐提升 5%~15%(JEP 522) | 和 ZGC 的吞吐差距再拉开一点 |
| 27 | 没指定 GC 时一律用 G1,不再看 CPU 和内存(JEP 523) | 坑 4 从这一版起消失 |
G1 和 ZGC 实验台先猜,再运行
这是估算模型,只为看清趋势,数字别当真。假设:停顿里复制活对象 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 的设计对比推出来。