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. 自测
- 一个
new出来的对象,在什么情况下根本不会被分配?这个优化叫什么? - TLAB 分配失败有哪几条分支,"分配失败"是否等于内存不足?
- 为什么说 HotSpot 没有真正的栈上分配,
-XX:-DoEscapeAnalysis关闭后到底发生了什么变化? - 逃逸分析的精度为什么依赖内联?哪些写法会让对象被判为全局逃逸?
-XX:PretenureSizeThreshold在 G1 下为什么不生效,等效机制是什么?- 分配速率与 GC 频率是什么关系,为什么"无证据就用对象池"常常更慢?