《Java 并发编程·性能诊断与事故》13.1 分配速率、TLAB 与逃逸

从一次「GC 次数高就该加大堆」的误判出发,用 ThreadMXBean.getThreadAllocatedBytes 与 JFR 分配事件实测 9.1 GB/s 的分配速率,拆解 TLAB 的 512 KB 填充与 184 字节/次的来源,再用三组逃逸分析对照说明标量替换为什么让分配计数归零,并纠正 HotSpot 并不做栈上分配。

承接的问题

第 12 章讲的是怎么把线程池的数字变成告警。告警响过之后,值班同学的下一个动作往往是打开 GC 面板:看到 Young GC 次数很高、堆使用曲线锯齿状,第一反应是「堆不够,加堆」。构造一个场景。libraryd-conc 的订单查询接口每请求要构造若干临时对象:一个 Order、三条 Line、一个 Line[]、若干个拼接出来的字符串。压测时 GC 面板显示 10 秒内 300 次 Young GC,也就是每 33 毫秒一次;值班同学把 -Xmx512m 加到 -Xmx2g,重启之后 GC 次数确实降下来了,但接口 RT 没有任何改善,反而因为堆更大、单次 Young GC 要搬的对象更多,尾延迟更差。

问题的根子不在堆大小,在分配速率。分配速率决定的是「GC 多久来一次」,堆大小只决定「每次来的时候搬多少」。当分配速率远高于回收速率时,加堆只是把「频繁的小停顿」换成「稀少的大停顿」,总回收成本并不会下降。

这一节只解决三个问题:分配速率怎么量出来;TLAB 在这条路径上帮了什么忙、在哪里帮不上忙;以及为什么经常出现「profiler 看到大量分配、GC 却没什么压力」和反过来的「分配字节数几乎是 0」两种看起来矛盾的现象。

本机口径:

Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8

写作时 uptime 的 load average 是 86.62 85.05 72.03。下面的计时都是单次运行、随调度波动、不作基准,只用于验证语义与量级。

先确认本机 JDK 25 的默认 GC,不靠猜:

java -XX:+PrintFlagsFinal -version | grep -E "UseG1GC|UseZGC|UseShenandoahGC|ConcGCThreads"
     uint ConcGCThreads       = 2        {product} {ergonomic}
     bool UseG1GC             = true     {product} {ergonomic}
     bool UseShenandoahGC     = false    {product} {default}
     bool UseZGC              = false    {product} {default}

本机 10 核 32 GB,ergonomics 选的是 G1,并发标记线程 2 条。本节所有分配实验都显式写 -XX:+UseG1GC,不依赖默认值。

分配速率:先量出来,再谈 GC

ThreadMXBean 在 com.sun.management 下有一个扩展方法 getThreadAllocatedBytes(long id),返回该线程累计分配的字节数(HotSpot 的实现是 TLAB 已用字节 + 慢路径分配,属于近似值,但足够算速率)。本机确认 isThreadAllocatedMemorySupported() 与 isThreadAllocatedMemoryEnabled() 都是 true,默认可用。

下面这个探针就是上面那个订单场景的最小复刻:每轮构造 1 个 Order、1 个长度 3 的 Line[]、3 个 Line,外加一次字符串拼接,然后把 Line[] 放进一个静态环里防止被优化掉。环只有 8192 槽,存活对象不到 2 MB,其余全部是朝生夕死的垃圾。

import com.sun.management.ThreadMXBean;
import java.lang.management.ManagementFactory;
import java.util.Locale;

public final class AllocLoop {

    record Order(String orderId, String isbn, long amountCents) {}
    record Line(String isbn, int qty) {}

    static final Object[] RING = new Object[8192];
    static volatile long sink;

