内存分配策略:TLAB、Eden、晋升、年龄阈值和大对象分配(063)

JVM原理与调优约 16 分钟阅读8,927 字符
知识库文档 64 / 251

一、从一个"简单"的 new 说起

java
User user = new User("张三", 25);

这行代码背后,JVM 做了至少五件事:类加载检查 → 计算对象大小 → 从堆中分配内存 → 零值初始化 → 设置对象头 → 执行构造函数。其中"分配内存"这一步看似简单,但在高并发场景下,如果所有线程都去堆上抢同一块内存区域,分配就会变成严重的性能瓶颈。

这正是 JVM 设计 TLAB(Thread Local Allocation Buffer)的动机——让每个线程在自己的"自留地"里分配对象,避免线程间竞争。当 TLAB 耗尽或对象太大不适合 TLAB 时,才退回到在堆的共享区域(Eden)中分配。而对象在经历多次 GC 后如何"晋升"到老年代、大对象如何绕过年轻代直接进老年代,以及年龄阈值如何动态调整——这些策略共同构成了 JVM 内存分配的核心体系。

本文将沿着"一条对象分配的完整生命周期"展开:从 TLAB 快速分配到 Eden 共享分配,再到 Survivor 区的年龄计数、动态晋升判断,最后到大对象的特殊处理路径。

二、TLAB:线程的"私有高速缓存"

2.1 为什么需要 TLAB

堆是所有线程共享的。如果每次 new 都要去堆上通过 CAS 操作抢占一块内存,在每秒数百万次分配的并发场景下,CAS 竞争会迅速成为瓶颈。JVM 的解决方案是 TLAB:每个线程在 Eden 区预先申请一小块专属内存(默认占 Eden 的 1%),线程在 TLAB 内分配对象时不需要任何同步操作,只需移动一个指针即可。

这种设计本质上是"用空间换时间"——TLAB 内的内存可能因为碎片化而浪费一部分,但换来了近乎零成本的分配速度。指针碰撞(Bump the Pointer)只需将 TLAB 的 top 指针向后移动 对象大小 个字节,这比 CAS 操作快一个数量级。

2.2 TLAB 的分配流程

[图1:TLAB 分配流程时序图——应用线程→TLAB(指针碰撞,无锁)→Eden(CAS申请新TLAB)→触发Minor GC→大对象直接进老年代]

文章配图
文章配图

2.3 TLAB 的核心参数

参数 默认值 含义
-XX:+UseTLAB JDK 8+ 默认开启 启用 TLAB(建议永远不要关闭)
-XX:TLABSize 自动计算 指定 TLAB 大小(一般不需要手动设)
-XX:-ResizeTLAB 默认允许自适应 禁止 JVM 自适应调整 TLAB 大小
-XX:TLABWasteTargetPercent 1% TLAB 允许的浪费空间比例
-XX:+PrintTLAB 关闭 打印 TLAB 使用统计(仅调试用)

TLAB 的浪费是怎样产生的:假设 TLAB 还剩 100 字节,但当前要分配的对象需要 200 字节,这 100 字节就"浪费"了(成为填充区),线程需要申请新的 TLAB。TLABWasteTargetPercent 控制这种浪费的上限——JVM 会根据这个参数动态调整每个线程的 TLAB 大小,在分配速度和空间利用率之间取得平衡。

一个常见的误区:有人以为关闭 TLAB 可以节省内存。实际上,关闭 TLAB 后所有分配都要走 CAS 路径,分配速度会下降 10-50 倍,在高并发场景下这是灾难性的。除非你在做极小规模的嵌入式应用,否则永远不要关闭 TLAB。

2.4 TLAB 在源码中的实现

在 HotSpot 源码中,TLAB 的核心数据结构定义在 threadLocalAllocBuffer.hpp 中:

cpp
class ThreadLocalAllocBuffer: public CHeapObj<mtThread> {
  HeapWord* _start;    // TLAB 起始地址
  HeapWord* _top;      // 当前分配指针(空闲区域起点)
  HeapWord* _end;      // TLAB 结束地址
  size_t    _desired_size;  // 期望的 TLAB 大小
  size_t    _refill_waste_limit; // 填充浪费上限
  // ...
};

分配操作的核心逻辑非常简洁:

cpp
inline HeapWord* ThreadLocalAllocBuffer::allocate(size_t size) {
  HeapWord* obj = top();
  if (pointer_delta(end(), obj) >= size) {
    // TLAB 空间足够,直接指针碰撞
    set_top(obj + size);
    return obj;
  }
  return NULL; // TLAB 不够,走慢路径
}

