对象的创建与内存布局
对象的创建过程
当 JVM 遇到 new 指令时,对象创建经过以下步骤:
new 指令
│
▼
① 类加载检查 ──→ 类未加载?──→ 执行类加载
│ 已加载
▼
② 分配内存
│
├── 空间规整?──→ 指针碰撞(Bump the Pointer)
│ └── 使用 CAS 保证线程安全
│
└── 空间不规整?──→ 空闲列表(Free List)
└── 使用 CAS 或 TLAB 保证线程安全
│
▼
③ 内存初始化零值
│
▼
④ 设置对象头(Mark Word + Klass Pointer)
│
▼
⑤ 执行 <init> 方法(构造函数)
1. 类加载检查
JVM 首先检查 new 指令的参数是否能在常量池中定位到类的符号引用,并检查该类是否已被加载、解析和初始化。若未加载,须先执行类加载过程。
2. 分配内存
类加载完成后,JVM 根据对象大小在堆中分配内存。分配方式取决于堆内存是否规整:
- 指针碰撞:堆内存绝对规整时,已用和空闲内存各在一边,中间放一个指针作为分界点。分配内存就是将指针向空闲方向移动对象大小的距离
- 空闲列表:堆内存不规整时,JVM 维护一个列表记录哪些内存块可用,分配时从列表中找到足够大的空间
分配方式的取决于 GC 收集器是否带有压缩整理能力:
- Serial、ParNew、G1、ZGC 等带压缩 → 指针碰撞
- CMS 使用标记-清除 → 空闲列表
3. 并发安全
对象创建在 JVM 中非常频繁,需要保证线程安全:
- CAS + 失败重试:对分配动作进行原子操作
- TLAB(Thread Local Allocation Buffer):每个线程在 Eden 区预先分配一小块私有缓冲区,线程在自己的 TLAB 上分配,用完后再用 CAS 申请新的 TLAB
# TLAB 相关参数
-XX:+UseTLAB # 启用 TLAB(默认开启)
-XX:TLABSize=256k # TLAB 大小
-XX:+PrintTLAB # 打印 TLAB 信息
4. 内存初始化零值
分配内存后,JVM 将分配到的内存空间初始化为零值(不包括对象头)。这保证了对象的实例字段无需赋初值即可使用:
// 以下代码不会出现 NPE 或未定义行为
int count; // 自动初始化为 0
boolean flag; // 自动初始化为 false
Object ref; // 自动初始化为 null
5. 设置对象头
初始化零值后,JVM 设置对象头信息,包括:
- 对象是哪个类的实例
- 如何找到类的元数据
- 对象的 GC 年龄
- 锁状态信息
6. 执行构造方法
从 JVM 角度看,<init> 方法(即构造函数)还没开始执行。从程序员角度看,new 关键字之后才真正开始初始化。
对象的内存布局
在 HotSpot 中,对象在内存中的布局分为三个区域:对象头(Header)、实例数据(Instance Data) 和 对齐填充(Padding)。
┌─────────────────────────────────────┐
│ 对象头 (Header) │
│ ┌────────────────────────────────┐ │
│ │ Mark Word (32/64 bit) │ │
│ │ - 哈希码、GC 年龄、锁状态 │ │
│ ├────────────────────────────────┤ │
│ │ Klass Pointer (32/64 bit) │ │
│ │ - 指向类元数据的指针 │ │
│ ├────────────────────────────────┤ │
│ │ Array Length (可选, 32 bit) │ │
│ │ - 数组对象才有 │ │
│ └────────────────────────────────┘ │
├─────────────────────────────────────┤
│ 实例数据 (Instance Data) │
│ - 父类继承的字段 │
│ - 自身定义的字段 │
├─────────────────────────────────────┤
│ 对齐填充 (Padding) │
│ - 保证对象大小为 8 字节整数倍 │
└─────────────────────────────────────┘
Mark Word
Mark Word 存储对象自身的运行时数据,在 32 位和 64 位 JVM 中的长度分别为 32 位和 64 位。它是实现轻量级锁和偏向锁的关键。
64 位 JVM Mark Word 布局:
| 存储内容 | 标志位 | 状态 |
|---|---|---|
| 对象哈希码、GC 分代年龄 | 01 | 无锁 |
| 指向锁记录的指针 | 00 | 轻量级锁 |
| 指向重量级锁的指针 | 10 | 重量级锁(互斥锁) |
| 空 | 11 | GC 标记 |
| 偏向线程 ID、偏向时间戳、GC 分代年龄 | 01 | 偏向锁 |
Klass Pointer
Klass Pointer 指向对象类型元数据(InstanceKlass),JVM 通过此指针确定对象是哪个类的实例。开启指针压缩后占 4 字节,否则占 8 字节。
数组长度
只有数组对象才有此字段,记录数组长度。JVM 可以通过普通对象的元数据确定大小,但数组不行。
实例数据
实例数据是对象真正存储的有效信息——代码中定义的各种字段内容,包括从父类继承的和自身定义的。
字段存储顺序受 -XX:FieldsAllocationStyle 参数影响,默认策略:
- 先存储基本类型(long/double > int/float > short/char > byte/boolean)
- 再存储引用类型
父类定义的字段出现在子类之前,CompactFields 参数(默认开启)会将较小字段插入父类字段的间隙中。
对齐填充
HotSpot 要求对象大小必须是 8 字节的整数倍。对象头正好是 8 字节的倍数(64 位 JVM 启用压缩),因此当实例数据不是 8 的倍数时,需要 Padding 补齐。
指针压缩(Compressed Oops)
为什么需要指针压缩
64 位 JVM 中,对象引用占 8 字节,相比 32 位的 4 字节增加了约 1.5 倍的内存消耗。更大的内存意味着:
- GC 工作量增大
- 缓存命中率降低
- 内存带宽压力增加
压缩原理
Compressed Oops 将 64 位对象引用压缩为 32 位:
存储时:引用 = (实际地址 - 堆基址) >> 3
使用时:实际地址 = 堆基址 + (引用 << 3)
利用对象 8 字节对齐的特性,低 3 位始终为 0,可以不存储。因此 32 位引用可寻址 2^32 × 8 = 32GB 的堆空间。
启用条件
- 堆大小 < 32GB 时默认启用
- 堆大小 ≥ 32GB 时自动关闭(此时 32 位引用无法寻址整个堆)
- 可通过
-XX:+UseCompressedOops显式启用(默认开启) - 配合
-XX:+UseCompressedClassPointers压缩 Klass Pointer
# 查看压缩指针状态
java -XX:+PrintFlagsFinal -version | grep Compressed
对象大小计算
在 64 位 JVM 启用压缩指针的情况下:
// 一个简单对象
class Simple {
int id; // 4 字节
}
// 对象头: Mark Word(8) + Klass Pointer(4) = 12 字节
// 实例数据: int(4) = 4 字节
// 对齐填充: 0 字节(12 + 4 = 16,已对齐)
// 总计: 16 字节
// 包含引用的对象
class WithRef {
int id; // 4 字节
Object ref; // 4 字节(压缩指针)
}
// 对象头: 12 字节
// 实例数据: 4 + 4 = 8 字节
// 对齐填充: 0 字节
// 总计: 20 → 对齐到 24 字节(补 4 字节 Padding)
对象访问定位
JVM 通过栈上的 reference 数据来操作堆上的具体对象。reference 类型在规范中只规定了一个指向对象的引用,主流访问方式有两种:
句柄访问
reference → ┌──────────────┐
│ 句柄池 │
│ ┌──────────┐ │ ┌────────────┐
│ │实例数据指针├─┼────→│ 堆中对象实例 │
│ ├──────────┤ │ └────────────┘
│ │类型数据指针├─┼─┐ ┌────────────┐
│ └──────────┘ │ └──→│ 方法区类型数据│
└──────────────┘ └────────────┘
- 优点:reference 存储的是稳定的句柄地址,对象移动(GC)时只需修改句柄中的实例数据指针
- 缺点:多一次间接访问开销
直接指针访问
reference → ┌────────────┐ ┌────────────┐
│ 堆中对象实例 │────→│ 方法区类型数据│
└────────────┘ └────────────┘
- 优点:速度快,少一次间接访问
- 缺点:对象移动时需要修改 reference 本身
HotSpot 使用直接指针访问,因为对象访问非常频繁,减少一次间接访问的开销意义很大。GC 时修改 reference 的工作由 GC 负责处理。
小结
本章深入分析了对象在 JVM 中的创建流程和内存布局。理解 Mark Word 的结构是掌握 Java 锁机制的基础;理解指针压缩有助于优化内存使用;理解对象访问方式有助于理解 GC 对应用的影响。下一章将进入垃圾回收算法的学习。
评论