    public static void main(String[] args) throws Exception {
        int seconds = Integer.parseInt(args[0]);
        ThreadMXBean b = (ThreadMXBean) ManagementFactory.getThreadMXBean();
        long threadId = Thread.currentThread().threadId();
        long b0 = b.getThreadAllocatedBytes(threadId);
        long t0 = System.nanoTime();
        long end = t0 + seconds * 1_000_000_000L;
        long ops = 0;
        int idx = 0;
        while (System.nanoTime() < end) {
            for (int k = 0; k < 20_000; k++) {
                Order o = new Order("ord-" + (idx & 8191), "978-7-111-00000-0", idx * 13L);
                Line[] lines = new Line[3];
                for (int j = 0; j < lines.length; j++) {
                    lines[j] = new Line(o.isbn(), j + 1);
                }
                RING[idx & 8191] = lines;
                sink += o.amountCents();
                idx++;
            }
            ops += 20_000;
        }
        long elapsed = System.nanoTime() - t0;
        long bytes = b.getThreadAllocatedBytes(threadId) - b0;
        System.out.printf(Locale.ROOT,
                "seconds=%.2f ops=%,d bytes=%,d bytesPerOp=%.2f allocMBps=%.1f%n",
                elapsed / 1e9, ops, bytes, bytes / (double) ops, bytes / 1048576.0 / (elapsed / 1e9));
    }
}
javac --release 25 -d classes AllocLoop.java
java -Xmx512m -XX:+UseG1GC -cp classes AllocLoop 5

本机输出:

seconds=5.00 ops=267,620,000 bytes=49,242,142,184 bytesPerOp=184.00 allocMBps=9391.8

9.4 GB/s。这不是笔误,单线程在 M1 Pro 上就是能跑到这个量级,因为绝大多数分配只是 TLAB 里的指针加法,没有真正的内存清零开销之外的同步。

184 这个数字可以对账。按 64 位 HotSpot 的对象布局(开启压缩指针,对象头 12 字节):

分配物布局字节
Order(String, String, long)头 12 + 引用 4 + 引用 4 + 对齐 4 + long 832
Line(String, int) × 3(12 + 4 + 4) 对齐到 2472
Line[3]数组头 16 + 3 × 4 引用32
"ord-" + nString 24 + byte[] 2448
合计184

实测 bytesPerOp=184.00 与手算完全一致。这种对账很有用:它证明计数器数的是真实分配,而不是把 TLAB 的浪费也算进去了。

再看 GC 压力。同一份代码加 JFR(settings=profile)跑 10 秒,用第 13.2 节那个聚合工具统计 jdk.GCPhasePause:

GC Pause   events=300 totalMs=461.782 avgMs=1.539
pauseTotalMs=461.782 pauseMaxMs=11.518

300 次 Young GC、平均 1.54 毫秒、总计占 10 秒的 4.6%。这就是「分配速率高」的真实代价:停顿次数多,但单次短。加堆会把 300 次变成 60 次、把 1.5 毫秒变成 7 毫秒,总停顿未必下降,尾延迟却一定变差。

判断要不要动堆之前,先算一个比值:

分配速率 × 平均对象存活时间 ≈ 需要复制的存活字节数

如果存活时间接近 0(请求内的临时对象),提高堆只会让 Eden 更大、GC 更稀、单次复制更多,收益是负的;该做的是降低分配速率。如果存活时间长(缓存、会话),才轮到调堆和调晋升阈值。

TLAB:分配快在哪,浪费在哪

每个线程在 Eden 里有一块私有缓冲区,叫 TLAB(Thread Local Allocation Buffer)。分配对象时先尝试在 TLAB 里做指针碰撞,TLAB 用完再去 Eden 申请新的。它解决的是「多线程同时分配」的竞争:没有 TLAB 时所有线程抢同一个 Eden 顶指针,必须用 CAS;有 TLAB 之后,线程在自己的缓冲区里分配是纯本地操作。

先踩一个坑:网上大量文章让你加 -XX:+PrintTLAB。本机 JDK 25 直接拒绝:

java -XX:+PrintTLAB -version
Unrecognized VM option 'PrintTLAB'
Error: Could not create the Java Virtual Machine.

这个参数已经不存在了,等价物是统一日志:

java -Xmx256m -XX:+UseG1GC -Xlog:gc+tlab=trace -cp classes TlabSmoke

本机输出(摘录,同一线程的连续 refill):

[0.010s][trace][gc,tlab] ThreadLocalAllocBuffer::compute_size(2) returns 65536
[0.010s][trace][gc,tlab] TLAB: fill thread: 0x0000000100fc69f0 [id: 8707] desired_size: 512KB slow allocs: 0  refill waste: 8192B alloc: 0.53191      545KB refills: 1 waste  0.0% gc: 0B slow: 0B
[0.037s][trace][gc,tlab] TLAB: fill thread: 0x0000000100fc69f0 [id: 8707] desired_size: 512KB slow allocs: 0  refill waste: 8192B alloc: 0.53191     1089KB refills: 2 waste  0.1% gc: 0B slow: 1032B
[0.040s][trace][gc,tlab] TLAB: fill thread: 0x0000000100fc69f0 [id: 8707] desired_size: 512KB slow allocs: 0  refill waste: 8192B alloc: 0.53191     3268KB refills: 10 waste  0.2% gc: 0B slow: 9352B

