Skip to content

JVM对象分配、TLAB、晋升与GC完整原理

new 看起来只是一条语句,运行时却可能经历类初始化检查、对象大小计算、JIT分配消除、TLAB指针碰撞、Eden慢路径、GC、Survivor复制、老年代晋升,最后才是分配成功或 OutOfMemoryError

这些细节受JDK、HotSpot版本、垃圾收集器、堆参数和运行负载影响。本页以企业常见JDK7/8分代HotSpot为主线,同时说明G1等收集器不能机械套用“固定Eden/S0/S1连续布局”。

学习目标

  1. 讲清对象从 new 到可用引用的步骤。
  2. 区分JIT分配消除、TLAB和Eden公共分配。
  3. 解释TLAB为什么属于堆、为什么能减少竞争。
  4. 理解指针碰撞、空闲列表和对象置零。
  5. 解释Minor GC中Eden、From、To和晋升过程。
  6. 理解对象年龄、动态年龄和Survivor不足。
  7. 区分普通大对象、G1 Humongous对象和收集器差异。
  8. 解释分配担保、Promotion Failed和CMS Concurrent Mode Failure。
  9. 区分Minor/Young、Old/Major、Mixed和Full GC术语。
  10. 使用JDK7/8与JDK9+对应日志参数观察,而不混用。
  11. 根据GC日志判断分配速率、存活率、晋升和老年代压力。
  12. 区分暂时分配失败、Java Heap OOM和容器OOMKilled。

一、对象分配总流程

mermaid
flowchart TD
    A["执行new或JIT编译后的分配"] --> B{"对象是否被标量替换/分配消除"}
    B -- "是" --> C["可能不创建完整堆对象"]
    B -- "否" --> D["确认类已初始化并计算对象大小"]
    D --> E{"能否在当前线程TLAB分配"}
    E -- "能" --> F["移动TLAB指针快速分配"]
    E -- "不能" --> G["申请新TLAB或走共享Eden慢路径"]
    G --> H{"堆中是否有适合空间"}
    H -- "有" --> I["原子更新分配位置"]
    H -- "没有" --> J["触发对应收集器的GC/扩堆尝试"]
    J --> K{"回收或扩容后可分配吗"}
    K -- "是" --> I
    K -- "否" --> L["OutOfMemoryError或进程被外部杀死"]
    F --> M["内存置零、对象头、字段初始化和构造器"]
    I --> M

流程是语义模型,不代表每次都严格执行同样机器步骤。JIT可能内联构造器、消除对象,收集器也可能有不同Region和并发分配机制。

二、类加载检查和对象大小

执行 new SomeClass() 前,JVM必须有可用类元数据并确保需要的类初始化语义完成。随后根据:

  • 对象头。
  • 实例字段布局。
  • 父类字段。
  • 引用压缩。
  • 对齐规则。
  • 数组长度(数组对象)。

计算分配大小。

对象大小不是简单字段类型相加。HotSpot可能重排字段以减少空洞,并按对象对齐边界补齐。精确布局应使用JOL等工具观察目标JVM,不要把某台64位机器的头大小当所有环境固定结论。

三、先判断对象是否真的需要完整堆分配

热点代码经过JIT逃逸分析后,若对象不逃出方法/线程,可能发生:

  • 标量替换:对象字段拆成独立标量。
  • 分配消除:不创建完整对象。
  • 锁消除:对象只在线程内,相关锁可能被移除。

这不是Java语法提供的“手动栈上分配”,也不是所有对象都会优化。解释执行、未热代码、逃逸对象和无法证明安全的路径仍走正常堆分配。

详见 JIT逃逸分析

四、TLAB为什么存在

Eden是线程共享区域。若每个线程创建小对象都竞争同一个全局分配指针,即使只是移动指针,也需要原子同步。

TLAB(Thread Local Allocation Buffer)是HotSpot从Eden划给线程的一小块私有分配区域:

