《Java 虚拟机原理与性能调优》6.2 G1 调优:Region、Humongous 与 Mixed GC

G1 的 region 到底多大?多大的对象算 Humongous?IHOP 和 MaxGCPauseMillis 改了为什么没效果?本节用本机实测拆开 Region、Humongous、Mixed GC 与停顿目标,并给出 libraryd 导出任务避开大对象的写法。

本节目标:弄清 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折算
256m10485761 MB
512m10485761 MB
1g10485761 MB
2g10485761 MB
4g20971522 MB
8g41943044 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),单次运行:

组参数roundsGC 次数总停顿 msp99 ms最大 msEden region 均值
A-Xmx512m,目标默认 2007704812761631.910.93932.870228.6
B-Xmx512m -XX:MaxGCPauseMillis=508904615371071.48.09322.166227.3
C-Xms512m -Xmx512m,目标 2008302214151397.410.34531.137228.8
D-Xms512m -Xmx512m -XX:MaxGCPauseMillis=18519115521283.45.14412.387201.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 阈值。

如果业务确实需要保留大对象,处理顺序是:

  1. 先问能不能流式化、分页或压缩,把单个对象降下来;
  2. 必须常驻的大对象放进长期缓存,避免「分配—丢弃」循环反复制造 Humongous;
  3. 最后才考虑 -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 与低延迟取舍:染色指针、读屏障与停顿 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发