四个字段值得记住:

  • desired_size: 512KB:G1 在这个堆大小下给每个线程的目标 TLAB 是 512 KB。TLAB 大小由 -XX:TLABSize 起步、由 -XX:TLABWasteTargetPercent(默认 1%)约束,JVM 会按分配速率自适应调整。
  • compute_size(2) returns 65536:新线程第一次只拿到 64 KB,之后根据浪费率逐步长到目标值。所以「TLAB 大小」不是常数。
  • waste 0.2%:TLAB 尾部放不下下一个对象时剩下的字节就是浪费。对象越小、TLAB 越大,浪费率越低;这就是为什么 TLAB 对大对象不友好。
  • slow allocs / slow: 9352B:TLAB 放不下、退化成共享 Eden 分配的字节数。这个数字持续增长说明分配尺寸和 TLAB 尺寸不匹配。

JFR 里有对应的结构化事件,用 jfr configure 打开它们:

jfr configure --input "$JAVA_HOME/lib/jfr/profile.jfc" --output mark.jfc \
  jdk.ObjectAllocationInNewTLAB#enabled=true \
  jdk.ObjectAllocationInNewTLAB#stackTrace=true \
  jdk.ObjectAllocationOutsideTLAB#enabled=true \
  jdk.GCTLABConfiguration#enabled=true
jdk.GCTLABConfiguration {
  usesTLABs = true
  minTLABSize = 2.0 kB
  tlabRefillWasteLimit = 64 bytes
}

jdk.ObjectAllocationInNewTLAB {
  objectClass = java.lang.String (classLoader = bootstrap)
  allocationSize = 24 bytes
  tlabSize = 512.0 kB
  eventThread = "main" (javaThreadId = 3)
  stackTrace = [
    AllocLoop.main(String[]) line: 25
  ]
}

ObjectAllocationInNewTLAB 的语义是「这次 refill 之后的第一笔分配」,不是「所有 TLAB 分配」。所以它的条数等于 refill 次数,约等于「分配字节数 / TLAB 大小」,而不是对象个数。这一点在读火焰图时最容易搞混:图上 ObjectAllocationInNewTLAB 的样本量反映的是触发 refill 的分配点,不是分配总量。要看总量得用 jdk.ObjectAllocationSample,它带 weight(估算字节数):

jdk.ObjectAllocationSample {
  objectClass = AllocLoop$Line[] (classLoader = app)
  weight = 511.0 kB
  eventThread = "main" (javaThreadId = 3)
  stackTrace = [
    AllocLoop.main(String[]) line: 25
  ]
}

这个 10 秒的录制里 jdk.ObjectAllocationSample 一共 2957 条,约 296 条/秒,每条权重从 0.5 KB 到 15 MB 不等。采样事件是按字节加权的,不能拿条数当对象个数。

TLAB 帮不上忙的地方很明确:大对象。当对象尺寸超过 TLAB 剩余空间、且超过 -XX:TLABSize 能提供的比例时,HotSpot 走「直接在 Eden 里分配」的慢路径,JFR 里记成 jdk.ObjectAllocationOutsideTLAB:

jdk.ObjectAllocationOutsideTLAB {
  objectClass = byte[] (classLoader = bootstrap)
  allocationSize = 586.0 kB
  eventThread = "main" (javaThreadId = 3)
  stackTrace = [
    HumongousProbe.main(String[]) line: 12
  ]
}

586 KB 的数组,TLAB 只有 512 KB,注定走慢路径。更糟的是,在 G1 下它还会被判定为 humongous:本机 -Xmx512m 的日志里写着 Heap Region Size: 1M,而 G1 的 humongous 阈值是一个 region 的一半,即 512 KB。600 KB 的数组直接占掉整个 region,这部分留到 13.3 展开。

逃逸分析:没有「栈上分配」

接下来解释那个最容易误读的现象:写了一个分配很密集的方法,用 getThreadAllocatedBytes 一量,几乎是 0。

