对象创建与内存布局
new 一个对象看起来只有一行代码,背后却涉及类加载检查、内存分配、零值初始化、对象头设置和构造方法调用五个步骤。理解这个过程,才能解释对象为什么是 16 字节对齐的、synchronized 的锁信息存在哪里、指针压缩到底压缩了什么。本文面向希望把 JVM 知识落到字节层面的 Java 开发者。
对象创建的五个步骤
第一步:类加载检查
遇到 new 指令时,虚拟机先检查常量池中能否定位到该类的符号引用,并确认类是否已被加载、解析和初始化。如果没有,先触发类加载流程。这一步在解释执行和 JIT 编译后都会发生,只是 JIT 会把检查结果内联优化。
第二步:分配内存
类加载完成后,对象所需大小在编译期就已确定。分配方式取决于堆是否规整:
- 指针碰撞(Bump the Pointer):堆内存规整,用一个指针分隔已用和空闲区域,分配就是把指针向空闲方向移动对象大小。
- 空闲列表(Free List):堆内存不规整,虚拟机维护一张空闲块列表,分配时找到足够大的块并更新列表。
选择哪种方式由 GC 是否带压缩决定。Serial、ParNew 使用标记-复制或标记-整理,堆规整,用指针碰撞;CMS 使用标记-清除,会产生碎片,用空闲列表。G1 按 Region 管理,两种方式结合使用。
分配时的并发安全有两种解法:CAS 重试保证指针更新的原子性,或者给每个线程预分配 TLAB。HotSpot 默认使用 TLAB,只有 TLAB 用完或对象过大(超过 -XX:TLABSize 的一半)时才走共享堆的慢路径。
第三步:初始化零值
内存分配完成后,虚拟机把除对象头外的所有字段设为零值。这保证了实例字段即使不显式赋值也能直接使用,Java 中「对象字段有默认值」的语义正来源于此。
第四步:设置对象头
虚拟机把对象的元数据信息、哈希码、GC 分代年龄、锁状态标志写入对象头。此时构造方法还没执行,但一个可用的对象已经诞生。
第五步:执行构造方法
执行 <init> 方法,按 Java 语义完成字段赋值和父类构造调用。
对象的内存布局
HotSpot 中,一个对象由三部分组成:对象头、实例数据、对齐填充。
对象头
对象头分为两部分。第一部分是 Mark Word,在 64 位 JVM 上占 8 字节,内容随锁状态复用:
| 锁状态 | 存储内容 | 标志位 |
| --- | --- | --- |
| 无锁 | 哈希码(31 bit)、分代年龄(4 bit)、偏向标志 | 01 |
| 偏向锁 | 线程 ID、Epoch、分代年龄 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 |
| 重量级锁 | 指向 Monitor 的指针 | 10 |
| GC 标记 | 空 | 11 |
这也是为什么调用 hashCode() 后对象无法再进入偏向锁:Mark Word 中哈希码和线程 ID 争用同一块空间。
注意:JDK 15 起偏向锁默认关闭(JEP 374),JDK 18 彻底移除相关代码。JDK 17 上运行时若看到
-XX:+UseBiasedLocking的警告,属于预期行为。
第二部分是类型指针(Klass Pointer),指向方法区中该对象的类元数据。开启指针压缩时占 4 字节,关闭时占 8 字节。
如果对象是数组,对象头还会多出 4 字节的数组长度。
实例数据
实例数据按字段类型和声明顺序排布,虚拟机尽量让相同宽度的字段靠在一起以减少填充。父类字段排在子类字段之前。long 和 double 占 8 字节,int 和 float 占 4 字节,short 和 char 占 2 字节,byte 和 boolean 占 1 字节,引用类型在压缩指针下占 4 字节。
对齐填充
HotSpot 要求对象起始地址按 8 字节对齐,因此对象总大小必须是 8 的倍数,不足时补齐。这解释了「空对象占 16 字节」的经典问题:Mark Word 8 字节加类型指针 4 字节,共 12 字节,补齐到 16 字节。
指针压缩
64 位 JVM 的引用如果按原生指针存储,每个引用占 8 字节,会显著增加内存占用和缓存压力。压缩指针(Compressed Oops)利用对象地址 8 字节对齐的特性,把地址右移 3 位后存入 32 位空间,寻址范围从 4 GB 扩展到 32 GB。
开启条件与堆大小相关:
- 堆小于 4 GB 时默认开启,因为不需要压缩也能寻址。
- 堆在 4 GB 到 32 GB 之间时默认开启,这是压缩收益最大的区间。
- 堆超过 32 GB 时自动关闭,引用退回 8 字节。
这意味着「把 -Xmx 设成 40G」可能比设成 32G 更浪费内存,因为指针膨胀吃掉了多出来的 8 GB。可以用 -XX:+UseCompressedOops 强制开启,但超过 32 GB 后地址会溢出,必须配合 -XX:ObjectAlignmentInBytes 调整对齐粒度。
用 JOL 观察真实布局
JOL(Java Object Layout)是 OpenJDK 提供的对象布局分析工具,比手算可靠。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
public class LayoutDemo {
static class User {
long id;
int age;
boolean active;
String name;
}
public static void main(String[] args) {
System.out.println(VM.current().details());
System.out.println(ClassLayout.parseClass(User.class).toPrintable());
}
}
输出中会看到 OFFSET SIZE TYPE DESCRIPTION 三段式表格。重点关注两处:Instance size 给出的总字节数,以及 Losses due to the next object alignment 这一行——它说明有多少字节被对齐填充浪费。
在 JDK 17 默认参数(堆小于 32 GB)下,User 的大小是 32 字节:Mark Word 8、类型指针 4、long 8、int 4、boolean 1、引用 4,合计 29 字节,补齐到 32。如果把 long id 换成 int id,总大小会降到 24 字节,这就是字段顺序与类型选择带来的实际收益。
逃逸分析与标量替换
JIT 在 C2 编译时会做逃逸分析:如果对象只在方法内部使用且不会返回,就可能被标量替换——把对象拆成若干局部变量,直接在栈上分配,甚至完全不创建对象。
public long sum() {
long total = 0;
for (int i = 0; i < 1000; i++) {
Point p = new Point(i, i);
total += p.x + p.y;
}
return total;
}
在 -XX:+DoEscapeAnalysis(默认开启)下,Point 对象可能一个都不会分配。这解释了为什么用 jmap 统计对象数量时,某些「理论上大量创建」的对象却看不到。
常见坑 / 最佳实践
- 用
-Xmx40g换吞吐。堆超过 32 GB 后压缩指针失效,引用从 4 字节变成 8 字节,实际可用内存可能不升反降。需要大堆时优先考虑 G1 或 ZGC 的分区设计,而不是盲目加-Xmx。 - 以为
hashCode()不影响锁。调用hashCode()或System.identityHashCode()会写入 Mark Word,使对象无法进入偏向锁。高并发场景下对锁对象提前调用哈希是常见的性能反模式。 - 忽视对象对齐带来的内存放大。大量小对象(如只含一个
int的包装类)实际占用远大于字段之和。批量场景应优先使用数组或基本类型集合。 - 用
new创建可复用的短生命周期对象。日志拼接、格式化、临时集合是逃逸分析的常见失效点,应改用参数化日志和线程局部缓冲。 - 在
equals/hashCode中依赖可变字段。对象进入HashMap后字段被修改,哈希值变化会导致无法查找,这是布局之外但同样与对象状态强相关的坑。
小结
- 对象创建经历类加载检查、内存分配、零值初始化、设置对象头、执行构造方法五步。
- 对象由 Mark Word、类型指针、实例数据和对齐填充组成,空对象在 64 位 JVM 上占 16 字节。
- Mark Word 在锁升级过程中复用同一块空间,哈希码与偏向线程 ID 互斥。
- 压缩指针在 4 GB 到 32 GB 堆区间收益最大,超过 32 GB 自动关闭。
- 用 JOL 测量真实布局,比按字段手算更可靠。
- 点赞
- 收藏
- 关注作者
评论(0)