2440 字
约 8 分钟
14
S05-X16 · 对象分配路径:TLAB、栈上分配与逃逸分析的边界
2024-12-31
2026-11-08

new 之后对象到底落在哪,是 JVM 里被误解最深的一段。"对象都在堆上""小对象先在栈上分配""逃逸分析会自动把对象放栈里"——这三句话分别对了一部分,又都错了关键部分。把路径拆开看,很多调优直觉会立刻被证伪。


1. new 之后走过的路径

阶段 判定 落点 判定者
① 逃逸分析 对象未逃逸且可标量替换 不分配,字段拆成局部变量 C2
② TLAB 快速路径 当前线程 TLAB 有足够空间 TLAB(Eden 内) 分配器
③ Eden 共享区 TLAB 不够,共享 Eden 有余量 Eden 分配器(需同步)
④ 直接进老年代 对象过大(PretenureSizeThreshold,仅部分 GC) Old 分配器
⑤ 晋升 存活够久 / Survivor 满 Old GC

注意 ① 不是"栈上分配",而是根本不分配——这是后面所有讨论的基础。② 是绝大多数对象的实际落点。


2. TLAB:每线程一块私有 Eden

TLAB(Thread Local Allocation Buffer)解决的是"多线程同时分配同一块 Eden"的竞争问题。每个线程从 Eden 里划走一小块归自己用,分配只需指针碰撞(bump-the-pointer),无需 CAS。

参数 含义 默认(JDK 17)
-XX:+UseTLAB 启用 TLAB 开启
-XX:TLABSize 显式指定大小 0(自动推导)
-XX:TLABWasteTargetPercent 允许的 TLAB 内部浪费比例 1
-XX:MinTLABSize 最小 TLAB 平台相关
-XX:+ResizeTLAB 允许运行期调整 开启

TLAB 内分配失败(剩余空间不够)有两条分支:

情况 处理
对象小于 TLABWasteTargetPercent 允许的浪费阈值 在当前 TLAB 里"浪费"剩余空间,直接 refill 新 TLAB
对象大于阈值但小于 TLAB 大小的一半 直接在 Eden 共享区分配(走慢路径)
对象大于 TLAB 一半 直接 Eden 或老年代

所以"分配失败"不等于"内存不够",多数情况只是 refill 一次。

$ java -XX:+PrintTLAB -XX:+UnlockDiagnosticVMOptions -cp /tmp/out Alloc

典型输出形态(字段固定):

TLAB: gc thread: 12345 refill waste: 8192 alloc: 0.9932 4096/8192 refills: 412 waste 1%
TLAB: gc thread: 12345 slow allocs: 3 % of alloc: 0.7 waste 0%

refills 是扩容次数,waste 是浪费比例,slow allocs 是走 Eden 共享区的次数。浪费比例长期偏高,说明 TLAB 相对对象尺寸偏小。


3. "栈上分配"严格说是标量替换

HotSpot 没有真正的栈上分配。所谓"栈上分配"是逃逸分析 + 标量替换的组合效果:

概念 参数 实际动作
逃逸分析 -XX:+DoEscapeAnalysis(默认开) 分析对象引用是否逃出方法/线程
标量替换 -XX:+EliminateAllocations(默认开) 把对象字段拆成独立局部变量
同步消除 -XX:+EliminateLocks(默认开) 去掉不逃逸对象上的锁
void hot() {
    Point p = new Point(1, 2);   // 未逃逸
    int s = p.x + p.y;           // 替换后等价于 int s = 1 + 2;
}

优化后根本没有 Point 对象,也就谈不上"分配在栈上"。这正是 -XX:-DoEscapeAnalysis 一关,分配量暴涨的原因——不是"从栈搬到堆",而是从"没有对象"变成"每次 new 一个对象"。


4. 逃逸的三种状态与判定限制

状态 含义 能否优化
不逃逸 NoEscape 引用不传出方法 可标量替换、可锁消除
方法逃逸 ArgEscape 作为参数传出但方法内不存 部分优化(不替换)
全局逃逸 GlobalEscape 存入静态字段、返回、进集合 不可优化

判定失效的常见原因:

限制 例子 为什么
虚调用 对象传给接口方法 目标不确定,视为逃逸
存入全局结构 list.add(p) 引用进入堆上的可达图
返回对象 return new Point() 逃出方法
同步块 对象被 synchronized 使用(旧版本) 需要稳定的对象身份
构造器内 this 逸出 构造器里注册监听器 对象在构造期已可达
方法太大未内联 调用链未展开 分析范围受限

最后一条最容易被忽略:逃逸分析的精度依赖内联。如果 new 所在的调用链没被内联,C2 看不到跨方法的使用,只能保守判定为逃逸。


5. 大对象与 PretenureSizeThreshold 的坑