看下面这个对照探针。Point 是两字段 record,RING 是静态数组,escape 是 volatile 引用:

import com.sun.management.ThreadMXBean;
import java.lang.management.ManagementFactory;
import java.util.Locale;

public final class EscapeProbe {

    record Point(int x, int y) {}

    static volatile Object escape;
    static final Point[] RING = new Point[16];
    static volatile long sink;

    static ThreadMXBean bean() {
        return (ThreadMXBean) ManagementFactory.getThreadMXBean();
    }

    interface Body { long run(int n); }

    static void probe(String name, int iters, Body body) {
        for (int i = 0; i < 200_000; i++) {
            sink += body.run(1);
        }
        long b0 = bean().getThreadAllocatedBytes(Thread.currentThread().threadId());
        long acc = body.run(iters);
        long bytes = bean().getThreadAllocatedBytes(Thread.currentThread().threadId()) - b0;
        sink += acc;
        System.out.printf(Locale.ROOT,
                "%-26s bytes=%,12d bytesPerOp=%7.2f%n",
                name, bytes, bytes / (double) iters);
    }

    public static void main(String[] args) {
        int n = 3_000_000;

        probe("local no-escape", n, k -> {
            long s = 0;
            for (int i = 0; i < k; i++) { Point p = new Point(i, i + 1); s += p.x(); }
            return s;
        });

        probe("escape via volatile", n, k -> {
            long s = 0;
            for (int i = 0; i < k; i++) { Point p = new Point(i, i + 1); escape = p; s += p.y(); }
            return s;
        });

        probe("escape into ring", n, k -> {
            long s = 0;
            for (int i = 0; i < k; i++) { Point p = new Point(i, i + 1); RING[i & 15] = p; s += p.y(); }
            return s;
        });
    }
}

三组参数各跑一次,只改 JIT 的两个开关:

java -Xmx256m -XX:+UseG1GC -cp classes EscapeProbe
java -Xmx256m -XX:+UseG1GC -XX:-DoEscapeAnalysis -cp classes EscapeProbe
java -Xmx256m -XX:+UseG1GC -XX:-EliminateAllocations -cp classes EscapeProbe

本机输出汇总(每组各跑一次;local no-escape 是 300 万次迭代的均值,预热残留会计入分子,所以 EA 打开时不是严格的 0):

配置local no-escapeescape via volatileescape into ring
默认(EA on)0.07 B/次24.00 B/次24.00 B/次
-XX:-DoEscapeAnalysis24.07 B/次24.00 B/次24.00 B/次
-XX:-EliminateAllocations24.07 B/次24.00 B/次24.00 B/次

读法:

  • 对象只要逃逸(赋给 volatile 字段、写进静态数组),就必须在堆上分配,24 字节/次,与 JIT 开关无关。
  • 对象不逃逸时,C2 的逃逸分析(-XX:+DoEscapeAnalysis,默认开)判定它可以被标量替换(-XX:+EliminateAllocations,默认开),于是 Point 的两个字段直接变成两个局部变量,堆上没有任何对象。分配计数从 24.07 掉到 0.07,剩下的 0.07 是预热期的残留。
  • 关掉 DoEscapeAnalysis 或关掉 EliminateAllocations,任意一个都会让优化失效,回到 24 字节/次。

这里必须纠正一个流传很广的说法:HotSpot 没有「栈上分配」。对象不会真的被搬到线程栈上;C2 做的是标量替换——把对象拆成若干标量(基本类型)或直接消除,对象本身不再存在。所以「栈上分配」这个词在 HotSpot 语境下是错的,正确的是「逃逸分析 + 标量替换」。这也解释了为什么分配计数会变成 0,而不是「分配在栈上所以不计入堆」。

还有一个细节:标量替换只对能完全拆开的对象有效。数组只有在长度固定且元素不逃逸时才可能被整体消除(受 -XX:EliminateAllocationArraySizeLimit 约束,默认 64);一旦数组被写进静态字段,整条链上的对象都会逃逸。上面的 AllocLoop 里 RING[idx & 8191] = lines 这一句,就是让 Line[] 和三个 Line 都无法被消除的关键。

反过来,这也意味着用分配计数器排查「分配热点」是有盲区的:被标量替换掉的分配在计数器里根本不存在,而真正残留的分配往往就是那些逃逸到堆里的对象。看到 0 不要高兴太早,先确认对象是不是被优化掉了。