mermaid
flowchart TD
    A["Eden堆空间"] --> B["线程A的TLAB"]
    A --> C["线程B的TLAB"]
    A --> D["共享分配区域"]
    B --> E["A只移动自己的top指针"]
    C --> F["B只移动自己的top指针"]

在自己的TLAB中,线程通常只需执行:

text
newTop = top + objectSize
if newTop <= end:
    objectAddress = top
    top = newTop

不需要与其他线程争同一个Eden指针,因此小对象分配非常快。

TLAB仍然属于堆

对象在TLAB中:

  • 仍是堆对象。
  • 仍位于Eden。
  • 仍受GC管理。
  • 仍可能进入Survivor或晋升。
  • 不等于栈上分配。
概念完整堆对象作用
TLAB减少堆分配竞争
逃逸分析+标量替换可能不是消除完整对象和分配

五、TLAB放不下时怎样处理

可能选择:

  1. 在剩余TLAB中分配(空间足够)。
  2. 放弃少量尾部空间,申请新TLAB。
  3. 对象相对较大,不值得新建TLAB,走Eden共享慢路径。
  4. Eden无适合空间,进入GC/扩容路径。

HotSpot会根据历史分配调整TLAB大小和是否保留尾部。不要为了“尾部浪费”随意固定TLABSize,TLAB减少竞争的收益通常远大于少量碎片;生产调参应以日志和压测为依据。

六、指针碰撞和空闲列表

指针碰撞

若可用堆空间在逻辑上连续:

text
已使用 | top | 未使用

分配只需把top向前移动对象大小。TLAB内通常无需跨线程CAS,共享区域则需要原子协调。

空闲列表

若内存存在碎片,运行时维护可用块列表,从适合块中分配。采用哪种方式与收集器和堆整理能力有关。

“Java对象分配永远只是移动指针”过于绝对。即使常见快路径是指针碰撞,慢路径、Humongous对象、Region回收和碎片状态也更复杂。

七、对象内存为什么要置零和设置对象头

分配空间后,JVM通常要让实例字段呈现Java默认零值语义:数字为0、boolean为false、引用为null。随后设置对象头,例如:

  • 类型元数据关联。
  • 哈希、锁和GC年龄相关标记信息(具体布局随JVM变化)。
  • 数组长度。

最后执行字段显式初始化、实例初始化块和构造器。Java引用对外可用前还要遵守JMM构造与安全发布规则;构造器中让 this 逸出可能让其他线程看到未完成状态。

八、为什么对象通常先进入新生代

弱分代假设:多数对象生命周期短。请求DTO、临时集合、字符串和序列化中间对象常在很短时间变成不可达。

将它们集中在年轻区域,可通过复制少量存活对象快速回收大部分空间。若所有短命对象直接进入老年代,会增加老年代扫描、整理和长停顿压力。

但“所有对象都进固定Eden”不是跨收集器绝对规则。G1使用Region并动态扮演Eden/Survivor/Old;大对象和特殊路径也不同。

九、Minor/Young GC中的对象流转

以典型分代复制收集理解:

mermaid
flowchart TD
    A["Eden + From Survivor"] --> B["从GC Roots标记/追踪存活对象"]
    B --> C{"对象应继续年轻吗"}
    C -- "是" --> D["复制到To Survivor并更新年龄"]
    C -- "否/无法容纳" --> E["晋升老年代"]
    D --> F["清空Eden和原From"]
    E --> F
    F --> G["From和To角色交换"]

To Survivor无法容纳所有存活对象时,一部分对象可能提前晋升。晋升不仅由年龄决定,也受Survivor容量、收集器策略和老年代状态影响。

十、对象年龄和晋升阈值

对象每次在年轻代GC后继续存活,年龄通常增加。达到当前晋升阈值后进入老年代。

-XX:MaxTenuringThreshold 是上限相关参数,不代表每个对象一定存活满该次数才晋升。实际阈值可能动态调整。

动态年龄判定