这就是 TLAB 分配如此快的原因——在快速路径上,它只是一次边界检查 + 一次指针赋值,没有锁、没有 CAS、没有函数调用层级。

三、Eden 区:对象分配的"主战场"

3.1 Eden 的结构与特点

当 TLAB 耗尽后,线程需要从 Eden 区的共享空间中申请一块新的 TLAB。Eden 区是年轻代中最大的区域(默认占年轻代的 80% 左右),它是绝大多数 Java 对象的出生地。

Eden 区的分配同样采用指针碰撞(Bump the Pointer)方式,但需要通过 CAS 操作保证线程安全——这就是"TLAB 内分配"和"Eden 区共享分配"的关键区别:前者不需要 CAS,后者需要。

[图2:Java堆内存分代结构示意图,Eden区中标注多个线程的TLAB,老年代标注大对象直接分配通道]

文章配图
文章配图

3.2 Eden 区的大小如何影响分配效率

Eden 区的大小由 -Xmn(年轻代总大小)或 -XX:NewRatio(老年代与年轻代的比值)间接决定。Eden 区过小,会导致:

  • Minor GC 过于频繁:对象还没"死透"就被迫 GC,存活对象比例高,复制成本大。
  • TLAB 频繁重新申请:每个线程的 TLAB 太小,快速耗尽后不断去 Eden 走 CAS 路径。
  • 分配速率受限:整体吞吐量下降。

Eden 区过大也有问题:单次 Minor GC 的暂停时间变长(因为需要扫描的 Eden 区域更大,尽管存活对象比例可能更低)。

经验法则:在大多数 Web 应用中,请求处理期间产生的临时对象生命周期很短(毫秒到秒级),设置较大的 Eden 区可以让这些对象在多个请求之间自然死亡,减少被 GC 扫描的次数。如果年轻代 GC 频率超过每 5 秒一次,通常意味着 Eden 区偏小。

四、Survivor 区与年龄计数器

4.1 年龄计数器如何工作

在 HotSpot 中,每个对象的对象头(Mark Word)中有一个 4 bit 的字段用于记录"分代年龄"(Generational GC Age),取值范围 0-15。对象刚创建时年龄为 0,每经历一次 Minor GC 且存活下来,年龄 +1。

这个 4 bit 的限制意味着对象的年龄最大只能到 15,这也是为什么 -XX:MaxTenuringThreshold 的最大值就是 15——再多一位就放不下了。

4.2 动态年龄判定:比 MaxTenuringThreshold 更重要的机制

很多人以为晋升年龄就是一个固定的 MaxTenuringThreshold 值,但实际上 JVM 有一个动态年龄判定(Dynamic Age Determination)机制,它才是真正决定对象何时晋升的主导因素。

规则:在 Minor GC 之后,HotSpot 从年龄 1 开始向上累加,检查相同年龄的所有对象占用的总大小。如果累加到某个年龄时,该年龄及以下所有年龄的对象总大小超过了 Survivor 区容量的 TargetSurvivorRatio(默认 50%),那么大于等于该年龄的所有对象都会被晋升到老年代。

用一个例子说明:

code
Survivor 区大小:10MB
TargetSurvivorRatio:50%(即 5MB)
Survivor 中各年龄对象分布:
  年龄 1:2MB
  年龄 2:2MB  
  年龄 3:2MB   ← 年龄 1+2+3 = 6MB > 5MB(Survivor 的一半)
  年龄 4:1MB
  年龄 5:0.5MB

此时,年龄 >= 3 的所有对象(年龄 3 的 2MB + 年龄 4 的 1MB + 年龄 5 的 0.5MB = 3.5MB)都会被晋升到老年代。即使 MaxTenuringThreshold 设为 15,这些对象也没机会活到 15 岁。

[图3:动态年龄判定示意图——Survivor区各年龄对象占用色块,超过50%阈值线触发晋升]

文章配图
文章配图

4.3 关键参数

参数 默认值 含义
-XX:MaxTenuringThreshold 15(CMS)、G1 下默认更小 对象晋升的最大年龄阈值
-XX:TargetSurvivorRatio 50 Survivor 区期望使用率(影响动态年龄判定)
-XX:SurvivorRatio 8 Eden 与单个 Survivor 的比例(Eden:S0:S1 = 8:1:1)
-XX:+AlwaysTenure 关闭 跳过 Survivor,所有存活对象直接晋升老年代
-XX:+NeverTenure 关闭 对象永不晋升(仅用于测试,生产环境禁用)