在 libraryd-conc 里落地

conc-ops 不对每个方法做微优化,而是把「分配预算」变成一条可观测的约束:

指标采集方式建议阈值处置
alloc.bytesPerRequest请求前后各读一次 getThreadAllocatedBytes,相减按接口基线定,超过 1.5 倍告警查该请求路径上的临时对象
alloc.rateMBps全进程 bytes / 时间,1 秒窗口超过 Eden 容量 / 5 秒限流或减少分配
gc.young.countGarbageCollectorMXBean.getCollectionCount()与分配速率联动看次数高但停顿短时,先降分配
gc.pause.avgMsjdk.GCPhasePause 聚合超过 SLA 的 1/10查单次停顿的构成
tlab.waste-Xlog:gc+tlab=trace 抽样长期 > 5%调整对象尺寸或 TLAB 参数

采集代码只做一件事:把「每次请求分配了多少」变成和 RT 同一个维度的指标。入口处记一次 getThreadAllocatedBytes(Thread.currentThread().threadId()),出口处再记一次,相减就是这次请求的分配字节数。难点不在 API,在于别让这层埋点自己变成开销。getThreadAllocatedBytes 本身很便宜(读 TLAB 的 top 指针,几次内存访问),但不要在每个请求里都包一层:它是一次 VM 调用,调用点之后分配快路径上的 TLAB 指针无法继续留在寄存器里,高频调用本身就会让分配变慢(按实现推导,本机未单独测量这部分开销)。做法是按采样率开,比如 1% 的请求采样,或者只在压测环境全量开。生产上更稳的做法是读 jdk.ThreadAllocationStatistics 事件(JFR 周期采样,开销极低),它给的是每个线程的分配速率而不是精确字节数。

常见错误与边界

  • 把 GC 次数高当成堆不够。 分配速率高、对象朝生夕死时,加堆只会让停顿变稀变更长,总回收成本不降。
  • 用 -XX:+PrintTLAB。 JDK 25 已移除,用 -Xlog:gc+tlab=trace。
  • 把 ObjectAllocationInNewTLAB 的条数当分配次数。 它是 refill 事件,条数与 TLAB 大小成反比。
  • 把 ObjectAllocationSample 的条数当对象个数。 它是按字节加权的采样,要看 weight 的和。
  • 看到分配计数是 0 就以为没有分配。 可能被标量替换掉了;用 -XX:-DoEscapeAnalysis 对照一次。
  • 把「栈上分配」当 HotSpot 的特性。 HotSpot 只有逃逸分析和标量替换,没有栈上分配对象。
  • 在高频路径上调用 getThreadAllocatedBytes。 会干扰 TLAB 的寄存器缓存,采样率要控制。
  • 给大对象调大 TLAB。 超过 TLAB 尺寸的对象走慢路径,调大 TLAB 只会浪费 Eden 空间。
  • 只盯 bytesPerOp 不看存活时间。 分配字节数大但对象立刻死,和分配字节数中等但对象活到老年代,代价完全不同。

小结

分配速率是比 GC 次数更靠前的指标。本机实测:单线程订单对象构造路径能跑到 9.4 GB/s、184 字节/次,10 秒触发 300 次 Young GC,但平均停顿只有 1.54 毫秒、总计占 4.6% 的墙钟时间。这说明「GC 次数多」不等于「GC 有问题」,真正的判据是停顿占比和尾延迟。

TLAB 让分配退化成指针加法,用 -Xlog:gc+tlab=trace 能看到 512 KB 的目标尺寸、64 KB 的起步尺寸和 0.2% 的尾部浪费;超过 TLAB 尺寸的对象走 ObjectAllocationOutsideTLAB,在 G1 下还可能直接变成 humongous。逃逸分析则解释了另一个方向的误判:不逃逸的对象会被标量替换,分配计数归零,而 HotSpot 并不存在「栈上分配」。

下一节把视角从「分配了多少」转到「回收时并发线程在干什么」:并发标记阶段的写屏障、ConcGCThreads 的 CPU 份额,以及它到底会不会影响业务线程的尾延迟。

阅读导航:上一节 12.3 告警阈值与容量水位 | 下一节 13.2 并发标记与业务线程

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收