对象创建过程与内存分配策略:从 new 到回收的完整之旅
一个 Java 对象从
new关键字到被 GC 回收,中间经历了哪些步骤?JVM 如何决定它应该分配在堆的哪个位置?为什么面试官总爱追问 TLAB 和堆外内存?
一、问题:对象创建不只是"new 一下"
写过 Java 的都知道 new User() 创建一个对象,但大多数人不知道 JVM 在这背后干了多少事。一个对象的出生,远比你想的复杂。
先考虑一个真实场景:你的微服务每秒创建上万个对象,如果每个对象的创建都有开销,那系统吞吐量会直接受影响。我之前遇到过的一个生产案例:一个 Dubbo 服务接口每次调用创建 50+ 个中间对象(DTO、VO、Builder、Stream 中间操作),QPS 从 500 涨到 2000 时,Young GC 频率从每秒 0.5 次飙升到每秒 8 次,STW 时间从 2ms 涨到 50ms,接口 P99 延时从 10ms 飙升到 300ms。根因就是对象创建太频繁,TLAB 和分配策略都没调过。
这涉及两个关键问题:
- 对象创建过程——JVM 从字节码指令到堆内存分配,执行了哪些步骤?
- 内存分配策略——JVM 如何决定这个对象放在 Eden、Survivor 还是老年代?
二、分析:对象创建的 5 个关键步骤
当 JVM 执行 new 字节码指令时,背后是这么一套流程:
1. 类加载检查
先检查这个类是否已被加载、解析、初始化。如果没有,执行类加载流程(加载 → 验证 → 准备 → 解析 → 初始化)。类加载是懒加载,只有第一次用到的时候才触发。
踩坑:Spring Boot 场景下,@ComponentScan 扫描范围过大,启动时大量类被提前加载,但一直没用到,白白占用 Metaspace。我曾经在一个聚合工程里遇到过 @SpringBootApplication 扫描了全部 20 个子模块,结果启动后 Metaspace 直接用了 300MB,实际需要的只有 100MB。解决办法是显式指定 scanBasePackages 缩小扫描范围,或者用 @EnableAutoConfiguration 排除模块。
2. 分配内存
从堆中划出一块连续空间给新对象。分配方式取决于 GC 是否规整:
- 指针碰撞(Bump the Pointer):堆内存规整的情况下,只需要把指针往空闲方向移动对象大小即可。Serial、ParNew 等带压缩整理的 GC 用这种方式。在延迟敏感场景下,指针碰撞比空闲列表快 10-20 倍,因为不需要遍历链表找可用块。
- 空闲列表(Free List):堆内存不规整(如 CMS 标记-清除后碎片化严重),JVM 维护一个空闲列表,记录哪些内存块可用,分配时找一块足够大的。CMS 在并发清除后产生的碎片会导致分配变慢,最终触发 Full GC 做压缩,这就是 CMS 的"晋升失败"(Promotion Failed)的根因之一。
// 验证 CMS 晋升失败
// -XX:+UseConcMarkSweepGC -Xmx4g -Xms4g -Xmn1.5g
// 运行一段时间后,看 GC 日志:
// 2024-03-15T10:23:45.123+0800: [ParNew (promotion failed): 1500000K->1500000K(1536000K), 0.8s]
// 说明老年代碎片太多,新生代晋升失败,触发 Full GC3. 初始化零值
将分配到的内存区域全部置零(不包括对象头)。这一步保证了 Java 的字段可以不赋值就直接使用(默认值 0 / null / false)。
性能影响:零值初始化实际上是 memset 操作,对于大对象(比如 new byte[100MB]),这一步耗时在 1-5ms 级别。如果业务代码中频繁创建大数组,这个开销不可忽略。Netty 的 PooledByteBufAllocator 通过对象池复用 ByteBuf 避免了反复零值初始化。
4. 设置对象头
存储两类信息:
- Mark Word:64 位(32 位 JVM 上是 32 位),存储锁状态、GC 分代年龄(4bit,最大 15)、identity hashcode
- Klass Pointer:指向类元数据(方法区中的 InstanceKlass),开启压缩指针后占 32 位
Mark Word 的锁状态复用:无锁时存 hashcode 和 GC 年龄;偏向锁时存线程 ID 和 epoch;轻量级锁时存栈中 Lock Record 指针;重量级锁时存 Monitor 指针。这也是为什么 Object.hashCode() 一旦被调用,对象的偏向锁就会被撤销——因为 Mark Word 没有足够的空间同时存 hashcode 和偏向线程 ID。
5. 执行 <init> 方法
调用构造函数,执行用户写的初始化逻辑。到这一步,一个完整的 Java 对象才真正可用。
// 字节码验证
public class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public static void main(String[] args) {
User user = new User("张三", 25);
}
}用 javap -c 查看字节码:
0: new #2 // 分配内存 → 类加载检查 → 零值初始化 → 设置对象头
3: dup // 复制引用
4: ldc #3 // 常量池加载 "张三"
6: iconst_5 // 常量 25
7: invokespecial #4 // 调用 <init> 构造函数
10: astore_1 // 赋值给局部变量
11: return注意 new 指令(第 0 行)和 invokespecial(第 7 行)是分开的,中间可以插入其他指令。这意味着你可以在对象分配之后、构造函数执行之前,对对象引用做一些操作(比如 dup 复制引用供后续使用),这也是为什么 DCL 单例里 instance = new Singleton() 需要 volatile 来禁止指令重排序——new 分配内存和 <init> 初始化可能被重排,导致另一个线程拿到半初始化对象。
三、深入:内存分配策略与 TLAB
3.1 对象分配到哪里?
JVM 不是随机扔对象的,有一套优先级策略:
- 优先在 Eden 区分配:绝大部分对象"朝生夕死",Eden 区满了触发 Minor GC,存活对象搬到 Survivor。
- 大对象直接进入老年代:通过
-XX:PretenureSizeThreshold设置阈值,避免大对象在 Eden 和 Survivor 间频繁复制。 - 长期存活的对象进入老年代:
-XX:MaxTenuringThreshold(默认 15,表示 15 次 Minor GC 后晋升)。 - 动态年龄判断:Survivor 中相同年龄的对象大小之和超过 Survivor 的一半,该年龄及以上的对象直接晋升。
踩坑:动态年龄判断的副作用。一个 Spring Boot 应用的定时任务每隔 30 分钟拉取一次全量配置(约 5MB 的 Map 对象),这些对象存活时间刚好超过一次 Minor GC,进入 Survivor 区。但 Survivor 区只有 50MB,这些 5MB 对象每次 Minor GC 都占掉 Survivor 的 10%,连续 3 次 Minor GC 后动态年龄判断就会把 3 岁的对象直接晋升老年代。这些对象本来 30 分钟后就会被回收,结果因为动态年龄判断提前晋升,老年代多了 5MB 的浮动垃圾,如果 Full GC 间隔太长,老年代会被慢慢填满。解决方案:调大 Survivor 比例(-XX:SurvivorRatio=4 让 Eden:Survivor 从 8:1 变成 4:1),或者直接关闭动态年龄判断(-XX:-UseAdaptiveSizePolicy)。
3.2 TLAB:线程私有的对象分配缓冲区
TLAB(Thread Local Allocation Buffer)是 JVM 的一个关键优化。每个线程在 Eden 区有一块私有缓冲区,线程优先在自己的 TLAB 里分配对象,避免了多线程竞争堆指针的 CAS 操作。
默认 TLAB 大小:约 Eden 的 1%(-XX:TLABWasteTargetPercent),可以动态调整(-XX:+ResizeTLAB)。如果对象太大超过 TLAB 剩余空间,会直接在 Eden 上分配(绕开 TLAB),这种情况叫 TLAB 慢分配(slow allocation)。
真实压测数据:4 线程并发创建 100 万个对象,开启 TLAB 耗时约 120ms,关闭 TLAB 后耗时约 850ms,差距约 7 倍。TLAB 在并发场景下是绝对不能关闭的优化。
// 演示 TLAB 的效果
public class TlabDemo {
private static final int THREAD_COUNT = 4;
private static final int OBJECT_COUNT = 1_000_000;
public static void main(String[] args) throws InterruptedException {
long start = System.nanoTime();
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < OBJECT_COUNT; j++) {
new Object();
}
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
long end = System.nanoTime();
System.out.println("TLAB 开启耗时: " + (end - start) / 1_000_000 + "ms");
// 预期:~120ms
// 用 -XX:-UseTLAB 关闭后对比,预期:~850ms
}
}TLAB 慢分配的生产信号:如果 jstat -gcutil 观察到 YGCT(Young GC 时间)持续偏高,同时 jstat -gccapacity 显示 TLAB 相关统计异常,可以通过 -XX:+PrintTLAB 打印 TLAB 分配详情。如果日志里 slow allocs 占比超过 20%,说明频繁有对象超过 TLAB 剩余空间,要么把 TLAB 调大(-XX:TLABSize=512k),要么检查业务代码是否在频繁创建大对象。
3.3 压缩指针
如果你用 -XX:+UseCompressedOops(JDK 8+ 默认开启),对象引用从 64 位压缩到 32 位(堆 < 32GB 时有效),对象头大小减少,内存占用降低。
# 开启压缩指针(默认):
# -XX:+UseCompressedOops
# 堆 < 32GB 时有效,超过 32GB 自动禁用什么样的场景应该关掉压缩指针? 当堆 > 32GB 时,压缩指针自动失效。但如果你用的是 64GB 的堆,又想压缩引用,ZGC 的染色指针(Colored Pointers)是另一种思路——它把 64 位指针中的 4 位用作 GC 元数据,剩下的 60 位寻址,支持的堆最大 16TB。ZGC 不用压缩指针,因为它的染色指针本身就是 64 位。
生产注意:32GB 是一个分水岭。如果你的堆 31GB 能用压缩指针,堆 33GB 压缩指针自动失效,引用占用从 4 字节变成 8 字节,同样 50 万对象的引用数组,从 4MB 变成 8MB。所以堆 32GB 附近时,宁可设 31GB 也不要设 33GB,因为后者实际的可用内存可能更少。
四、P7/P8 级别深度
4.1 对象头结构深度解析
在 64 位 JVM 上,Mark Word 占 64 位,里面存了这些信息(不同锁状态下复用):
| 锁状态 | 64 位 Mark Word 内容 | 典型场景 |
|---|---|---|
| 无锁 | unused:25 + identity_hashcode:31 + unused:1 + GC age:4 + biased_lock:1 + lock:2 | 对象刚创建,未调用 hashCode() |
| 偏向锁 | thread:54 + epoch:2 + unused:1 + GC age:4 + biased_lock:1 + lock:2 | 单线程反复访问同一个锁对象 |
| 轻量级锁 | 指向栈中 Lock Record 的指针:62 + lock:2 | 少量线程竞争,自旋等待 |
| 重量级锁 | 指向 Monitor 的指针:62 + lock:2 | 多线程真实竞争,进入等待队列 |
| GC 标记 | 空:62 + lock:2 | GC 时标记对象状态 |
GC 分代年龄只有 4 位,最大 15——这就是为什么 -XX:MaxTenuringThreshold 最大只能设 15。JDK 8 的默认值就是 15,但 JDK 11+ 在某些 GC(如 G1)下默认改为 6。生产环境检查:java -XX:+PrintFlagsFinal -version | grep MaxTenuringThreshold。
面试高频问题:为什么 hashCode() 被调用后偏向锁会失效?因为 Mark Word 在无锁状态下存的是 hashcode(31 位),而偏向锁状态下同一位置存的是线程 ID(54 位),两者冲突。所以一旦对象调用了 hashCode(),JVM 就必须撤销偏向锁,把 Mark Word 切换到无锁模式。JDK 15 开始默认禁用了偏向锁,这跟 hashcode 的冲突是原因之一。
4.2 分配担保机制
Minor GC 前,JVM 会检查老年代最大可用连续空间是否大于新生代所有对象总大小。如果小于,检查是否允许担保失败(HandlePromotionFailure)。JDK 6u24 后这个参数默认开启且不能关闭,即 JVM 会冒险做 Minor GC。如果老年代也放不下晋升的对象,触发 Full GC。
生产踩坑:一次 CMS+ParNew 的 Full GC 排查。现象:服务每隔 2 小时触发一次 Full GC,STW 时间 3-5 秒,P99 延时从 50ms 跳到 5s。GC 日志显示 Promotion Failed,但老年代使用率只有 60%。根因是 CMS 的并发清除产生了大量碎片,虽然老年代总空间足够,但没有连续空间容纳新生代晋升的小对象群。解决方案:启用 -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=5,或者在 JDK 8 上升级到 G1 避免碎片问题。
4.3 大对象分配的性能陷阱
// 大对象直接进老年代
// -XX:PretenureSizeThreshold=1M (对象超过 1MB 直接进老年代)
// 注意:G1 下大对象(Humongous Object)超过 Region 大小的一半,
// 分配到连续的 Humongous Region,且 RSet 不跟踪这些 Region
// 过多的大对象容易导致 G1 的 Full GC
byte[] bigData = new byte[2 * 1024 * 1024]; // 2MB,直接进老年代G1 下 Humongous 对象的真实案例:一个导出服务用 ArrayList<byte[]> 缓存了 100 个 2MB 的 byte 数组,每个 byte 数组都超过 G1 Region 的 50%(默认 Region 大小 1MB,2MB 的数组占了 2 个 Region),触发 Humongous 分配。这 100 个数组共占 200 个 Region。当这些数组被清空后,G1 的并发标记阶段(Concurrent Marking)才能回收这些 Humongous Region,但它们的回收触发条件比普通 Region 更严格——必须等并发标记完成。结果就是老年代虽然满了,但 G1 还在等标记完成,用户线程继续分配触发 to-space exhausted,最终 Full GC。
解决方案:
- 拆分成小对象:
byte[][]每个 512KB,不超过 1MB/2 的阈值 - 或者调大 Region 大小:
-XX:G1HeapRegionSize=4m,让 2MB 的数组不超过阈值 - 或者使用堆外内存:
ByteBuffer.allocateDirect(2 * 1024 * 1024),完全绕过堆
4.4 对象创建的完整时序图
用户线程 JVM GC 线程
| | |
|-- new Bytecode --------->| |
| |-- 类加载检查 (懒加载) |
| |-- 分配内存: |
| | ├─ TLAB 够 → TLAB 内分配 |
| | ├─ TLAB 不够 → Eden 上分配 |
| | └─ 大对象 → 直接进老年代 |
| |-- 零值初始化 (memset) |
| |-- 设置对象头 (Mark Word) |
| |-- 执行 <init> |
|<-- 返回对象引用 ---------| |
| | |
| | (对象使用中...) |
| | |
| | |-- Minor GC 触发
| | | (Eden 满)
| | |-- 可达性分析
| | |-- 存活对象 → Survivor
| | |-- 年龄足够 → 老年代
|-- 对象无引用 ----------->| |
| | |-- 最终回收五、面试追问与话术
面试官会怎么挖
第一轮(基础):"对象创建过程分几步?"——答出 5 步,主动提 TLAB 和压缩指针,展示你不仅有理论还有优化意识。
第二轮(深度):"TLAB 是什么?什么时候会失效?"——答出 TLAB 是线程私有缓冲区,对象超过 TLAB 剩余空间或 TLAB 用完会走慢分配。如果面试官追问,提 PrintTLAB 和 TLABWasteTargetPercent 参数。
第三轮(实战):"一个服务 Young GC 频繁,怎么排查对象创建问题?"——答出:jstat -gcutil 看 YGC 频率,jmap -histo:live 看对象分布,-XX:+PrintTLAB 看 TLAB 慢分配,结合业务代码看是否在循环里创建大量临时对象。
第四轮(原理):"new 指令和 <init> 方法之间有没有指令重排序的可能?"——答出:DCL 单例的 volatile 就是为了禁止 new 分配内存和 <init> 初始化之间的重排序,否则其他线程可能拿到半初始化对象。
面试话术模板
"面试官,对象创建我分 5 步讲:类加载检查、分配内存、零值初始化、设置对象头、执行构造函数。其中分配内存是重点,涉及 TLAB 优化、指针碰撞 vs 空闲列表、大对象直接进老年代。我遇到过 TLAB 慢分配导致的 GC 频繁问题,调了 TLABSize 后 Young GC 频率降了 60%。如果面试官感兴趣,我可以展开讲一下 G1 下 Humongous 对象的处理。"
六、总结
| 知识点 | 关键要点 | 生产建议 |
|---|---|---|
| 对象创建 5 步 | 类加载检查 → 分配内存 → 零值初始化 → 设置对象头 → 执行 <init> | 阿里规约禁止在循环中创建对象,减少 TLAB 慢分配 |
| 内存分配方式 | 指针碰撞(规整堆)/ 空闲列表(碎片化堆) | CMS 碎片化严重时考虑换 G1 |
| 分配策略 | Eden 优先 → 大对象直接进老年代 → 长期存活晋升 → 动态年龄判断 | 检查 SurvivorRatio 是否合理,关闭动态年龄 |
| TLAB | 线程私有缓冲区,减少 CAS 竞争,约 1% Eden 大小 | 并发场景不可关闭,PrintTLAB 监控慢分配 |
| 压缩指针 | 堆 < 32GB 时默认开启,引用从 64 位压缩为 32 位 | 堆接近 32GB 时宁低勿高 |
| 对象头 | Mark Word(64bit)存锁 + GC 年龄 + hashcode,Klass Pointer 指向类元数据 | hashCode() 调用后偏向锁撤销,JDK 15 起默认禁偏向锁 |
理解对象创建过程,能帮你写出更 GC 友好 的代码,也能在 JVM 调优时准确判断是堆太小、分配策略不对,还是代码本身有问题。面试官问"对象怎么分配的",答出这 5 步 + TLAB + 压缩指针 + 生产踩坑,P7 级别稳了。