对象创建与内存分配策略

对象创建与内存分配策略

Java 对象的创建看似简单,但 JVM 内部经历了复杂的流程。理解这个过程有助于优化内存使用和排查问题。

对象创建流程

new关键字
|
v
类加载检查 -> 是否已加载?
|否
v
加载 -> 验证 -> 准备 -> 解析 -> 初始化
|
v
内存分配(堆)
|
v
零值初始化
|
v
设置对象头
|
v
执行<init>构造方法
|
v
对象创建完成

1. 类加载检查

User user = new User();

JVM 执行到 new 指令时:

  1. 检查 User 是否已被加载
  2. 未加载则执行类加载流程
  3. 检查类是否 abstract/interface(不能实例化)

2. 内存分配方式

指针碰撞(Bump the Pointer)

适用场景:堆内存规整(Serial、ParNew 等带 Compact 的收集器)。

已使用    |  空闲
████████|▶ 分配指针移动

碰撞指针

分配方式:将指针向空闲方向移动对象大小的距离。

空闲列表(Free List)

适用场景:堆内存不规整(CMS 等基于 Mark-Sweep 的收集器)。

已使用  空闲  已使用  空闲  已使用
███████ ███ ██████ ██████ ████

从空闲列表找合适块

分配方式:从空闲列表中找到足够大的内存块分配。

3. 线程安全问题

问题

多个线程同时分配内存,可能产生冲突。

解决方案

方案1:CAS + 重试

// 采用CAS保证原子性
while (CAS(top, top + size)) {
// 分配成功
}

方案2:TLAB(Thread Local Allocation Buffer)

-XX:+UseTLAB        # 启用TLAB(默认开启)
-XX:TLABSize=256k # 设置TLAB大小
线程A的TLAB        线程B的TLAB        共享Eden区
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 分配中... │ │ 分配中... │ │ │
└──────────┘ └──────────┘ └──────────┘

先在TLAB分配,TLAB满了再在共享区分配(需同步)

4. 对象内存布局

对象头(Header)

┌─────────────────────────────────────────┐
│ 对象头(64位JVM) │
├─────────────────────────────────────────┤
│ Mark Word(64bit) │
│ ├─ hashCode: 31bit │
│ ├─ GC年龄: 4bit │
│ ├─ 偏向锁标记: 1bit │
│ ├─ 锁状态: 2bit │
│ └─ 其他: 26bit │
├─────────────────────────────────────────┤
│ Class Metadata Address(64bit) │
│ (压缩后32bit,UseCompressedOops) │
├─────────────────────────────────────────┤
│ Array Length(仅数组有,32bit) │
└─────────────────────────────────────────┘

实例数据(Instance Data)

字段存储顺序:

  1. long/double
  2. int/float
  3. short/char
  4. byte/boolean
  5. 引用类型

对齐填充:对象大小必须是 8 字节的整数倍。

对象大小计算

// 64位JVM,开启压缩指针
Object obj = new Object();
// Mark Word: 8 bytes
// Class Pointer: 4 bytes
// Padding: 4 bytes
// 总大小: 16 bytes

public class MyClass {
int a; // 4 bytes
long b; // 8 bytes
}
// Mark Word: 8
// Class Pointer: 4
// int a: 4
// long b: 8
// Padding: 4
// 总大小: 32 bytes

5. 指针压缩(Compressed Oops)

原理

-XX:+UseCompressedOops      # 开启压缩指针(默认开启,堆<32G)
-XX:+UseCompressedClassPointers # 压缩类指针

64 位 JVM 中,对象引用从 8 字节压缩到 4 字节:

未压缩:0x0000000123456789(8字节)
压缩后:0x23456789(4字节)+ 基地址偏移

为什么堆小于 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() {
User user = new User(); // user未逃逸出方法
user.setName("test");
// user可以在栈上分配
}

标量替换:将对象拆散为成员变量分配。

// 原始代码
Point point = new Point(1, 2);
int x = point.x;
int y = point.y;

// 优化后
int point_x = 1;
int point_y = 2;
int x = point_x;
int y = point_y;

同步消除:逃逸分析确定对象不会被其他线程访问,消除同步。

-XX:+DoEscapeAnalysis     # 开启逃逸分析(默认开启)
-XX:+EliminateAllocations # 开启标量替换

总结

知识点 要点
分配方式 指针碰撞 vs 空闲列表
线程安全 TLAB + CAS
对象布局 对象头 + 实例数据 + 对齐填充
指针压缩 堆<32G时4字节引用
分配策略 Eden -> Survivor -> Old
逃逸分析 栈上分配、标量替换、同步消除

理解对象创建和内存分配,有助于写出内存友好的代码和进行 JVM 参数调优。


   转载规则


《对象创建与内存分配策略》 小乐 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录