04.4 - 分配策略(TLAB / 栈上分配 / 大对象直接进入老年代)
定位: JVM 为提高对象分配性能而做的三大关键优化——让"new 对象"接近"栈分配"的效率 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: TLAB 原理与参数、逃逸分析判断规则、标量替换如何消除对象、PretenureSizeThreshold
一、这是什么?为什么需要它?
对象分配是 JVM 最频繁的操作之一。一个 Web 服务器每秒可能创建数百万个对象。如果每次 new 都要 CAS 竞争堆指针、每次对象销毁都要 GC 扫描、大对象的复制开销巨大,性能会不可接受。
JVM 为此实现了三层优化,从底层到高层:
优化层级 核心思路 收益
┌──────────────┐ ┌──────────────────────┐ ┌──────────────────┐
│ TLAB (堆层) │ │ 线程私有分配缓冲区, │ │ 消除线程间竞争 │
│ │ │ 大多数 new 无同步 │ │ 2-3 倍分配加速 │
├──────────────┤ ├──────────────────────┤ ├──────────────────┤
│ 大对象直接 │ │ 超大对象避开 Eden 复制 │ │ 减少 Young GC │
│ 进入老年代 │ │ 直接分配到 Old │ │ 复制开销 │
├──────────────┤ ├──────────────────────┤ ├──────────────────┤
│ 栈上分配 + │ │ 逃逸分析+标量替换: │ │ 零 GC 回收! │
│ 标量替换 │ │ 对象在栈上甚至寄存器 │ │ 完全消除堆分配 │
│ (JIT 层) │ │ 存活, 不再需要 GC │ │ 性能最优 │
└──────────────┘ └──────────────────────┘ └──────────────────┘核心矛盾:堆上对象越多 → GC 压力越大 → STW 越长。这三层优化都在试图"减少堆上的对象数量"或者"减少堆分配的同步开销"。
二、原理拆解
2.1 TLAB —— 线程本地分配缓冲区
为什么需要 TLAB?
无 TLAB: 每个 new 都竞争 Eden 分配指针
Thread A: new Object() → CAS(指针, 指针+16) 成功
Thread B: new Object() → CAS(指针, 指针+16) 失败 → 重试 CAS
Thread C: new Object() → CAS(指针, 指针+16) 失败 → 重试 CAS → 失败 → 重试...
↑
多线程 CAS 冲突严重所有线程共享同一个 Eden 空间,如果每次分配都 CAS(Compare And Swap)竞争分配指针,高并发下 CAS 冲突和重试浪费大量 CPU 周期。
TLAB 如何解决?
TLAB(Thread Local Allocation Buffer)是 Eden 区中被线程"专享"的小块内存,每个线程有自己的私有分配区域:
Eden 空间
┌─────────────────────────────────────────────────────────────────────┐
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ TLAB T1 │ │ TLAB T2 │ │ TLAB T3 │ │ 共享区域 │
│ │ (Eden 1%) │ │ (Eden 1%) │ │ (Eden 1%) │ │ (大对象/TLAB耗尽后)│
│ │ Thread 1 │ │ Thread 2 │ │ Thread 3 │ │ │
│ └───────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────────┘分配流程:
线程想要 new 一个对象
│
▼
┌─ TLAB 剩余空间是否足够? ──── 足够 → 在 TLAB 内指针 bump (零同步!)
│
▼ (不够)
┌─ 剩余空间是否 < TLABWasteTargetPercent? ─── 否 → 当前 TLAB"浪费", 申请新 TLAB
│ (CAS 抢占 Eden)
▼ (是, 剩余空间太小不值得浪费)
┌─ 直接在 Eden 共享区域分配 (CAS + 失败重试)
│
▼
对象分配完成为什么 TLAB 内分配零同步:每个线程只在自己的 TLAB 内操作。TLAB 是线程私有的——其他线程无法访问这块内存。在自己的私有区域内移动指针不需要任何锁或 CAS。
TLAB 的浪费问题
TLAB 默认占 Eden 的 1%。如果一个线程的 TLAB 还剩 300 字节,但下一个要分配的对象需要 1024 字节:
- 方案 A:在当前 TLAB 分配 → 空间不足,失败
- 方案 B:浪费这 300 字节,申请新 TLAB
JVM 选方案 B。但 300 字节的碎片就浪费了。如果很多线程都类似,总浪费会很大。-XX:TLABWasteTargetPercent=1(默认值 1%)控制总 TLAB 浪费占 Eden 的比例。
# TLAB 相关参数
-XX:+UseTLAB # 启用 TLAB (JDK 8 默认开启)
-XX:TLABSize=2m # 设置 TLAB 大小 (默认动态)
-XX:-ResizeTLAB # 禁用自动调整大小
-XX:TLABWasteTargetPercent=1 # TLAB 浪费目标占比 (默认 1%)
-XX:+PrintTLAB # 打印 TLAB 使用情况 (JDK 8 日志)2.2 大对象直接进入老年代
为什么需要这个机制?
场景: 一个 10MB 的大数组
Young GC 时对象复制路径:
Eden(10MB) → 复制到 S0(10MB) → S0 满了(装不下) → 直接进 Old
↑ 复制 10MB 数据的成本
如果这个对象活过了 3 次 GC:
Eden → S0(复制10MB) → S1(再复制10MB) → S0(再复制10MB) → Old
↑ 总共复制了 30MB 的数据!大对象在年轻代之间复制的代价是"线性成本"——每次复制都要搬运全部数据。小对象(几十字节)复制几百次都没关系,但大对象(几 MB)复制几次就很昂贵。
PretenureSizeThreshold 参数
# 设置大对象阈值: 超过 1MB 的对象直接进入老年代
-XX:PretenureSizeThreshold=1mEden 空间 老年代 (Old)
┌──────────────────────┐ ┌──────────────────────────────┐
│ ┌────┐┌───┐┌───┐ │ │ │
│ │小对││小 ││小 │ │ │ ┌─────────────────────────┐ │
│ │象A ││B ││C │ │ │ │ 大对象 (10MB 数组) │ │
│ └────┘└───┘└───┘ │ │ │ 直接进入老年代 │ │
│ │ │ └─────────────────────────┘ │
└──────────────────────┘ └──────────────────────────────┘需要注意的是:-XX:PretenureSizeThreshold 只对 Serial 和 ParNew 有效,对 Parallel Scavenge 无效。G1 有自己的大对象处理机制(Humongous Object)。ZGC/Shenandoah 也有各自的大对象分配策略。
G1 中的大对象:超过 Region 大小 50% 的对象视为 Humongous Object,直接分配到连续的 Humongous Region 中,类似大对象直接进入老年代的思想。
2.3 栈上分配与标量替换(JIT 优化)
逃逸分析 (Escape Analysis)
这是 JVM 中最精妙的优化之一。逃逸分析判定:对象是否逃逸出当前方法的作用域?
不逃逸 (No Escape):
┌──────────────────────────────────────┐
│ public void doSomething() { │
│ Point p = new Point(1, 2); │ ← p 只在方法内使用
│ int sum = p.x + p.y; │ 方法结束就不可达
│ System.out.println(sum); │
│ } │
└──────────────────────────────────────┘
→ 这个 Point 对象可以分配到栈上甚至寄存器
逃逸 (Escape):
┌──────────────────────────────────────┐
│ public Point createPoint(int x, int y){│
│ return new Point(x, y); │ ← 对象被返回给调用者
│ } │ → 调用者栈帧结束后还可能存活
│ │
│ public void caller() { │
│ Point p = createPoint(1, 2); │ ← p 可能再被传给其他人
│ other.method(p); │
│ } │
└──────────────────────────────────────┘
→ 必须分配到堆上逃逸分析的判定级别:
逃逸级别:
不逃逸 (NoEscape) → 可以栈上分配 + 标量替换
方法逃逸 (ArgEscape) → 参数逃逸(参数引用被外部使用), 无法栈上分配
全局逃逸 (GlobalEscape) → 赋值给静态变量或跨线程, 必须堆分配逃逸分析是分析的"前提",不是分配的"方式"。分析出对象不逃逸后,JIT 做两个优化:
栈上分配 (Stack Allocation)
如果对象不逃逸,JIT 编译器将其分配在栈帧(或寄存器)而不是堆上:
常规堆分配: 栈上分配:
┌───── 栈 ─────┐ ┌───── 栈 ─────┐
│ doSomething()│ │ doSomething()│
│ 局部变量表: │ │ 局部变量表: │
│ p ─────→ │ │ p → 栈上对象 │
│ │ │ │
└───────────────┘ └──────────────┘
│ │
▼ │
┌───── 堆 ─────┐ 方法返回时栈帧弹出
│ Point对象 │ → 对象自动销毁
│ 等 GC 回收 │ 零 GC 开销!
└───────────────┘优势:栈帧随方法调用弹出而自动销毁——栈上分配的对象不需要 GC 介入。这是"零 GC 开销"的分配方案。
标量替换 (Scalar Replacement) —— 比栈上分配更强
标量替换更进一步:不分配对象,直接把对象的字段拆成独立的局部变量:
优化前 (原始代码):
┌──────────────────────────────────────────────┐
│ class Point { │
│ int x; │
│ int y; │
│ } │
│ │
│ void doSomething() { │
│ Point p = new Point(1, 2); │ ← 分配对象
│ int sum = p.x + p.y; │ ← 访问字段
│ } │
└──────────────────────────────────────────────┘
标量替换后 (JIT 编译器生成):
┌──────────────────────────────────────────────┐
│ void doSomething() { │
│ int x = 1; // p.x → 局部变量 x │ ← 没有对象了!
│ int y = 2; // p.y → 局部变量 y │
│ int sum = x + y; │
│ } │
└──────────────────────────────────────────────┘标量替换的核心:
- 聚合量 (Aggregate):对象是聚合量——它把多个标量(int, long, ref 等)聚合成一个整体
- 标量 (Scalar):基本类型字段是可分解的独立单元
- 替换:对象不逃逸时,JIT 把聚合量打散成标量,在栈(或寄存器)上单独操作每个字段
标量替换比栈上分配更强:栈上分配只是换了个地方创建对象,对象头还在(MarkWord + KlassPtr),有内存开销。标量替换连对象头都省了,直接在寄存器中操作 x 和 y,速度最快。
效果对比例子:
原始代码创建 1000 万个 Point 并求和:
标量替换: ~5ms (寄存器/栈上直接计算, 无对象创建)
栈上分配: ~15ms (创建了对象但有对象头, 无需 GC)
堆分配+GC: ~200ms (堆上创建 + GC 回收)
→ 标量替换可以比堆分配快 40 倍!逃逸分析相关参数
-XX:+DoEscapeAnalysis # 启用逃逸分析 (JDK 6u23 起默认开启)
-XX:-DoEscapeAnalysis # 禁用逃逸分析
-XX:+EliminateAllocations # 启用标量替换 (默认开启, 依赖逃逸分析)
-XX:-EliminateAllocations # 禁用标量替换
-XX:+PrintEscapeAnalysis # 打印逃逸分析结果 (JDK 8 需用 -XX:+UnlockDiagnosticVMOptions)三、图解全景
┌─────────────────────────────────────────────────────────────────────────┐
│ 对象分配完整决策流程图 │
│ │
│ new 指令发起 │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ ① JIT 逃逸分析 │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ 对象是否逃逸方法? │ │ │
│ │ │ ├─ 不逃逸 (NoEscape) │ │ │
│ │ │ │ ↓ │ │ │
│ │ │ │ 标量替换 → 彻底消除对象, 字段分解为 │ │ │
│ │ │ │ 局部变量 (最快!) │ │ │
│ │ │ │ │ │ │
│ │ │ └─ 参数逃逸/方法逃逸 (ArgEscape) │ │ │
│ │ │ ↓ │ │ │
│ │ │ 栈上分配 (如果 JIT 决定) │ │ │
│ │ │ ↓ │ │ │
│ │ │ → 仍需堆分配, 进入下一步 │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ ② 大对象检查 │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ 对象大小 > PretenureSizeThreshold? │ │ │
│ │ │ ├─ 是 → 直接在老年代分配 (避免年轻代复制) │ │ │
│ │ │ └─ 否 → 进入年轻代分配 │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ ③ TLAB 分配 (年轻代 Eden) │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ 当前线程 TLAB 剩余空间是否足够? │ │ │
│ │ │ ├─ 足够 → TLAB 内指针 bump (零同步!) │ │ │
│ │ │ │ → 分配完成 │ │ │
│ │ │ │ │ │ │
│ │ │ └─ 不够 → 检查 TLABWasteTargetPercent │ │ │
│ │ │ ├─ 浪费不超限 → 当前 TLAB 作废 │ │ │
│ │ │ │ CAS 申请新 TLAB │ │ │
│ │ │ └─ 浪费超限 → Eden 共享区域 │ │ │
│ │ │ CAS 分配 │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 对象分配完成 │
└─────────────────────────────────────────────────────────────────────────┘TLAB 在堆中的位置:
┌─────────────────────────────────────────────────────────────────────────┐
│ Eden │
│ ┌─────────────┬─────────────┬─────────────┬─────────────────────────┐ │
│ │ TLAB T1 │ TLAB T2 │ TLAB T3 │ │ │
│ │ (Thread 1) │ (Thread 2) │ (Thread 3) │ 共享区域 (Eden 中) │ │
│ │ 指针: │→ │ 指针: │→ │ 指针: │→ │ 大对象或 TLAB 耗尽时 │ │
│ │ │ │ │ 用 CAS 从这里分配 │ │
│ └─────────────┴─────────────┴─────────────┴─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘四、实战验证
验证 1: 逃逸分析效果演示
public class EscapeAnalysisDemo {
// 无逃逸——可被标量替换优化
public static long sumAgesNoEscape() {
long sum = 0;
for (int i = 0; i < 10_000_000; i++) {
// Person 对象只在方法内使用, 不逃逸
Person p = new Person(i % 100, "User" + i);
sum += p.getAge();
}
return sum;
}
// 有逃逸——必须堆分配
public static long sumAgesEscape(Person[] arr) {
long sum = 0;
for (int i = 0; i < arr.length; i++) {
// arr 来自外部, 对象被赋值到逃逸的数组中
arr[i] = new Person(i % 100, "User" + i);
sum += arr[i].getAge();
}
return sum;
}
static class Person {
private int age;
private String name;
public Person(int age, String name) {
this.age = age;
this.name = name;
}
public int getAge() { return age; }
}
public static void main(String[] args) {
// 预热
for (int i = 0; i < 100_000; i++) {
sumAgesNoEscape();
}
long start1 = System.nanoTime();
long r1 = sumAgesNoEscape();
long time1 = System.nanoTime() - start1;
Person[] arr = new Person[10_000_000];
long start2 = System.nanoTime();
long r2 = sumAgesEscape(arr);
long time2 = System.nanoTime() - start2;
System.out.println("无逃逸 (标量替换): " + time1 / 1_000_000 + "ms");
System.out.println("有逃逸 (堆分配) : " + time2 / 1_000_000 + "ms");
System.out.println("差距: " + (time2 - time1) / 1_000_000 + "ms");
System.out.println(r1 + ", " + r2 + ", arr[0]=" + arr[0]);
}
}# 启用逃逸分析 (默认开启)
java -XX:+DoEscapeAnalysis -XX:+EliminateAllocations EscapeAnalysisDemo
# 预期: 无逃逸 ~15ms, 有逃逸 ~200ms
# 禁用逃逸分析
java -XX:-DoEscapeAnalysis EscapeAnalysisDemo
# 预期: 无逃逸也变成 ~200ms (所有 Person 都在堆上分配)验证 2: TLAB 效果演示
# 创建大量临时对象, 对比 TLAB 开启/关闭
# 启用 TLAB (默认)
java -XX:+UseTLAB AllocationBenchmark
# 输出: 创建 10,000,000 个对象耗时: ~180ms
# 禁用 TLAB (所有分配走 CAS)
java -XX:-UseTLAB AllocationBenchmark
# 输出: 创建 10,000,000 个对象耗时: ~420ms验证 3: PretenureSizeThreshold 效果
# 不设大对象阈值: 10MB 数组在 Eden 分配
java -Xms20m -Xmx20m -XX:+PrintGCDetails BigObjectTest
# GC 日志显示: 大对象在 Eden 分配, 然后触发 GC
# 设置大对象阈值: 10MB 数组直接进老年代
java -Xms20m -Xmx20m -XX:PretenureSizeThreshold=1m -XX:+PrintGCDetails BigObjectTest
# GC 日志显示: 老年代直接增加了 10MB, 无 Young GC五、面试视角
| 追问 | 答案要点 |
|---|---|
| 什么是逃逸分析? | JIT 编译器分析对象引用是否逃逸出方法作用域。分三级:不逃逸(NoEscape)、参数逃逸(ArgEscape)、全局逃逸(GlobalEscape)。 |
| 栈上分配和标量替换有什么区别? | 栈上分配是把对象分配到栈帧而不是堆上(仍有对象头开销);标量替换更彻底——直接把对象字段拆成独立局部变量,完全消除对象。标量替换速度更快。 |
| 逃逸分析是必须的吗? | 是标量替换/栈上分配的前提。没有逃逸分析,JIT 无法安全地将对象分配到栈上或消除对象。 |
| TLAB 是什么?为什么需要? | Thread Local Allocation Buffer,Eden 中线程私有的分配缓冲区。消除多线程并发 new 对象时 CAS 竞争。TLAB 内分配只需指针 bump。 |
| TLAB 有浪费吗? | 有。TLAB 剩余空间不足分配新对象时会被"作废",造成空间浪费。-XX:TLABWasteTargetPercent 控制总浪费比例(默认 1%)。 |
| 什么对象会直接进入老年代? | 大小超过 -XX:PretenureSizeThreshold 的对象(仅 Serial/ParNew 有效)。避免大对象在年轻代间频繁复制带来的开销。 |
| G1 中的大对象怎么处理? | 超过 Region 大小 50% 的对象作为 Humongous Object,分配到连续的 Humongous Region 中。类似大对象直接进老年代的思想但实现不同。 |
一个方法内 new 的对象一定在栈上吗? | 不一定。逃逸分析判定对象不逃逸(且满足内联条件)才能被优化。如果方法被内联到更大的调用链中,逃逸判断范围也随之扩大。 |
📚 相关链接
- **对象创建过程** — 对象创建流程中分配步骤的详细说明
- **Java堆** — TLAB 在堆中的布局和 Survivor 区分配
- **方法内联和逃逸分析** — JIT 逃逸分析的深入原理和编译器实现
- **垃圾收集器详解** — 不同 GC 对分配策略的影响
- ← 返回 **对象分配索引**