对象创建与内存分配策略
Java 对象的创建看似简单,但 JVM 内部经历了复杂的流程。理解这个过程有助于优化内存使用和排查问题。
对象创建流程
new关键字 |
1. 类加载检查
User user = new User(); |
JVM 执行到 new 指令时:
- 检查
User是否已被加载 - 未加载则执行类加载流程
- 检查类是否 abstract/interface(不能实例化)
2. 内存分配方式
指针碰撞(Bump the Pointer)
适用场景:堆内存规整(Serial、ParNew 等带 Compact 的收集器)。
已使用 | 空闲 |
分配方式:将指针向空闲方向移动对象大小的距离。
空闲列表(Free List)
适用场景:堆内存不规整(CMS 等基于 Mark-Sweep 的收集器)。
已使用 空闲 已使用 空闲 已使用 |
分配方式:从空闲列表中找到足够大的内存块分配。
3. 线程安全问题
问题
多个线程同时分配内存,可能产生冲突。
解决方案
方案1:CAS + 重试
// 采用CAS保证原子性 |
方案2:TLAB(Thread Local Allocation Buffer)
-XX:+UseTLAB # 启用TLAB(默认开启) |
线程A的TLAB 线程B的TLAB 共享Eden区 |
4. 对象内存布局
对象头(Header)
┌─────────────────────────────────────────┐ |
实例数据(Instance Data)
字段存储顺序:
- long/double
- int/float
- short/char
- byte/boolean
- 引用类型
对齐填充:对象大小必须是 8 字节的整数倍。
对象大小计算
// 64位JVM,开启压缩指针 |
5. 指针压缩(Compressed Oops)
原理
-XX:+UseCompressedOops # 开启压缩指针(默认开启,堆<32G) |
64 位 JVM 中,对象引用从 8 字节压缩到 4 字节:
未压缩:0x0000000123456789(8字节) |
为什么堆小于 32G 才能压缩?
- 4 字节最多表示 2^32 = 4G
- 对象按 8 字节对齐,可表示 4G * 8 = 32G
- 超过 32G,指针压缩失效,对象引用回到 8 字节
注意:堆设置在 32G~48G 之间是浪费的(指针不压缩但性能下降),建议超过 32G 直接设到 48G 以上。
6. 内存分配策略
对象优先在 Eden 分配
byte[] allocation = new byte[2 * 1024 * 1024]; // 2MB,在Eden分配 |
大对象直接进入老年代
-XX:PretenureSizeThreshold=4m # 大于4MB的对象直接进老年代 |
注意:该参数只对 Serial 和 ParNew 收集器有效。
长期存活对象进入老年代
-XX:MaxTenuringThreshold=15 # 晋升年龄阈值(默认15) |
对象每经历一次 Minor GC,年龄加 1,达到阈值晋升。
动态对象年龄判定
Survivor 区中相同年龄的所有对象大小总和超过 Survivor 空间的一半,年龄大于等于该年龄的对象直接进入老年代。
空间分配担保
Minor GC 之前,检查老年代最大可用连续空间是否大于年轻代所有对象总空间:
- 大于:Minor GC 安全
- 小于:检查是否允许担保失败
- 允许:检查老年代可用空间是否大于历次晋升平均大小
- 不允许:Full GC
-XX:+HandlePromotionFailure # JDK6后默认允许担保失败 |
7. 逃逸分析
概念
分析对象动态作用域,判断对象是否逃逸出方法/线程。
优化手段
栈上分配:未逃逸的对象在栈上分配,随方法结束自动销毁。
public void method() { |
标量替换:将对象拆散为成员变量分配。
// 原始代码 |
同步消除:逃逸分析确定对象不会被其他线程访问,消除同步。
-XX:+DoEscapeAnalysis # 开启逃逸分析(默认开启) |
总结
| 知识点 | 要点 |
|---|---|
| 分配方式 | 指针碰撞 vs 空闲列表 |
| 线程安全 | TLAB + CAS |
| 对象布局 | 对象头 + 实例数据 + 对齐填充 |
| 指针压缩 | 堆<32G时4字节引用 |
| 分配策略 | Eden -> Survivor -> Old |
| 逃逸分析 | 栈上分配、标量替换、同步消除 |
理解对象创建和内存分配,有助于写出内存友好的代码和进行 JVM 参数调优。