参数 语义 G1 下是否生效
-XX:PretenureSizeThreshold 超过此大小的对象直接在老年代分配 不生效
-XX:G1HeapRegionSize Region 大小 生效
Humongous 阈值 ≥ Region 的 1/2 生效(等效替代品)

这是一个高频错误:把 Parallel/Serial 时代的 -XX:PretenureSizeThreshold=1m 搬到 G1 上,参数被接受但不起作用——G1 用 Humongous Region 机制代替了"大对象直入老年代"。

$ java -XX:+UseG1GC -XX:PretenureSizeThreshold=1m -Xlog:gc+heap=info -jar app.jar
# 该参数在 G1 下无对应行为;用以下方式观察大对象
$ java -XX:+UseG1GC -Xlog:gc+humongous=debug -jar app.jar

6. 分配速率与 GC 频率

分配速率(allocation rate)是比堆占用更根本的指标:

速率 现象 处理方向
低(< 100MB/s) Young GC 稀疏 无需动作
中(数百 MB/s) Young GC 频繁但停顿短 增大新生代或降低临时对象
高(> 1GB/s) Young GC 密集、晋升压力大 先找分配热点,再谈调参

观测方式:

$ jcmd <pid> GC.heap_info
$ jstat -gc <pid> 1000 10        # 每秒一次,看 EU/OU 的增长
$ jcmd <pid> GC.class_histogram | head -15

如果 [B(byte 数组)与 String 长期占据前两位,通常意味着序列化/日志/HTTP 报文在制造临时对象——这是分配速率的常见来源,改代码比调 GC 有效得多。


7. 常见坑

坑 现象 真实原因 处理
以为 new 一定进堆 反编译看到对象被"消除"就困惑 标量替换后无对象 用 -XX:-DoEscapeAnalysis 对照
以为"栈上分配"是真栈分配 找不到栈上的对象布局 HotSpot 无此机制 理解为标量替换
一开 -XX:-DoEscapeAnalysis 就"优化" 分配量涨、GC 变多 关掉了优化 只在验证优化效果时关
无证据就用对象池 吞吐下降、GC 更多 池本身持有引用、破坏 TLAB 快速路径、增加同步 先测分配速率再决定
G1 下设 PretenureSizeThreshold 参数"生效"但无行为 G1 用 Humongous 代替 改 G1HeapRegionSize
误判 TLAB 浪费为泄漏 waste 高 TLAB 粒度与对象尺寸不匹配 调 TLABWasteTargetPercent

"对象池反而更慢"这条值得展开:池化把"指针碰撞分配"换成"取用/归还 + 状态管理",在 TLAB 路径上分配本来就只有几条指令;一旦池需要同步、需要清理状态、需要处理生命周期,成本立刻超过 GC 的代价。只有在对象构造极贵(如解析大报文、建立连接)时,池才有意义。


8. 排错

现象 原因 处理
Young GC 极频繁但每次回收量小 分配速率高、Eden 小 提高分配速率可见度,找热点
分配速率正常但晋升量异常大 对象存活时间刚好跨过晋升阈值 调 MaxTenuringThreshold 或查缓存驻留
TLAB refills 极高 TLAB 太小或对象尺寸分布偏大 调 TLABSize / TLABWasteTargetPercent
关逃逸分析后性能暴跌 证明原代码大量依赖标量替换 保持默认开启,勿在生产关闭
大对象引发频繁 Humongous 回收 对象 ≥ Region/2 增大 Region 或拆分对象
# 验证某段代码是否真的减少了分配
$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintTLAB -cp /tmp/out Alloc | tail -3

9. 边界

场景 关注点 理由
高吞吐服务 分配速率 + TLAB 浪费 直接决定 GC 频率
低延迟服务 晋升速率 + Humongous 影响单次停顿
短生命周期任务 基本不用管 进程结束即释放
大量临时对象(序列化/日志) 改代码优先 调参只能缓解
对象构造昂贵 池化可能有益 但必须先量化构造成本

10. 自测

  1. 一个 new 出来的对象,在什么情况下根本不会被分配?这个优化叫什么?
  2. TLAB 分配失败有哪几条分支,"分配失败"是否等于内存不足?
  3. 为什么说 HotSpot 没有真正的栈上分配,-XX:-DoEscapeAnalysis 关闭后到底发生了什么变化?
  4. 逃逸分析的精度为什么依赖内联?哪些写法会让对象被判为全局逃逸?
  5. -XX:PretenureSizeThreshold 在 G1 下为什么不生效,等效机制是什么?
  6. 分配速率与 GC 频率是什么关系,为什么"无证据就用对象池"常常更慢?
S05-X16 · 对象分配路径:TLAB、栈上分配与逃逸分析的边界
https://www.clxhxhhr.top/posts/5767/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。