HotSpot可根据Survivor中各年龄对象累计大小选择较低阈值,目标是让存活对象适配Survivor容量。教材常用“某年龄及以下累计超过目标容量”解释,具体比例和输出应以当前JVM的tenuring日志为准,不要把“始终等于Survivor一半”当所有版本固定公式。

提前晋升常见原因

  • Survivor不足。
  • 动态阈值降低。
  • 对象年龄达到阈值。
  • 收集器特定策略。
  • 大对象/特殊分配路径。

十一、大对象怎样分配:必须看收集器

Serial/ParNew等传统场景

-XX:PretenureSizeThreshold 可在部分收集器中让超过阈值的对象直接进入老年代,以避免在年轻代多次复制。它不是所有收集器都以相同方式支持,尤其不能把该参数机械套到Parallel/G1等所有组合。

G1 Humongous对象

G1将堆划为Region。通常超过单个Region一半的对象会按Humongous对象处理,占用一个或多个连续Region,并带来:

  • 连续Region需求。
  • 末尾空间浪费。
  • 回收时机和并发标记压力。
  • 大数组频繁分配导致GC异常。

其他收集器

ZGC、Shenandoah等布局和大对象策略不同。分析必须先确认:

bash
java -version
jcmd <pid> VM.flags
jcmd <pid> VM.info

以及实际GC日志,不能先套结论。

十二、分配担保是什么

年轻代GC后,存活对象可能需要晋升老年代。GC前/过程中必须评估老年代是否有足够容量承接最坏或估计晋升量,这就是常说的分配担保思想。

mermaid
flowchart TD
    A["Eden分配失败准备Young GC"] --> B["估计存活与晋升需求"]
    B --> C{"老年代可承接吗"}
    C -- "能" --> D["执行Young GC并晋升"]
    C -- "风险高" --> E["按收集器策略先做Old/Full或并发周期"]
    D --> F{"实际晋升成功吗"}
    F -- "成功" --> G["继续运行"]
    F -- "失败" --> H["Promotion Failed/退化收集/最终OOM"]

JDK6u24前后HotSpot历史策略和 HandlePromotionFailure 行为有变化。现代排查不要只背历史比较公式,应直接看目标JDK、收集器和GC日志。

十三、Promotion Failed和CMS Concurrent Mode Failure

Promotion Failed

Young GC试图把存活对象晋升到老年代,但没有合适空间。可能触发退化或Full GC;若回收后仍不能满足分配,最终OOM。

CMS Concurrent Mode Failure

CMS并发回收尚未完成,业务线程继续分配/晋升使老年代没有足够空间,CMS可能退化为Stop-The-World的Serial Old处理,停顿显著增加。

常见原因:

  • CMS启动过晚。
  • 老年代增长快。
  • 晋升量突增。
  • 碎片导致没有适合连续空间。
  • 并发周期被CPU竞争拖慢。

这些是CMS语境,不能直接套到G1/ZGC。

十四、Minor、Major、Mixed和Full GC别混用

这些名称并非JVM规范统一规定所有实现的严格术语。常见工程口径:

名称常见含义注意
Young/Minor GC回收年轻区域可能伴随晋升和STW
Old/Major GC以老年代回收为主工具/资料口径不同
Mixed GCG1回收年轻Region和部分老Region不等于Full GC
Full GC通常对整个堆/更多区域做完整收集触发与实现随收集器变化

不要背“Major一定比Minor慢10倍”。停顿取决于堆大小、存活对象、算法、并行度、硬件和收集器。正确做法是比较当前环境GC日志的暂停、频率和回收效果。

十五、什么可能触发Full GC或重型退化收集

可能包括但不限于:

  • 老年代/整个堆分配压力且常规回收无法满足。
  • 晋升失败。
  • CMS并发失败。
  • 显式 System.gc() 请求未被禁用/忽略。
  • 元数据压力与类卸载需求(依JDK/收集器)。
  • G1 Humongous/Region分配失败或并发周期跟不上。
  • 收集器特定失败与退化路径。

