本节目标:弄清 G1 的 region 尺寸、Humongous 判定、IHOP 与 Mixed GC 的触发关系,并用本机实测判断哪些参数值得调、哪些调了也白调。
适用版本:JDK 25(Temurin 25.0.5)。本机为 Apple M1 Pro / 10 核 / 32 GB / macOS 26.3,默认收集器 G1。
承接的问题
libraryd 的默认启动参数是 -Xmx8g,没有指定收集器,于是 JDK 25 选了 G1。白天借还书接口很稳,夜里跑 LoanExportJob 时日志开始出现两种噪声:
[info][gc] GC(412) Pause Young (Mixed) (G1 Evacuation Pause) 3014M->1880M(8192M) 41.227ms
[info][gc] GC(413) Pause Young (Concurrent Start) (G1 Humongous Allocation) 3120M->1902M(8192M) 46.811ms
第一个问题:为什么堆是 8 GB,日志里的 region 相关数字却按「块」记账,一块到底多大?
第二个问题:Humongous Allocation 是什么触发的,导出任务只是拼了一个几十 MB 的数组,为什么会被单独标记?
第三个问题:把 InitiatingHeapOccupancyPercent 从 45 调到 20,或者把 MaxGCPauseMillis 从 200 调到 50,为什么有时候毫无变化?
这三个问题对应 G1 的三层设计:region 是分配和回收的粒度,Humongous 是 region 粒度带来的副作用,IHOP 与停顿目标是两套不同的反馈回路。下面所有数字都在本机跑出,除特别说明外都是单次运行,未做统计。
Region:G1 把堆切成固定大小的块
G1 的堆不是「Eden 一块、Old 一块」的连续空间,而是一组大小相等的 region。每个 region 在某一时刻扮演 Eden、Survivor、Old 或 Humongous 的角色,角色可以随 GC 改变。jcmd <pid> GC.heap_info 对 G1 只报 region 计数,不报传统分代的字节数。
本机 OldLoad 跑 3 秒、-Xms256m -Xmx256m 时采样:
garbage-first heap total reserved 262144K, committed 262144K, used 239932K
region size 1024K, 133 young (136192K), 3 survivors (3072K)
region size 1024K 是最关键的一行。133 个 young region 合计 136192K,正好是 133 × 1024K;3 个 survivor region 合计 3072K。G1 的 young 大小不是一个固定字节数,而是「多少个 region」,所以它能在不移动老年代的前提下按 region 粒度扩张或收缩。
region 大小由人体工学决定,不是常量。用 -XX:+PrintFlagsFinal 看不同 -Xmx 下的最终值:
| 最大堆 | G1HeapRegionSize | 折算 |
|---|---|---|
| 256m | 1048576 | 1 MB |
| 512m | 1048576 | 1 MB |
| 1g | 1048576 | 1 MB |
| 2g | 1048576 | 1 MB |
| 4g | 2097152 | 2 MB |
| 8g | 4194304 | 4 MB |
规则大致是「目标约 2048 个 region,取值 1–32 MB 且为 2 的幂」。所以 8 GB 堆得到 4 MB region,2 GB 及以下都是 1 MB。不要在任何文档或脚本里假设 region 恒为 4 MB:换一个 -Xmx,Humongous 阈值就跟着变。
region 大小的影响有三层:
- 越小,Humongous 阈值越低,大对象越容易被判为 Humongous;
- 越小,RSet 和标记位图的粒度越细,但 region 元数据总量越大;
- 越大,Mixed GC 每次能回收的最小单位越大,堆很满时容易「想收但凑不出一个可回收 region」。
-XX:G1HeapRegionSize 可以手工覆盖,但绝大多数情况下不该动。它是全局尺子,改它等于同时改了 Humongous 判定、RSet 粒度和 Mixed 回收粒度。
Humongous:半个 Region 就是分水岭
G1 的判定很直接:对象大小 ≥ region 的一半,就是 Humongous;≥ 一个 region 时占用一串连续 region。8 GB 堆、4 MB region 下,阈值是 2 MB。
用 HumongousLab 分配 30 个块、保留其中一半,末尾 System.gc() 触发一次 Full GC,观察 -Xlog:gc+heap=info 的 Humongous regions 行。默认堆(最大 8 GB,region 4 MB):
### 每块 1500KB,低于 2MB 阈值
[0.046s][info][gc,heap] GC(0) Humongous regions: 0->0
[0.051s][info][gc,heap] GC(1) Humongous regions: 0->0
### 每块 2500KB,高于 2MB 阈值
[0.061s][info][gc,heap] GC(0) Humongous regions: 30->15
30->15 表示 GC 前有 30 个 Humongous region,回收后剩 15 个,正好对应「分配 30 块、保留 15 块」。1500KB 的块没有出现在这一行里,因为它们走的是普通 young 分配。
把堆换成 256 MB(region 1 MB,阈值 512 KB),同样 30 块:
### 每块 400KB,低于 512KB 阈值
[0.150s][info][gc,heap] GC(0) Humongous regions: 0->0
[0.150s][info][gc ] GC(0) Pause Full (System.gc()) 16M->7M(256M) 8.540ms
### 每块 600KB,高于 512KB 阈值
[0.218s][info][gc,heap] GC(0) Humongous regions: 30->15
[0.218s][info][gc ] GC(0) Pause Full (System.gc()) 32M->16M(256M) 13.023ms
再做一个反证:同样是 512 MB 堆、每块 3 MB,不改 region 时 3 MB ≥ 2 MB 属于 Humongous;把 region 手工改成 8 MB,阈值变成 4 MB,3 MB 就不再是 Humongous。
### 默认 region(1MB),3MB 块
[0.040s][info][gc,heap] GC(0) Humongous regions: 120->60
### -XX:G1HeapRegionSize=8m,3MB 块
[0.040s][info][gc,heap] GC(0) Humongous regions: 0->0
同一个对象,只因为 region 变大就不再是 Humongous。这说明「大对象」不是绝对概念,而是「相对 region 的一半」。
Humongous 的代价不在分配本身,而在回收路径:
- Humongous 对象直接进入老年代 region,不经过 young 的复制晋升;
- 小于一个 region 的 Humongous 对象独占一个 region,剩余空间浪费;
- 短命的大数组会快速推高老年代占用,诱发并发标记周期,但它们又很快死亡,Mixed GC 收不到多少东西;
- 一串连续 region 的 Humongous 对象在标记和 RSet 维护上更昂贵,且容易造成碎片。
所以在日志里看到 G1 Humongous Allocation,第一反应应该是「谁在分配大对象」,而不是「把 region 调大」。
IHOP 与 Mixed GC:什么时候开始收老年代
G1 的老年代回收不是「满了再收」,而是先做并发标记,再挑垃圾比例高的 old region 做 Mixed GC。触发标记周期的阈值就是 IHOP(Initiating Heap Occupancy Percent)。
本机默认值:
uint InitiatingHeapOccupancyPercent = 45 {product} {default}
bool G1UseAdaptiveIHOP = true {product} {default}
uint G1HeapWastePercent = 5 {product} {default}
uintx MaxGCPauseMillis = 200 {product} {default}
注意 G1UseAdaptiveIHOP=true:45 只是起点,运行期 G1 会根据晋升速率调整实际阈值。所以日志里的触发点通常不等于 45%。
实测对照,-Xms256m -Xmx256m 跑 OldLoad 8 120(保留 120 MB 活对象):
### 默认(自适应 IHOP)
[0.148s][debug][gc,ergo] GC(5) Initiate concurrent cycle (concurrent cycle initiation requested)
[0.151s][info ][gc ] GC(5) Pause Young (Concurrent Start) (G1 Evacuation Pause) 191M->124M(256M) 2.459ms
### -XX:InitiatingHeapOccupancyPercent=20 -XX:-G1UseAdaptiveIHOP
[0.092s][debug][gc,ergo] GC(2) Initiate concurrent cycle (concurrent cycle initiation requested)
[0.102s][info ][gc ] GC(2) Pause Young (Concurrent Start) (G1 Evacuation Pause) 145M->106M(256M) 9.862ms
默认在 GC(5)、堆占用 191 MB(约 75%)时才发起第一个并发周期;显式设 20% 并关闭自适应后,GC(2)、145 MB(约 57%)就发起了。gc+ergo 的 Initiate concurrent cycle 是比猜阈值更可靠的观测点。
一个完整的并发周期在日志里长这样(来自上面 IHOP=20 的那次运行):
[0.102s][info][gc] GC(3) Concurrent Mark Cycle
[0.106s][info][gc] GC(3) Pause Remark 149M->149M(256M) 0.370ms
[0.113s][info][gc] GC(3) Pause Cleanup 129M->129M(256M) 0.012ms
[0.113s][info][gc] GC(3) Concurrent Mark Cycle 11.449ms
[0.120s][info][gc] GC(5) Pause Young (Prepare Mixed) (G1 Evacuation Pause) 185M->125M(256M) 3.316ms
[0.127s][info][gc] GC(6) Pause Young (Mixed) (G1 Evacuation Pause) 192M->124M(256M) 0.781ms
顺序是 Concurrent Start → Remark → Cleanup → Prepare Mixed → Mixed。Prepare Mixed 只是宣告「下一轮 young GC 会带上 old region」,真正的回收发生在 Mixed 那一轮。
但这里必须诚实:上面两组配置在 8 秒内都只出现了 1 次 Mixed GC。原因不是 IHOP 没生效,而是老年代里 120 MB 是活对象,标记完也挑不出多少可回收 region。IHOP 决定「什么时候开始标记」,不决定「能回收多少」。 如果老年代本身没有垃圾,调低 IHOP 只会增加并发标记的 CPU 和 Remark 停顿。
什么时候才值得调低 IHOP:老年代增长快、并发标记还没跑完就又满了,日志里出现 Pause Full (G1 Compaction Pause) 或疏散失败。此时提前标记能避免 Full GC,代价是更多并发线程占用和更频繁的 Remark。G1HeapWastePercent=5 是另一道闸门:可回收空间不足 5% 时,G1 干脆不做 Mixed。
MaxGCPauseMillis:目标不是承诺
MaxGCPauseMillis 默认 200 ms。它不是硬上限,而是 G1 用来推算 young region 数量的输入:目标越小,G1 越倾向于少选几个 Eden region。实际停顿能不能达标,取决于存活对象多少、region 大小和 CPU 核数。
本机对照实验,四组都用 OldLoad 10 250(10 秒,保留 250 MB),单次运行:
| 组 | 参数 | rounds | GC 次数 | 总停顿 ms | p99 ms | 最大 ms | Eden region 均值 |
|---|---|---|---|---|---|---|---|
| A | -Xmx512m,目标默认 200 | 77048 | 1276 | 1631.9 | 10.939 | 32.870 | 228.6 |
| B | -Xmx512m -XX:MaxGCPauseMillis=50 | 89046 | 1537 | 1071.4 | 8.093 | 22.166 | 227.3 |
| C | -Xms512m -Xmx512m,目标 200 | 83022 | 1415 | 1397.4 | 10.345 | 31.137 | 228.8 |
| D | -Xms512m -Xmx512m -XX:MaxGCPauseMillis=1 | 85191 | 1552 | 1283.4 | 5.144 | 12.387 | 201.3 |
几个容易被过度解读的点:
- A 到 B 把目标从 200 降到 50,Eden region 均值只从 228.6 变到 227.3,几乎没动。因为实际停顿(p50 不到 1 ms)远低于目标,G1 没有理由缩小 young。
- B 的最大停顿比 A 低,但 A 最慢的三次都是启动期
GC(1..3)(221M->121M、284M->201M、335M->254M),属于预热阶段。单次运行无法把这点差异归因于参数。 - D 把目标压到 1 ms,Eden region 均值才明显降到 201.3(最小 21),GC 次数升到 1552。但稳态最大停顿仍有 12.387 ms,是目标的 12 倍——目标不是承诺。
- C 与 A 的差别只是固定了
-Xms。C 的停顿并没有更好,最大 31.137 ms 与 A 的 32.870 ms 在同一量级。固定堆改变的是提交行为和启动后的扩容次数,不是回收算法。
调参顺序应该是:先用日志量出 p50/p99,再判断目标是否设置得离谱。默认 200 ms 对大多数服务已经足够;把目标设到 1–5 ms,通常只会得到更频繁的 GC 和依然达不到的停顿。
在 libraryd 里落地
回到 LoanExportJob。它的原始写法是把当天 20 万条 Loan 先拼成一个大字符串,再一次性写文件:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
final class BadExport {
record Loan(long id, String isbn) {}
static void writeAll(List<Loan> loans, Path path) throws IOException {
StringBuilder sb = new StringBuilder(64 * 1024 * 1024);
for (Loan loan : loans) {
sb.append(loan.id()).append(',').append(loan.isbn()).append('\n');
}
Files.writeString(path, sb.toString());
}
}
StringBuilder 的底层数组会随扩容翻倍,最终可能达到 64 MB 以上;转成 String 时还要再复制一份。在 4 MB region 下,这是一个占十几个连续 region 的 Humongous 对象,而且写盘后立刻死亡。日志里的 G1 Humongous Allocation 就是它。
正确的方向不是调 region,而是消除大对象:分块写入,让活跃对象始终小于 region 的一半。
package com.plume.library.app;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
public final class LoanExportWriter {
private static final int CHUNK_BYTES = 256 * 1024;
private final OutputStream out;
private final byte[] buffer = new byte[CHUNK_BYTES];
private int filled;
public LoanExportWriter(OutputStream out) {
this.out = out;
}
public void writeLine(String line) throws IOException {
byte[] bytes = (line + "\n").getBytes(StandardCharsets.UTF_8);
if (bytes.length > CHUNK_BYTES) {
flush();
out.write(bytes);
return;
}
if (filled + bytes.length > CHUNK_BYTES) {
flush();
}
System.arraycopy(bytes, 0, buffer, filled, bytes.length);
filled += bytes.length;
}
public void flush() throws IOException {
if (filled > 0) {
out.write(buffer, 0, filled);
filled = 0;
}
}
}
javac --release 25 可编译。调用侧改成逐条 writeLine、结束时 flush,堆上最大的临时对象是 256 KB 的缓冲区,在 4 MB region 下远低于 2 MB 阈值;即使换成 1 MB region(2 GB 以下堆),256 KB 也低于 512 KB 阈值。
如果业务确实需要保留大对象,处理顺序是:
- 先问能不能流式化、分页或压缩,把单个对象降下来;
- 必须常驻的大对象放进长期缓存,避免「分配—丢弃」循环反复制造 Humongous;
- 最后才考虑
-XX:G1HeapRegionSize=8m或16m,并且用-Xlog:gc+heap=info的Humongous regions行验证,而不是凭感觉。
调大 region 的代价要一起评估:Mixed GC 的最小回收单位变大,堆接近满时更不容易凑出可回收空间;RSet 条目也更粗。对 libraryd 这种「大对象只是导出中间产物」的场景,改代码的收益远大于改 region。
观测三件套,建议一起开:
java -Xms8g -Xmx8g \
"-Xlog:gc,gc+heap=info,gc+ergo=debug:file=/var/log/libraryd/gc.log:time,uptime,level,tags:filecount=5,filesize=50m" \
-jar libraryd.jar
gc+heap=info看Humongous regions、Eden/Survivor region 数;gc+ergo=debug看Initiate concurrent cycle的时机;jcmd <pid> GC.heap_info看运行时的region size、young 与 survivor region 数。
常见错误与边界
错误一:用 -Xmn 固定年轻代。 实测 -Xmn64m -Xmx512m 下,Eden region 数在 56–64 之间波动(均值 62.0),并不是恒定的 64 MB。-Xmn 在 G1 下只是给年轻代一个约束,G1 仍会按停顿目标调整;写死它会削弱 G1 的自适应能力。
错误二:直接写 -XX:G1NewSizePercent=10。 JDK 25 会直接拒绝启动:
Error: VM option 'G1NewSizePercent' is experimental and must be enabled via -XX:+UnlockExperimentalVMOptions.
Error: The unlock option must precede 'G1NewSizePercent'.
Improperly specified VM option 'G1NewSizePercent=10'
Error: Could not create the Java Virtual Machine.
顺序也重要:-XX:+UnlockExperimentalVMOptions 必须放在实验参数之前。这类参数属于「知道有这回事即可」,生产环境优先用 MaxGCPauseMillis 间接控制 young 大小。
错误三:以为 region 恒为 4 MB。 8 GB 堆是 4 MB,4 GB 是 2 MB,2 GB 及以下是 1 MB。写监控规则、估算 Humongous 阈值、算 Mixed 回收粒度时,都要以运行时 GC.heap_info 或 PrintFlagsFinal 为准。
错误四:把 MaxGCPauseMillis 设成 1–5 ms。 上面的 D 组说明,G1 会压缩 Eden,但稳态停顿仍有 12 ms 量级;再低只会增加 GC 频率。目标应设在实测 p99 附近,而不是理想值。
错误五:以为调低 IHOP 就能降低老年代占用。 IHOP 只控制标记开始时间;如果老年代没有足够垃圾,G1HeapWastePercent=5 会让 Mixed GC 直接不发生。先确认老年代里确实有可回收对象,再谈 IHOP。
边界:System.gc() 只用于实验。 本文的 Humongous 实验用 System.gc() 强制触发 Full GC,是为了让 Humongous regions 行稳定出现。生产代码不应依赖 System.gc(),它的行为受 -XX:+DisableExplicitGC 影响,且会打乱 G1 的自适应节奏。
小结
G1 的调优可以压缩成四句话:
- region 是 G1 的尺子,大小随堆变化,8 GB 堆默认 4 MB,不要写死;
- Humongous 的判定是「≥ region 的一半」,消除大对象比调大 region 更根本;
- IHOP 决定何时开始并发标记,Mixed GC 能收多少取决于老年代里有多少垃圾;
MaxGCPauseMillis是软目标,先量 p99 再调,别把它当硬上限。
排查顺序也固定:先看 G1 Humongous Allocation 这类原因,再看 gc+ergo 的 Initiate concurrent cycle,最后才动参数。参数是最后一步,不是第一步。
下一节换一个方向:如果业务连十几毫秒的停顿都不能接受,G1 的天花板在哪里,ZGC 又是怎么把停顿压到亚毫秒的。
阅读导航:上一节:6.1 GC 日志解析:统一日志、停顿指标与分配失败 · 下一节:6.3 ZGC 与低延迟取舍:染色指针、读屏障与停顿 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。