《Java 虚拟机原理与性能调优》3.3 分配与 TLAB:指针碰撞、线程本地缓冲与大对象

80 万个 LoanEvent 在 1.03 秒内分配完,折合 2.5 GB/s。本节用 -Xlog:gc+tlab=debug 读 TLAB totals、用 gc+plab 看 PLAB、用 gc+region 定位 Humongous,再用 JFR 的 jdk.ObjectAllocationSample 找分配热点,最后讨论 libraryd 每请求创建 LoanEvent 的取舍。

本节目标:看懂 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:TLABSize0(自动)固定 TLAB 大小,0 表示按分配速率自适应
-XX:TLABRefillWasteFraction64允许浪费的比例,1/64
-XX:MinTLABSize2 KBTLAB 下限

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 倍。

因此优化顺序是:

  1. 先用 jfr view allocation-by-class 确认 LoanEvent 是否真的排在前面。
  2. 如果排在前面,先消除事件里的临时字符串和集合,而不是急着上对象池。
  3. 审计可以关闭时,让调用方持有 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 与阈值 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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