JVM对象分配、TLAB、晋升与GC完整原理
new 看起来只是一条语句,运行时却可能经历类初始化检查、对象大小计算、JIT分配消除、TLAB指针碰撞、Eden慢路径、GC、Survivor复制、老年代晋升,最后才是分配成功或 OutOfMemoryError。
这些细节受JDK、HotSpot版本、垃圾收集器、堆参数和运行负载影响。本页以企业常见JDK7/8分代HotSpot为主线,同时说明G1等收集器不能机械套用“固定Eden/S0/S1连续布局”。
学习目标
- 讲清对象从
new到可用引用的步骤。 - 区分JIT分配消除、TLAB和Eden公共分配。
- 解释TLAB为什么属于堆、为什么能减少竞争。
- 理解指针碰撞、空闲列表和对象置零。
- 解释Minor GC中Eden、From、To和晋升过程。
- 理解对象年龄、动态年龄和Survivor不足。
- 区分普通大对象、G1 Humongous对象和收集器差异。
- 解释分配担保、Promotion Failed和CMS Concurrent Mode Failure。
- 区分Minor/Young、Old/Major、Mixed和Full GC术语。
- 使用JDK7/8与JDK9+对应日志参数观察,而不混用。
- 根据GC日志判断分配速率、存活率、晋升和老年代压力。
- 区分暂时分配失败、Java Heap OOM和容器OOMKilled。
一、对象分配总流程
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划给线程的一小块私有分配区域:
flowchart TD
A["Eden堆空间"] --> B["线程A的TLAB"]
A --> C["线程B的TLAB"]
A --> D["共享分配区域"]
B --> E["A只移动自己的top指针"]
C --> F["B只移动自己的top指针"]在自己的TLAB中,线程通常只需执行:
newTop = top + objectSize
if newTop <= end:
objectAddress = top
top = newTop不需要与其他线程争同一个Eden指针,因此小对象分配非常快。
TLAB仍然属于堆
对象在TLAB中:
- 仍是堆对象。
- 仍位于Eden。
- 仍受GC管理。
- 仍可能进入Survivor或晋升。
- 不等于栈上分配。
| 概念 | 完整堆对象 | 作用 |
|---|---|---|
| TLAB | 是 | 减少堆分配竞争 |
| 逃逸分析+标量替换 | 可能不是 | 消除完整对象和分配 |
五、TLAB放不下时怎样处理
可能选择:
- 在剩余TLAB中分配(空间足够)。
- 放弃少量尾部空间,申请新TLAB。
- 对象相对较大,不值得新建TLAB,走Eden共享慢路径。
- Eden无适合空间,进入GC/扩容路径。
HotSpot会根据历史分配调整TLAB大小和是否保留尾部。不要为了“尾部浪费”随意固定TLABSize,TLAB减少竞争的收益通常远大于少量碎片;生产调参应以日志和压测为依据。
六、指针碰撞和空闲列表
指针碰撞
若可用堆空间在逻辑上连续:
已使用 | 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中的对象流转
以典型分代复制收集理解:
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等布局和大对象策略不同。分析必须先确认:
java -version
jcmd <pid> VM.flags
jcmd <pid> VM.info以及实际GC日志,不能先套结论。
十二、分配担保是什么
年轻代GC后,存活对象可能需要晋升老年代。GC前/过程中必须评估老年代是否有足够容量承接最坏或估计晋升量,这就是常说的分配担保思想。
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 GC | G1回收年轻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
一次快路径分配失败可能依次尝试:
TLAB不足
→ 新TLAB或共享Eden
→ Young GC
→ 堆扩容(未达Xmx且策略允许)
→ 老年代/完整收集
→ 仍无法满足才Java Heap OOM而容器总内存超过cgroup限制时,进程可能直接被OOM Killer杀掉,退出码常见137,没有机会抛Java OutOfMemoryError 或生成Heap Dump。
十七、JDK7/8与JDK9+ GC日志命令
JDK7/8常见实验参数
java -Xms64m -Xmx64m \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-XX:+PrintTenuringDistribution \
-Xloggc:gc.log \
AllocationDemo部分参数和输出受JDK小版本/收集器影响,先在目标环境验证。
JDK9+统一日志
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
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后老年代基线是否持续上升。
分配率高 + GC后老年代稳定
→ 可能是短命对象压力
分配率不高 + GC后老年代基线持续上升
→ 可能是慢性存活增长/泄漏仅看一行日志不能下结论,应与流量、版本、对象直方图、Heap Dump和业务时间线对齐。
二十、商业场景:大文件导入为什么容易触发分配风暴
错误方式:
一次读完整文件到byte[]
→ 转String
→ split产生大量String/数组
→ DTO和集合全部留到最后后果:大数组、重复副本、短命对象分配率、晋升和OOM同时增加。
正确方向:
- 流式/分块读取。
- 明确字符集和缓冲区复用。
- 分批解析、校验和入库。
- 有界队列形成背压。
- 批次完成后解除引用。
- 限制上传大小、行数和字段长度。
- 用GC日志和Allocation Profile验证。
不要通过把所有对象“直接进老年代”解决设计问题,这通常只是把压力后移到更昂贵的回收阶段。
二十一、线上排查分配和晋升异常
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检查清单:
java -version、实际GC和完整启动参数。- 堆是否固定、容器总内存是否足够。
- Young GC频率、回收量、暂停和晋升。
- Survivor是否过小、对象是否提前晋升。
- 大数组/Humongous对象来源。
- Old在Full GC后是否仍高。
- Allocation热点与Heap存活对象是否一致。
- 最近流量、批量任务、缓存和发布变化。
二十二、常见误区
| 误区 | 正确理解 |
|---|---|
| TLAB在栈上 | TLAB属于Eden堆 |
| 所有对象都进Eden | JIT消除、大对象和收集器策略例外 |
| Pretenure参数对所有GC相同 | 支持与行为依收集器/JDK |
| 对象一定活15次才晋升 | 动态阈值和空间不足可提前 |
| Survivor满会单独触发固定GC | GC触发和流转由收集器/分配路径决定 |
| Major GC固定慢10倍 | 用目标环境日志测量 |
| JDK8还有PermGen | JDK8主要是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日志而不是固定口诀排查。
