本节目标:看懂 TLAB 与 PLAB 的日志字段,知道大对象为什么绕过 TLAB,并能用 JFR 找到真正的分配热点。
适用版本:JDK 25(Temurin 25.0.5);JFR 25;默认 G1、默认开启 TLAB。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1,压缩 oops,默认 MaxHeapSize 8 GB,G1 Region 4 MB。
承接的问题
libraryd 的借阅接口每处理一次请求就创建一个 LoanEvent,用于审计和指标。压测时 QPS 还没到瓶颈,GC 次数却先上来了。团队的第一反应是「对象太多了,改成对象池」,但没有人知道到底分配了多少、分配在哪里、有多少真正进入了老年代。
先把这个场景压缩成一个可测量的回路:4 个线程各创建 2000 万个 LoanEvent,对象只读一次就丢弃。本机跑完 8000 万个对象用了 1.03 秒:
sink=8000132000000 humongous=4
real 1.03
user 1.14
sys 0.15
每个 LoanEvent 按 3.2 的布局是 32 字节,8000 万个是 2.56 GB,分配速率约 2.5 GB/s。这个数字不是 libraryd 的生产值,而是一个上限探针:它把分配路径压到最热,让 TLAB 日志和 JFR 都有足够的样本。
指针碰撞与 TLAB
如果堆是一整块连续空间,分配一个新对象只需要把「当前指针」往前推对象大小,再返回旧地址。这叫指针碰撞(bump-the-pointer),不需要查找空闲链表,也不需要锁。问题在于多线程:两个线程同时推进同一个指针会互相覆盖。
HotSpot 的解法是给每个线程一块私有区域,叫 TLAB(Thread Local Allocation Buffer)。线程在自己的 TLAB 里做指针碰撞,TLAB 用完了再向堆申请一块新的;只有申请新 TLAB 时才需要同步。相关参数:
| 参数 | 默认 | 作用 |
|---|---|---|
-XX:+UseTLAB | 开启 | 是否使用线程本地分配缓冲 |
-XX:TLABSize | 0(自动) | 固定 TLAB 大小,0 表示按分配速率自适应 |
-XX:TLABRefillWasteFraction | 64 | 允许浪费的比例,1/64 |
-XX:MinTLABSize | 2 KB | TLAB 下限 |
TLAB 只服务「小对象」。当对象大于 TLAB 剩余空间时,线程有两个选择:申请新 TLAB,或者走慢路径直接在共享 Eden 上分配。日志里的 slow allocs 就是后者。
读 TLAB 日志
打开 TLAB 日志的 tag 是 gc+tlab,不是 gc+heap+tlab:
java -Xmx512m \
"-Xlog:gc,gc+tlab=debug" \
-cp out lab.AllocShape 4 2000000
本机输出(单次运行):
[0.138s][debug][gc,tlab] GC(0) TLAB totals: thrds: 6 refills: 94 max: 29 slow allocs: 1 max 1 waste: 3.0% gc: 1380528B max: 524224B slow: 91224B max: 28688B
[0.140s][info ][gc ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 48M->1M(512M) 1.729ms
[0.170s][debug][gc,tlab] GC(1) TLAB totals: thrds: 5 refills: 603 max: 167 slow allocs: 4 max 4 waste: 0.6% gc: 646352B max: 214384B slow: 618832B max: 172240B
[0.173s][info ][gc ] GC(1) Pause Full (System.gc()) 221M->21M(84M) 3.175ms
逐字段读 TLAB totals:
thrds:本次统计覆盖的线程数。程序有 4 个虚拟线程,加上载体线程和主线程,所以不是 4。refills:累计申请新 TLAB 的次数。max是单线程的最高值。slow allocs:没有在 TLAB 内完成、走慢路径的分配次数。waste:TLAB 内因剩余空间不足而浪费的比例。gc:GC 发生时 TLAB 里还没用完的字节数,max是单线程最高值。slow:走慢路径分配的字节数。
GC(1) 的 refills: 603 对应约 200 MB 分配量,平均每个 TLAB 约 340 KB。这个大小不是固定的,HotSpot 会根据线程的分配速率调整。
把 TLAB 固定成 256 KB,refills 立刻翻倍:
java -Xmx512m -XX:TLABSize=256k \
"-Xlog:gc+tlab=debug:file=t-size256k.log" \
-cp out lab.AllocShape 4 2000000
1:[0.168s][debug][gc,tlab] GC(0) TLAB totals: thrds: 6 refills: 188 max: 50 slow allocs: 0 max 0 waste: 1.6% gc: 600512B max: 262096B slow: 191336B max: 50184B
TLAB 从约 340 KB 降到 256 KB,refills 从 94 增到 188。TLAB 越小,同步申请越频繁;TLAB 越大,每个线程占用的 Eden 越多,GC 时浪费也越大。默认的自适应策略在两者之间找平衡,除非有明确证据,不要固定它。
分配速率与晋升
TLAB 只解决「分配得快」,不解决「活下来多少」。对象在 Young GC 中存活一次,年龄加一;年龄达到阈值后晋升到老年代。G1 用 gc+age 打印年龄表和期望 Survivor 大小:
java -Xmx256m "-Xlog:gc+age=trace" \
-cp out lab.AllocShape 4 3000000
[0.077s][debug][gc,age] GC(0) Desired survivor size 3145728 bytes, new threshold 15 (max threshold 15)
[0.078s][trace][gc,age] GC(0) Age table:
[0.078s][trace][gc,age] GC(0) - age 1: 184816 bytes, 184816 total
[0.096s][debug][gc,age] GC(1) Desired survivor size 7864320 bytes, new threshold 15 (max threshold 15)
[0.097s][trace][gc,age] GC(1) Age table:
[0.097s][trace][gc,age] GC(1) - age 1: 32 bytes, 32 total
[0.097s][trace][gc,age] GC(1) - age 2: 184784 bytes, 184816 total
第一次 GC 后只有 184816 字节存活,约为已分配量的千分之一;第二次 GC 时,这批对象年龄变成 2,新的对象又只有 32 字节存活。new threshold 15 是 G1 根据 Survivor 占用动态算出的晋升年龄,最大 15。分配速率再高,只要绝大多数对象在第一次 GC 前就死了,晋升量仍然很小;反之,如果缓存把对象长期持有,它们会逐次加龄并最终进入老年代,Young GC 就救不了。
Humongous 对象不参与年龄表:它们在老年代直接分配,回收要看并发标记周期。这也是 3.2 里「大数组要谨慎」的另一个原因。
PLAB 与大对象
PLAB(Promotion Local Allocation Buffer)是 TLAB 在 GC 期间的对应物。Young GC 把存活对象复制到 Survivor 区时,每个 GC 线程也需要一块私有区域做指针碰撞,这就是 PLAB。用 gc+plab 看:
java -Xmx256m "-Xlog:gc+plab=debug:file=plab.log" \
-cp out lab.AllocShape 4 3000000
[0.123s][debug][gc,plab] GC(0) Young PLAB allocation: allocated: 245760B, wasted: 0B, unused: 77344B, used: 168416B, undo waste: 0B
[0.124s][debug][gc,plab] GC(0) Young other allocation: region end waste: 0B, regions filled: 1, num plab filled: 5, direct allocated: 16400B, num direct allocated: 1, failure used: 0B, failure wasted: 0B
[0.124s][debug][gc,plab] GC(0) Young sizing: calculated: 33680B, actual: 33680B
allocated 是 PLAB 的总大小,used 是真正复制进去的字节,unused 是 GC 结束时剩下的空间。sizing 显示 HotSpot 计算出的 PLAB 大小和实际采用值,这里都是 33680 字节。PLAB 太小会导致频繁申请,太大会浪费 Survivor 空间。
大对象走另一条路。G1 把超过 Region 一半的对象视为 Humongous,直接在老年代分配一组连续 Region:
java -Xmx512m "-Xlog:gc+region=trace:file=reg.log" \
-cp out lab.HoldHumongous
[0.049s][trace][gc,region] G1HR 0 ALLOC(HUMS) [0x0000000460000000, 0x0000000460100000, 0x0000000460100000]
[0.049s][trace][gc,region] G1HR 1 ALLOC(HUMC) [0x0000000460100000, 0x0000000460200000, 0x0000000460200000]
[0.049s][trace][gc,region] G1HR 2 ALLOC(HUMC) [0x0000000460200000, 0x0000000460300000, 0x0000000460300000]
[0.049s][trace][gc,region] G1HR 3 ALLOC(HUMC) [0x0000000460300000, 0x0000000460400000, 0x0000000460400000]
[0.049s][trace][gc,region] G1HR 4 ALLOC(HUMC) [0x0000000460400000, 0x0000000460500000, 0x0000000460500000]
[0.049s][trace][gc,region] G1HR 5 ALLOC(HUMS) [0x0000000460500000, 0x0000000460600000, 0x0000000460600000]
HUMS 是 Humongous Start,HUMC 是 Humongous Continuation。一个 4 MB 的 byte[] 在 1 MB Region 的堆上占 5 个 Region:4 MB 数据加上对象头和对齐,刚好超过 4 个 Region。程序分配了 4 个这样的数组,所以出现 4 组 HUMS。
-Xmx512m 时 GC.heap_info 报 region size 1024K,Humongous 阈值是 512 KB。3.1 里 -Xmx1g 也是 1 MB Region;本机默认 8 GB 堆才是 4 MB Region,阈值 2 MB。阈值随堆大小变,不是固定的 2 MB。
用 JFR 看分配热点
TLAB 日志告诉你分配总量,不告诉你分配的是什么。JFR 的 jdk.ObjectAllocationSample 按类型和调用栈采样:
java -Xmx1g \
-XX:StartFlightRecording=settings=profile,filename=alloc.jfr \
-cp out lab.AllocShape 4 20000000
记录结束后:
jfr view allocation-by-class alloc.jfr
Allocation by Class
Object Type Allocation Pressure
----------------------------------------------------------- -------------------
lab.AllocShape$LoanEvent 99.10%
java.util.ArrayList$Itr 0.87%
java.lang.invoke.InnerClassLambdaMetafactory 0.03%
LoanEvent 占 99.10%,和预期一致。单个采样事件长这样:
jdk.ObjectAllocationSample {
startTime = 11:27:49.054 (2026-10-28)
objectClass = lab.AllocShape$LoanEvent (classLoader = app)
weight = 511.0 kB
eventThread = "" (javaThreadId = 34, virtual)
stackTrace = [
lab.AllocShape.lambda$main$0(long, CountDownLatch) line: 31
java.lang.VirtualThread.run(Runnable) line: 460
jdk.internal.vm.Continuation.enterSpecial(Continuation, boolean, boolean)
]
}
weight 是采样估算的分配字节数,不是精确值。JFR 默认每分配约 150 KB 采样一次,因此 allocation-by-class 的百分比适合找热点,不适合算总量。要算总量,用 TLAB 日志或 GC 日志。
在 libraryd 里落地
LoanEvent 是审计事件,不是领域对象。下面把事件和它的出口接口放在一起(生产代码里接口通常单独成文件):
package com.plume.library.app;
public record LoanEvent(long loanId, String isbn, EventType type, long atMillis) {
public enum EventType { BORROWED, RETURNED, RENEWED }
}
interface LoanEventSink {
void onEvent(LoanEvent event);
static LoanEventSink noop() {
return event -> { };
}
}
按 32 字节计算,5000 QPS 下每秒分配 160 KB,一分钟不到 10 MB,完全不需要对象池。压测到 10 万 QPS 时是 3.2 MB/s,仍然远低于本机 2.5 GB/s 的分配能力。真正需要关注的是事件里的 String:如果每次从数据库读出的 isbn 是新 String,每个事件会额外带 24 字节的 String 和 32 到 40 字节的 byte[],成本是对象本身的 2 到 3 倍。
因此优化顺序是:
- 先用
jfr view allocation-by-class确认LoanEvent是否真的排在前面。 - 如果排在前面,先消除事件里的临时字符串和集合,而不是急着上对象池。
- 审计可以关闭时,让调用方持有
LoanEventSink.noop()。noop()返回的 lambda 只创建一次,调用时没有分配;真正记录事件的实现才构造LoanEvent。这样在不需要审计的压测里,分配路径上根本没有LoanEvent。
如果确实要复用对象,注意不可变 record 不能复用;可变对象复用又会在异步消费时出现「事件被下一条覆盖」的竞态。对象池适合「创建成本高、生命周期短、数量可控」的对象,LoanEvent 三条都不满足,不是候选。
常见错误与边界
「日志 tag 写成 gc+heap+tlab。」 zsh 下会得到:
[0.004s][warning][logging] No tag set matches selection: gc+heap+tlab. Did you mean any of the following? gc+heap* gc+tlab gc+heap+region gc+ergo+heap gc+heap+verify
tlab 是 gc 的子 tag,不是 heap 的子 tag。正确的组合是 gc+tlab。类似地,gc+plab、gc+region 都是独立组合,写之前先用 java -Xlog:help 查 tag 列表。
「把 TLAB 固定得太大。」 -XX:TLABSize=1m 在 -Xmx512m 和 -Xmx1g 上都会启动失败:
TLABSize (1048576) must be less than or equal to ergonomic TLAB maximum size (524288)
Error: Could not create the Java Virtual Machine.
本机的 ergonomic TLAB 上限是 524288 字节(512 KB),与 -Xmx 无关。想固定 TLAB 只能取不超过这个值的数,例如 256 KB。超过上限不是被截断,而是直接拒绝启动。
「把 JFR 的采样权重当成精确分配量。」 一次 10 秒的记录里 ObjectAllocationSample 只有 180 个事件,weight 合计 23 MB,而实际分配了 2.56 GB。采样是统计估计,事件太少时误差很大;allocation-by-class 的百分比在热点明显时可用,在几个类型接近时不可用。需要精确字节数就用 -Xlog:gc+tlab=debug 的 slow 和 GC 前后的堆变化。
小结
- 分配小对象走 TLAB 指针碰撞,只有申请新 TLAB 和慢路径才需要同步。
-Xlog:gc+tlab=debug的TLAB totals一行给出 refills、slow allocs、waste、gc 浪费和 slow 字节数。- 固定
-XX:TLABSize=256k会让 refills 翻倍;默认自适应通常比手工固定更稳。 - PLAB 是 GC 复制阶段的线程本地缓冲,用
gc+plab看 allocated/used/unused 和 sizing。 - 超过 Region 一半的对象走 Humongous,
gc+region=trace里表现为一组HUMS/HUMC;4 MB 数组在 1 MB Region 上占 5 个 Region。 - JFR 的
ObjectAllocationSample适合找分配热点,weight是采样估计,不是精确总量。
对象分配出来之后,执行引擎要决定什么时候把它们跑快。下一节从解释器开始,看一个方法如何从 tier 0 升到 tier 4。
阅读导航:上一节:3.2 对象布局:对象头、压缩指针与字段重排 · 下一节:4.1 解释执行与分层编译:C1、C2 与阈值 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。