ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

一个 3MB 的 byte[] 让 G1 每分钟触发一次并发周期:Humongous 对象的 4 个代价

一个 3MB 的 byte[] 让 G1 每分钟触发一次并发周期:Humongous 对象的 4 个代价 title: 一个 3MB 的 byte[] 让 G1 每分钟触发一次并发周期Humongous 对象的 4 个代价tags: [Java, G1, GC调优, JVM, 性能优化]堆没涨Full GC 却越来越勤我们有个对账服务每天凌晨把上游的交易明细文件拉下来解析后跟本地库比对。堆 6G用 G1JDK 17.0.9长期跑得很稳GC 日志一天也就十几行。2026 年 2 月业务方把单个对账文件的批次大小从 5000 笔提到 20000 笔说是为了减少文件个数。上线第二天监控报警这个服务的 GC 耗时占比从 0.3% 涨到 6.8%接口 P99 从 90ms 涨到 340ms。第一反应是内存不够了去看堆使用率——峰值 3.2G离 6G 还差得远。老年代使用率也只有 40% 出头。这就很反直觉堆没满为什么 GC 这么频繁打开 GC 日志-Xlog:gc*,gcheapdebug,gchumongousdebug:filegc.log:time,uptime,level,tags第一眼就看到了不对的地方[2026-02-14T02:31:07.4420800] GC(1841) Pause Young (Concurrent Start) (G1 Humongous Allocation) [2026-02-14T02:31:07.4420800] GC(1841) Humongous regions: 143-139 [2026-02-14T02:31:07.4430800] GC(1841) Metaspace: 98M-98M [2026-02-14T02:31:07.4430800] GC(1841) Pause Young (Concurrent Start) (G1 Humongous Allocation) 812M-798M(6144M) 21.337ms [2026-02-14T02:31:07.4430800] GC(1842) Concurrent Cycle ... [2026-02-14T02:32:11.9070800] GC(1856) Pause Young (Concurrent Start) (G1 Humongous Allocation)关键词G1 Humongous Allocation。这不是「内存不够触发 GC」而是「巨型对象分配触发了并发周期」。一分钟一次全天下来上千次。什么样的对象算 humongousG1 把堆切成大小相等的 region。默认 region 大小由堆大小推算目标是把堆切成约 2048 个 region6G / 2048 ≈ 3MB向上取到 2 的幂实际是4MB。可以用jcmd pid VM.flags确认-XX:G1HeapRegionSize4194304规则是当一个对象的大小超过 region 的一半它就是 humongous 对象。4MB 的 region阈值就是 2MB。humongous 对象的分配路径跟普通对象完全不同不走 TLAB也不进 Eden而是直接找一段连续的空闲 region分配并且这些 region 被标记为 humongous 类型起始 region 标记为 StartsHumongous后续标记为 ContinuesHumongous。它在逻辑上属于老年代。所以一个刚创建的临时大对象从出生起就在老年代里。一个 humongous region 只能装这一个对象剩余空间浪费掉。一个 5MB 的对象占 2 个 region8MB浪费 3MB。分配前如果发现堆里没有足够的连续 region会先触发一次 GC 尝试腾地方如果分配后老年代占比超过 IHOP 阈值会启动并发周期。我们的问题就出在这批次从 5000 提到 20000 之后解析文件时那个用来缓冲的byte[]从 800KB 变成了 3.2MB超过了 2MB 阈值。而这个数组每处理一个批次就 new 一个。定位到具体分配点我用 JFR 抓了 5 分钟专门看 humongous 相关事件jcmd 12345 JFR.start namehum settingsprofile \ duration300s filename/tmp/hum.jfr # 结束后用 jfr 命令行直接过滤 jfr print --events ObjectAllocationOutsideTLAB /tmp/hum.jfr | head -60ObjectAllocationOutsideTLAB这个事件专门记录不走 TLAB 的分配humongous 分配一定在里面。输出里排第一的分配点是public ListTradeRecord parse(InputStream in, int batchSize) throws IOException { // batchSize 从 5000 变成 20000 之后这个数组变成 3.2MB byte[] buffer new byte[batchSize * 168]; // 单条记录定长 168 字节 int read in.readNBytes(buffer, 0, buffer.length); ListTradeRecord list new ArrayList(batchSize); for (int off 0; off read; off 168) { list.add(decode(buffer, off)); // 解码成对象 } return list; }逐行看这段的问题new byte[batchSize * 168]一次性申请整批的空间batchSize20000时是 3,360,000 字节刚好跨过 2MB 门槛readNBytes把整批读进来解码完之后buffer就是垃圾了但它已经在老年代里必须等并发周期或 Mixed GC 才能回收。对账任务一晚上要处理 400 多个批次也就是 400 多个 3.2MB 的短命 humongous 对象每一个都可能触发并发周期。这就是「堆没满但 GC 很勤」的完整解释。四个可选方案的对比方案改动位置效果副作用我的取舍调大G1HeapRegionSize到 16MBJVM 参数阈值升到 8MB3.2MB 不再是巨型对象region 变少384 个Mixed GC 的回收粒度变粗停顿可能变长临时止血用不是根治分块读buffer 固定 256KB业务代码彻底不产生巨型对象需要处理跨块的记录边界最终选这个复用 buffer池化/ThreadLocal业务代码巨型对象只创建一次长期存活长期占 4MB 老年代多线程要每线程一份备选单线程场景够用换 ZGCJVM 参数ZGC 没有 humongous 概念JDK 17 的 ZGC 还没有分代吞吐量比 G1 低约 10%且内存占用更高这个服务不值得为一个 buffer 换 GC当晚为了止血先加了-XX:G1HeapRegionSize16m重启GC 耗时占比立刻从 6.8% 降到 0.9%。第二天再改代码。改成分块读的版本private static final int CHUNK 256 * 1024; // 256KB远低于 2MB 阈值 private static final int RECORD_LEN 168; public ListTradeRecord parse(InputStream in, int batchSize) throws IOException { ListTradeRecord list new ArrayList(batchSize); // 关键把 CHUNK 对齐到记录长度的整数倍避免记录被切断 int alignedChunk (CHUNK / RECORD_LEN) * RECORD_LEN; // 261,912 字节 byte[] buffer new byte[alignedChunk]; int read; while ((read in.readNBytes(buffer, 0, alignedChunk)) 0) { // read 一定是 RECORD_LEN 的整数倍除了文件末尾不完整的情况 if (read % RECORD_LEN ! 0) { throw new IOException(文件长度非法, read read); } for (int off 0; off read; off RECORD_LEN) { list.add(decode(buffer, off)); } } return list; }三个细节alignedChunk用整除再乘回去的方式对齐到 168 的倍数这样每次读进来的都是完整记录不用处理跨块拼接如果记录是变长的就必须处理那会麻烦不少readNBytes(byte[], int, int)是 JDK 9 的方法它会尽力读满指定长度跟read()不同后者可能只读一部分就返回很多人在这里踩坑buffer只 new 一次在循环外复用于每个 chunk。改完之后又加了一层监控直接从 JMX 里读 humongous 相关的数据Component public class HumongousMonitor { private final MeterRegistry registry; PostConstruct public void init() { for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) { if (!G1 Young Generation.equals(gc.getName())) continue; // NotificationEmitter 能拿到每次 GC 的原因GcInfo ((NotificationEmitter) gc).addNotificationListener((notification, handback) - { if (!GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION .equals(notification.getType())) { return; } GarbageCollectionNotificationInfo info GarbageCollectionNotificationInfo.from( (CompositeData) notification.getUserData()); String cause info.getGcCause(); registry.counter(jvm.gc.cause, cause, cause).increment(); if (cause.contains(Humongous)) { // 巨型对象触发的 GC 单独打日志方便回溯 log.warn(Humongous 触发 GC, duration{}ms, id{}, info.getGcInfo().getDuration(), info.getGcInfo().getId()); } }, null, null); } } }这段的价值是把 GC 原因变成了可告警的指标。GarbageCollectionNotificationInfo.getGcCause()返回的就是 GC 日志里那个括号内的原因字符串比如G1 Humongous Allocation、G1 Evacuation Pause。我们给causeG1 Humongous Allocation的计数器配了告警每分钟超过 3 次就通知。这个告警后来在另一个服务上又抓到过一次是有人用String.join拼了一个 5MB 的 SQL IN 子句。顺带说说 humongous 回收的一个变化有个知识点值得纠正。早期版本的 G1 里humongous region 只能在并发周期或 Full GC 时回收从 JDK 8u60 开始引入了「eager reclaim」优化如果一个 humongous 对象是原始类型数组比如byte[]、long[]并且没有任何 region 的 remembered set 指向它那么在 Young GC 时就能直接回收掉。可以在日志里看到效果上面那段日志里Humongous regions: 143-139就是 Young GC 回收了 4 个 humongous region。但注意两个前提必须是原始类型数组因为对象数组需要扫描内部引用成本高G1 选择不做必须没有跨 region 引用指向它。我们那个byte[]满足条件所以部分被 eager reclaim 掉了——如果不满足问题会比实际观察到的更严重。这也解释了为什么把 buffer 换成ByteBuffer.allocate()内部还是 byte[]没问题但如果换成Listbyte[]这种结构eager reclaim 就失效了。复盘数字故障影响GC 耗时占比 0.3% → 6.8%P99 90ms → 340ms持续约 30 小时跨两个对账窗口才定位。定位路径GC 日志看到G1 Humongous Allocation用了 15 分钟用 JFR 的ObjectAllocationOutsideTLAB找到分配点用了 20 分钟。之前浪费的时间全在「堆没满为什么 GC」的困惑上。止血-XX:G1HeapRegionSize16mGC 占比降到 0.9%10 分钟见效。根治分块读buffer 从 3.2MB 降到 256KBhumongous region 数从峰值 143 降到 0GC 占比 0.12%P99 回到 85ms比故障前还略好因为少了大对象的分配开销。参数最终没保留 16mregion 调大后我们观察到 Mixed GC 的单次停顿从 18ms 涨到 31ms代码改好之后把参数回退了。我的几个判断G1 调优的第一步永远是看 GC 原因不是调参数。GC 日志里括号里的那个 cause 字符串信息量极大G1 Evacuation Pause是正常的年轻代回收G1 Humongous Allocation是巨型对象G1 Preventive CollectionJDK 17是预防性回收To-space exhausted是疏散失败。原因不同处理方向完全不同。见到 GC 频繁就去调MaxGCPauseMillis和G1NewSizePercent多半是在瞎猜。我不建议把调大 region size 当作长期方案。它确实能让阈值跟着涨但 region 是 G1 增量回收的基本单位region 越大每次 Mixed GC 挑选和回收的粒度越粗、单次停顿越长也更容易出现「想只回收一点点却不得不搬一大块」的情况。真正该做的是消灭大对象。任何按批次大小线性分配的缓冲区都是隐患。new byte[n * recordLen]这种写法在 n 小的时候看不出问题业务一调参数就跨过阈值。我现在评审时会专门找这类表达式要求改成固定块大小 循环。同理还有new StringBuilder(n * 50)、new ArrayList(n)后面塞大对象。堆大小和 region 大小的耦合值得注意。region 大小由堆大小推算所以扩容堆会改变 humongous 阈值。我们另一个服务从 4G 扩到 8G 之后 region 从 2MB 变成 4MB阈值从 1MB 变成 2MB一批原本是 humongous 的 1.5MB 对象忽然变成普通对象GC 行为整体变了——扩容之后 GC 表现变化别只归因于「内存变多了」。留个问题上面说到 eager reclaim 只对原始类型数组生效对象数组不享受这个优化。那么如果你有一个必须存在的 3MB 的Object[]比如一个大批次的对象引用数组在 G1 下你有什么办法降低它对 GC 的影响把它拆成多个小数组算不算有效拆完之后总的引用数量没变remembered set 的压力会怎么变化欢迎在评论区聊聊。
返回列表