JDK7常见PermGen,JDK8移除PermGen,类元数据主要在本地内存Metaspace。Metaspace OOM和类卸载行为不能继续用“永久代满了”解释JDK8。

System.gc() 通常是请求/建议,具体是否执行、执行哪种收集受JVM和参数影响。-XX:+DisableExplicitGC 也可能影响依赖显式GC推进直接缓冲回收等场景,生产不能不评估就开启。

十六、分配失败不等于立刻OOM

一次快路径分配失败可能依次尝试:

text
TLAB不足
→ 新TLAB或共享Eden
→ Young GC
→ 堆扩容(未达Xmx且策略允许)
→ 老年代/完整收集
→ 仍无法满足才Java Heap OOM

而容器总内存超过cgroup限制时,进程可能直接被OOM Killer杀掉,退出码常见137,没有机会抛Java OutOfMemoryError 或生成Heap Dump。

十七、JDK7/8与JDK9+ GC日志命令

JDK7/8常见实验参数

bash
java -Xms64m -Xmx64m \
  -XX:+PrintGCDetails \
  -XX:+PrintGCDateStamps \
  -XX:+PrintTenuringDistribution \
  -Xloggc:gc.log \
  AllocationDemo

部分参数和输出受JDK小版本/收集器影响,先在目标环境验证。

JDK9+统一日志

bash
java -Xms64m -Xmx64m \
  -Xlog:gc*,gc+age=trace:file=gc.log:time,uptime,level,tags \
  AllocationDemo

不能在JDK8直接使用 -Xlog:gc*;也不要把JDK8 PrintGCDetails 当作JDK21唯一推荐写法。

十八、JDK7/8可运行Demo:观察分配和GC

java
import java.util.ArrayList;
import java.util.List;

public class AllocationDemo {
    public static void main(String[] args) throws Exception {
        List<byte[]> survivors = new ArrayList<byte[]>();
        for (int round = 0; round < 30; round++) {
            for (int i = 0; i < 200; i++) {
                byte[] temporary = new byte[32 * 1024];
                if (temporary[0] != 0) {
                    throw new AssertionError();
                }
            }
            if (round % 5 == 0) {
                survivors.add(new byte[512 * 1024]);
            }
            Thread.sleep(50L);
        }
        System.out.println("survivors=" + survivors.size());
    }
}

该Demo制造大量短命数组,并周期保留较大数组。它用于观察日志格式和对象流转,不保证每个JVM产生完全相同GC次数。JIT、堆布局和收集器都会改变结果。

十九、怎样读GC日志中的分配证据

关注:

  • GC前后年轻代占用:回收了多少短命对象。
  • Survivor/age table:哪些年龄对象较多、当前阈值。
  • 晋升量:老年代为何增长。
  • GC间隔:可近似反映分配速率变化。
  • 暂停时间和并行线程。
  • Full/退化收集原因。
  • GC后老年代基线是否持续上升。
text
分配率高 + GC后老年代稳定
→ 可能是短命对象压力

分配率不高 + GC后老年代基线持续上升
→ 可能是慢性存活增长/泄漏

仅看一行日志不能下结论,应与流量、版本、对象直方图、Heap Dump和业务时间线对齐。

二十、商业场景:大文件导入为什么容易触发分配风暴

错误方式:

text
一次读完整文件到byte[]
→ 转String
→ split产生大量String/数组
→ DTO和集合全部留到最后

后果:大数组、重复副本、短命对象分配率、晋升和OOM同时增加。

正确方向:

  • 流式/分块读取。
  • 明确字符集和缓冲区复用。
  • 分批解析、校验和入库。
  • 有界队列形成背压。
  • 批次完成后解除引用。
  • 限制上传大小、行数和字段长度。
  • 用GC日志和Allocation Profile验证。

不要通过把所有对象“直接进老年代”解决设计问题,这通常只是把压力后移到更昂贵的回收阶段。

二十一、线上排查分配和晋升异常