**TargetSurvivorRatio**的调优思路:这个参数直接影响动态年龄判定的"激进程度"。设为 90 意味着 Survivor 区几乎装满才触发晋升,对象会在年轻代停留更久,减少老年代压力,但 Minor GC 复制成本更高。设为 30 则相反——对象晋升更频繁,Minor GC 更轻量,但老年代增长速度更快。默认 50% 是大多数场景的平衡点。

五、对象晋升老年代的四条完整路径

[图4:对象晋升老年代四条路径决策流程图——MaxTenuringThreshold、动态年龄判定、分配担保、大对象]

文章配图
文章配图

路径 1:年龄阈值。最直观的一条路径,对象年龄达到 MaxTenuringThreshold 后晋升。但实际上这条路径的使用频率远低于路径 2,因为动态年龄判定往往在年龄达到阈值之前就已经触发了晋升。

路径 2:动态年龄判定。最容易被忽略但也最常触发的一条路径。它说明:即使你把 MaxTenuringThreshold 设为 15,实际上对象可能活不过 3-4 次 Minor GC 就被晋升了。这解释了为什么很多应用中老年代占比比预期高——不是对象生命周期长,而是 Survivor 区太小,动态年龄判定过早触发了晋升。

路径 3:分配担保(Handle Promotion)。如果 Minor GC 后发现 Survivor 空间不足以容纳所有存活对象,超出的部分会直接晋升老年代。这是"年轻代配置不当 → 老年代压力增大"的典型传导路径。

路径 4:大对象直接进老年代。这是下一节的重点。

六、大对象分配:一条特殊通道

6.1 大对象为什么不进年轻代

大对象(比如几 MB 的字节数组、大字符串)如果在年轻代分配,会带来两个问题:

  1. 复制成本高:Minor GC 使用复制算法,每次 GC 都要把大对象从一个 Survivor 复制到另一个,大对象的复制开销远高于小对象。
  2. Survivor 空间浪费:一个大对象可能就占满了整个 Survivor 区,导致其他小对象无处可放。

因此,JVM 提供了 -XX:PretenureSizeThreshold 参数,超过该阈值的大对象直接在老年代分配,跳过年轻代。在 Serial 和 Parallel 收集器中,这个参数有效;在 G1 中,大对象使用 Humongous Object 机制单独处理。

6.2 G1 的 Humongous Object

G1 收集器没有传统的"老年代是一个连续空间"的概念,而是将堆划分为多个大小相等的 Region。一个对象如果大小超过 Region 的一半(默认 Region 大小为 1-32MB,即 Humongous 阈值在 0.5MB-16MB 之间),就会被视为 Humongous Object,分配在专门的 Humongous Region 中。

[图5:G1堆内存中Humongous Object分配示意图——多个Region方格,深红色标注横跨多个Region的Humongous Object]

文章配图
文章配图

Humongous Object 的一个重要特性:它在年轻代收集时就会被回收(因为 Humongous Region 被视为年轻代的一部分),而不是等到 Mixed GC。但问题在于,Humongous Object 需要连续的 Region 来存储,如果堆碎片化严重,即使总的空闲空间足够,也可能因为找不到连续的 Region 而触发 Full GC。这是 G1 场景下最常见的 Full GC 诱因之一。

6.3 大对象分配的实践建议

  • 控制对象大小:在业务代码中避免创建超大对象(比如一次性读取整个文件到 byte[ ]),尽量用流式处理或分块读取。
  • G1 场景:如果服务频繁分配 1-2MB 的对象,考虑增大 Region 大小(-XX:G1HeapRegionSize),让 Humongous 阈值变高,减少 Humongous Object 的数量。
  • 监控 Humongous Allocation:G1 日志中 Humongous Allocation 标记的出现频率,直接反映了你的应用是否在大量分配大对象。如果这个标记频繁出现,先检查代码,再调整参数。

七、一张图总结对象分配的完整生命周期

[图6:对象分配完整生命周期流程图——从new对象→TLAB/Eden→Minor GC→Survivor→晋升→老年代→回收]

文章配图
文章配图

八、分配策略与 GC 的联动:容易踩的坑

8.1 TLAB 浪费与 Survivor 晋升的叠加效应

