承接的问题
第 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 8 | 32 |
Line(String, int) × 3 | (12 + 4 + 4) 对齐到 24 | 72 |
Line[3] | 数组头 16 + 3 × 4 引用 | 32 |
"ord-" + n | String 24 + byte[] 24 | 48 |
| 合计 | 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-escape | escape via volatile | escape into ring |
|---|---|---|---|
| 默认(EA on) | 0.07 B/次 | 24.00 B/次 | 24.00 B/次 |
-XX:-DoEscapeAnalysis | 24.07 B/次 | 24.00 B/次 | 24.00 B/次 |
-XX:-EliminateAllocations | 24.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.count | GarbageCollectorMXBean.getCollectionCount() | 与分配速率联动看 | 次数高但停顿短时,先降分配 |
gc.pause.avgMs | jdk.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 并发标记与业务线程
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。