Skip to content

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 的比例。

bash
# 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 参数

bash
# 设置大对象阈值: 超过 1MB 的对象直接进入老年代
-XX:PretenureSizeThreshold=1m
Eden 空间                      老年代 (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;                          │
│ }                                              │
└──────────────────────────────────────────────┘

标量替换的核心

  1. 聚合量 (Aggregate):对象是聚合量——它把多个标量(int, long, ref 等)聚合成一个整体
  2. 标量 (Scalar):基本类型字段是可分解的独立单元
  3. 替换:对象不逃逸时,JIT 把聚合量打散成标量,在栈(或寄存器)上单独操作每个字段

标量替换比栈上分配更强:栈上分配只是换了个地方创建对象,对象头还在(MarkWord + KlassPtr),有内存开销。标量替换连对象头都省了,直接在寄存器中操作 xy,速度最快。

效果对比例子:

原始代码创建 1000 万个 Point 并求和:

标量替换:    ~5ms    (寄存器/栈上直接计算, 无对象创建)
栈上分配:    ~15ms   (创建了对象但有对象头, 无需 GC)
堆分配+GC:   ~200ms  (堆上创建 + GC 回收)

→ 标量替换可以比堆分配快 40 倍!

逃逸分析相关参数

bash
-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: 逃逸分析效果演示

java
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]);
    }
}
bash
# 启用逃逸分析 (默认开启)
java -XX:+DoEscapeAnalysis -XX:+EliminateAllocations EscapeAnalysisDemo
# 预期: 无逃逸 ~15ms, 有逃逸 ~200ms

# 禁用逃逸分析
java -XX:-DoEscapeAnalysis EscapeAnalysisDemo
# 预期: 无逃逸也变成 ~200ms (所有 Person 都在堆上分配)

验证 2: TLAB 效果演示

bash
# 创建大量临时对象, 对比 TLAB 开启/关闭

# 启用 TLAB (默认)
java -XX:+UseTLAB AllocationBenchmark
# 输出: 创建 10,000,000 个对象耗时: ~180ms

# 禁用 TLAB (所有分配走 CAS)
java -XX:-UseTLAB AllocationBenchmark
# 输出: 创建 10,000,000 个对象耗时: ~420ms

验证 3: PretenureSizeThreshold 效果

bash
# 不设大对象阈值: 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 对分配策略的影响
  • ← 返回 **对象分配索引**

Knowledge4J — Java 知识库