如果 TLAB 浪费比例过高(TLABWasteTargetPercent 设得太大),Eden 区中会产生大量碎片。这些碎片虽然不占用存活对象空间,但会导致 Eden 区"有效容量"变小,Minor GC 更频繁,存活对象比例更高,从而加速晋升。这是一个连锁反应:

code
TLAB浪费大 → Eden有效容量小 → Minor GC频繁 → 存活对象比例高 → 动态年龄判定提前触发 → 晋升加速 → 老年代增长快 → Full GC频发

8.2 大对象 + 动态年龄判定的"双重压力"

这是一个典型的线上故障场景:某个接口批量处理时分配了大量 2-3MB 的临时对象,这些对象在 G1 中被视为 Humongous Object,直接在 Humongous Region 中分配。由于 Humongous Object 在 Young GC 时被回收,回收后释放的连续 Region 在下次分配时可能因为碎片化而找不到足够的连续空间,触发 Full GC。

同时,如果 Survivor 区配置过小(比如默认的 Eden:S0:S1 = 8:1:1 在有些场景下 Survivor 太小),动态年龄判定会过早触发大量小对象晋升,进一步加剧老年代压力。

解决方案:对于这类场景,优先从代码层面减少大对象分配(比如改用流式处理),其次考虑适当增大 Survivor 区(调小 SurvivorRatio),最后再考虑调整 G1 的 Region 大小。

8.3 参数调优的优先级

很多工程师拿到 GC 问题后,第一反应是"调参数"。实际上,参数的优先级应该是:

  1. 先看代码:是否有不必要的对象分配?是否有大对象可以改为流式处理?是否有内存泄漏?
  2. 再看容量:堆大小是否足够?年轻代/老年代比例是否合理?
  3. 最后才调参数:MaxTenuringThreshold、TargetSurvivorRatio 等细粒度参数,留到最后。

九、面试常见误区纠正

  • "MaxTenuringThreshold 就是对象晋升的年龄" —— 不准确,动态年龄判定才是实际主导晋升的机制,MaxTenuringThreshold 只是一个上限。
  • "TLAB 越大越好" —— 不对,TLAB 太大浪费多,太小则需要频繁申请,JVM 的自适应调整在大多数场景下已经是最优解。
  • "大对象直接进老年代是好事,减少年轻代压力" —— 错误,大对象在年轻代的回收成本确实高,但进老年代后回收成本更高(通常需要 Full GC),G1 的 Humongous Object 机制就是为了解决这个问题。
  • "关了 TLAB 可以省内存" —— 灾难性建议,TLAB 的浪费最多占 Eden 的 1%,但关闭后的分配性能下降是 10-50 倍量级。

十、总结

本文沿"一个新对象从诞生到最终被回收"的完整路径,逐一拆解了 JVM 的五层分配策略:

  1. TLAB:线程本地缓冲,走指针碰撞,无锁分配,极快。
  2. Eden 区:对象出生地,TLAB 耗尽后的共享分配区,CAS 分配。
  3. Survivor 区:对象的"中转站",年龄计数器在此递增。
  4. 动态年龄判定:实际主导晋升的机制,同龄对象总大小超过 Survivor 一半时触发。
  5. 大对象通道:绕过年轻代直接进老年代(或 G1 的 Humongous Region)。

理解这套分配策略,不是为了背参数,而是为了当线上出现"GC 频繁""老年代涨得快""Full GC 偶发"等问题时,你能从分配层面倒推出根因——是 TLAB 浪费导致 Eden 容量不足?是 Survivor 太小导致过早晋升?是大对象太多导致碎片化?还是代码本身就分配了太多不必要的对象?每个问题对应不同的优化路径,而本文的框架就是帮你找到那条正确的路。

延伸阅读

  • 《深入理解 Java 虚拟机》(周志明)第 3 章,垃圾收集器与内存分配策略
  • OpenJDK 源码:threadLocalAllocBuffer.hpp、threadLocalAllocBuffer.inline.hpp
  • 本系列相关篇目:058(垃圾回收基础算法)、059(Serial/Parallel/CMS)、060(G1)、062(Minor/Major/Full GC)

面试官视角:提问与回答

以下问题从面试官视角出发,考察你对内存分配策略的深度理解,而非简单复述文档内容。

Q1:你说 TLAB 是线程私有的,那如果线程 A 的 TLAB 中有一个对象被线程 B 引用了,这个对象还安全吗?Minor GC 时这个对象怎么处理?