mermaid
flowchart TD
    A["GC频繁/Old上涨/OOM告警"] --> B["确认JDK、收集器、堆和容器限制"]
    B --> C["对齐GC日志、流量和发布"]
    C --> D{"年轻代回收后Old是否持续涨"}
    D -- "否" --> E["查短命对象分配率和Eden容量"]
    D -- "是" --> F["查晋升、存活集和GC Roots"]
    E --> G["Allocation火焰图/代码路径"]
    F --> H["Histogram、Heap Dump和MAT"]
    G --> I["修复并同负载复测"]
    H --> I

检查清单:

  1. java -version、实际GC和完整启动参数。
  2. 堆是否固定、容器总内存是否足够。
  3. Young GC频率、回收量、暂停和晋升。
  4. Survivor是否过小、对象是否提前晋升。
  5. 大数组/Humongous对象来源。
  6. Old在Full GC后是否仍高。
  7. Allocation热点与Heap存活对象是否一致。
  8. 最近流量、批量任务、缓存和发布变化。

二十二、常见误区

误区正确理解
TLAB在栈上TLAB属于Eden堆
所有对象都进EdenJIT消除、大对象和收集器策略例外
Pretenure参数对所有GC相同支持与行为依收集器/JDK
对象一定活15次才晋升动态阈值和空间不足可提前
Survivor满会单独触发固定GCGC触发和流转由收集器/分配路径决定
Major GC固定慢10倍用目标环境日志测量
JDK8还有PermGenJDK8主要是Metaspace
分配失败就是OOM还可能换路径、GC和扩堆
Allocation高就是泄漏分配与存活是两件事

二十三、面试标准回答

对象分配全过程

执行new先保证类元数据和初始化语义,再计算对象大小;JIT可能对不逃逸对象标量替换,否则优先在Eden中的线程TLAB用指针碰撞分配,放不下则申请新TLAB或走共享慢路径。空间不足触发对应收集器GC/扩堆,仍失败才OOM。分配后置零、设置对象头、执行字段初始化和构造器。

TLAB属于堆吗

属于。TLAB是Eden划给线程的本地分配缓冲区,减少多个线程竞争共享分配指针;其中对象仍是真实堆对象并由GC管理。逃逸分析后的标量替换才可能不创建完整堆对象,两者不能混淆。

对象什么时候晋升老年代

达到当前年龄阈值、Survivor容纳不足、动态年龄阈值降低、部分大对象策略或收集器特定路径都可能晋升。MaxTenuringThreshold是上限相关参数,不是所有对象固定存活次数。

Minor、Major、Full GC区别

它们不是JVM规范对所有实现完全统一的术语。Young/Minor通常回收年轻区;Major/Old通常指以老年代为主但工具口径不同;G1 Mixed回收年轻区和部分老Region;Full通常涉及整个堆或更重的退化收集。判断必须看收集器和GC日志原因。

二十四、关联知识点

二十五、学习验收

  • [ ] 能画出new到OOM的完整分配路径。
  • [ ] 能区分JIT消除、TLAB和共享Eden。
  • [ ] 能解释指针碰撞、置零和对象头。
  • [ ] 能说明TLAB为何属于堆。
  • [ ] 能画出Eden/From/To/Old流转。
  • [ ] 能解释动态年龄和提前晋升。
  • [ ] 能区分传统大对象与G1 Humongous。
  • [ ] 能解释分配担保和CMS失败术语。
  • [ ] 能区分JDK7/8与JDK9+日志参数。
  • [ ] 能根据GC前后和Old基线判断分配压力与存活增长。

本章小结

对象分配的快路径可能只是TLAB指针移动,但完整系统还包含JIT消除、共享慢路径、GC、Survivor、晋升和收集器退化。掌握这条链后,才能准确回答“TLAB是否属于堆”“大对象去哪里”“为什么提前晋升”“分配失败是否等于OOM”,并根据目标JDK、收集器和GC日志而不是固定口诀排查。