这是一个典型的"TLAB 不隔离引用"的问题。TLAB 只是分配时的线程私有区域,不是访问隔离机制——任何线程都可以通过引用访问其他线程 TLAB 中分配的对象。Minor GC 时,这个对象无论分配在哪个线程的 TLAB 里,只要它被 GC Roots 可达(包括被线程 B 的栈帧引用),就会被标记为存活并复制到 Survivor 区。TLAB 的"私有"只体现在分配阶段的无锁优化,不影响 GC 阶段的标记和复制。

Q2:如果我把 MaxTenuringThreshold 调到 1,是不是所有存活对象都只经历一次 Minor GC 就晋升了?

不会。这里有两个关键点:第一,对象年龄是从 0 开始计数的,第一次 Minor GC 存活后年龄变为 1,如果 MaxTenuringThreshold=1,确实会在这次 GC 后就晋升。但第二,动态年龄判定机制可能在此之前就已经触发晋升——比如 Survivor 区中年龄 0 的对象(刚复制进来的)加起来已经超过 Survivor 的一半,那么年龄 >= 0 的所有对象都会晋升,根本轮不到 MaxTenuringThreshold 生效。所以 MaxTenuringThreshold 是"上限"而非"精确控制"。

Q3:线上服务频繁 Full GC,你通过 jstat 发现老年代使用率一直在 80% 以上,但每次 Full GC 后只能回收到 75%。你会怎么排查?

这个现象说明老年代中大部分对象是"长期存活"的,而非"朝生夕死后被晋升"的。我会按以下顺序排查:

  1. 先 dump 堆查看老年代中对象的具体类型和引用链——如果发现大量应该被回收的业务对象(比如已经结束的请求的缓存数据),那是内存泄漏,沿着引用链定位代码。
  2. 如果老年代中确实都是长期存活对象(比如框架缓存、连接池等),那说明老年代容量本身不够,需要增大堆或者调整年轻代/老年代比例。
  3. 同时检查 GC 日志中晋升速率——如果 Minor GC 的晋升量异常大,说明 Survivor 区太小或动态年龄判定过于激进,需要从年轻代配置入手,而非只盯着老年代。

关键判断依据:Full GC 前后老年代占用的差异。差异小 = 内存泄漏或容量不足;差异大 = 晋升速率问题。

Q4:G1 的 Humongous Object 为什么可能导致 Full GC?ZGC 有大对象问题吗?

G1 中 Humongous Object 需要连续 Region 存储。如果堆碎片化严重,即使空闲 Region 总数足够,但找不到连续的空闲 Region,G1 就会触发 Full GC 来整理碎片。这是 G1 场景下最常见的 Full GC 诱因之一。

ZGC 使用染色指针(Colored Pointers)和并发整理,不依赖连续 Region——大对象在 ZGC 中被分配在 Page 中,ZGC 的并发重定位可以移动大对象,不需要连续空间。所以 ZGC 基本没有大对象碎片化导致 Full GC 的问题。但 ZGC 的大对象分配本身可能比 G1 慢(因为需要更复杂的并发管理),这是另一个维度的权衡。

Q5:你设计一个高并发 RPC 框架,服务端每秒处理 10 万请求,每个请求会产生约 2KB 的临时对象。从内存分配策略角度,你会关注哪些 JVM 参数?为什么?

我会关注以下几个参数:

  • TLAB 相关:确保 UseTLAB 开启(默认已开启),并可能通过 TLABSize 适当增大 TLAB,让每个线程的 TLAB 能容纳更多请求的临时对象,减少向 Eden 申请新 TLAB 的 CAS 操作频率。
  • 年轻代大小:-Xmn 或 -XX:NewRatio 调大年轻代,让 2KB 的临时对象有足够空间在 Eden 中快速分配和死亡,避免过早触发 Minor GC。
  • SurvivorRatio:可能需要调小(比如设为 4),让 Survivor 区更大,因为 10 万 QPS 下 Minor GC 频率高,存活对象比例可能不低,Survivor 太小会导致动态年龄判定过激。
  • MaxTenuringThreshold:结合业务特点,如果请求处理完对象即可回收,可以适当降低阈值(比如 6-8),让确实需要长期存活的对象尽早晋升,减少 Survivor 复制开销。
  • 最后,监控 GC 日志中 Humongous Allocation 的出现频率,避免请求中产生超过 G1 Region 一半大小的大对象。

实际调优时,这些参数不是一次性设好的——需要结合压测,观察 GC 日志中的分配速率、晋升速率和暂停时间,逐步迭代。