Java 对象布局
Java 对象布局描述对象头、实例字段和对齐填充怎样组成一个对象。本文先沿着 new 的过程介绍类型检查与内存分配,再解释构造顺序、内存布局和安全发布。布局以 Java 17、21 的 HotSpot 为准。
1. 面试核心
先记住这几条,再读正文- 创建过程。 JVM 创建对象时先确保目标类可用,再分配内存、建立字段默认值和对象头,最后执行实例初始化与构造器。卡 · 类型检查
- 线程分配缓冲。 TLAB 是 Java 堆中的线程分配缓冲区,能减少共享分配竞争。卡 · 内存分配
- 对象组成。 Java 17、21 的 HotSpot 对象由对象头、实例字段和对齐填充组成,对象头包含标记字和类指针。卡 · 对象的内存布局
- 数组与头部编码。 数组布局还包含长度字段,对象头编码取决于版本与配置。
- 构造期逸出。 构造期发布
this会让其他线程访问尚未完成构造的对象。
2. 类型检查
创建实例前,JVM 需要先确认目标类已经加载,并完成必要的类初始化。例如:
Order order = new Order(id, amount);
这类表达式会生成 new、dup、参数准备和 invokespecial <init> 等指令。执行 new 时需要解析目标类,并在必要时完成类初始化;随后分配实例。dup 复制对象引用,invokespecial 则调用实例构造器。
对象分配与构造是两个步骤。进入构造器时,实例已经存在,构造器可以通过 this 访问它。如果这时把引用交给其他线程,对方就可能看到尚未完成初始化的字段。
3. 内存分配
分配对象需要找到足够大的空闲空间,具体方式取决于收集器管理的堆布局。连续空闲区域可以移动顶部指针,这称为指针碰撞分配。空闲块分散时,则需要从空闲列表等结构中选择合适的块。
多个线程同时修改共享分配位置,需要同步以避免重叠分配。HotSpot 使用 TLAB(Thread-Local Allocation Buffer,线程本地分配缓冲区)减少这种竞争。每个线程在自己的缓冲区内移动指针,就能快速完成分配。空间耗尽或对象不适合在其中分配时,再申请新 TLAB,或走 TLAB 之外的分配路径。
TLAB 位于 Java 堆,隔离的是分配操作。对象发布后仍可由其他线程访问,也由 GC 统一管理,在需要时移动。
对象超出 TLAB 可用空间,不意味着它会直接进入老年代。它的分配去向取决于收集器和分配策略,需要结合具体实现判断。
4. 零值初始化与构造器
对象分配后,要先建立字段默认值,再执行 Java 代码中的显式初始化。默认值覆盖本类和父类声明的全部实例字段,下面列出三类常见默认值:
- 整数字段的默认值是
0。 - 布尔字段的默认值是
false。 - 引用字段的默认值是
null。
下面的例子展示默认值、字段初始化表达式和构造器赋值的先后关系:
final class Counter {
int step = 1;
int value;
Counter(int initial) {
value = initial;
}
}
本例中的 step 和 value 起初都为 0。构造过程先隐式调用父类无参构造器,再执行 step = 1,最后执行 value = initial。所以,默认值与显式字段初始化属于不同步骤。
Java 17、21 要求显式的 this(...) 或 super(...) 构造器调用位于首条语句。普通类构造器省略它们时,编译器会隐式插入 super()。父类构造返回后,当前类的实例字段初始化表达式与实例初始化块按源码顺序执行,再执行构造器体的剩余部分。
4.1 构造器中的动态分派
构造尚未完成时,方法调用仍会按照对象的实际类型动态分派。因此,父类构造器调用可覆写方法,会执行子类实现;这时子类字段初始化表达式尚未执行。
class Parent {
Parent() { show(); }
void show() {}
}
class Child extends Parent {
int value = 7;
@Override void show() { System.out.println(value); }
}
// new Child() 输出 0。
执行 new Child() 时,父类构造器调用的是 Child.show(),所以读取到子类字段的默认值 0。等父类构造返回,才执行 value = 7。因此,构造器应避免调用依赖完整子类状态的可覆写方法。
5. 对象的内存布局
对象占用的空间除了业务字段,还包含 JVM 管理它所需的信息。Java 17、21 的 HotSpot 对象布局分为三个部分:
- 对象头包含标记字(Mark Word)和类指针。标记字承载锁、身份哈希和 GC 等运行时状态,类指针把对象关联到类型元数据。
- 实例数据保存当前类声明的字段,以及继承自父类的字段。
- 对齐填充补齐对象末尾的空间,让对象大小满足 JVM 的对齐要求。
数组还保存长度字段。HotSpot 可以调整实例字段的排列,以减少字段之间的空洞。因此,单看源码声明顺序,无法推算出实际字段偏移。
压缩指针和对象对齐参数会影响具体字节数,计算方法见 压缩指针与对象对齐。
6. 构造与安全发布
对象构造完成,还需要让使用它的线程看到初始化结果。在构造器中提前交出 this,会使对象在构造结束前就被外部访问,这称为构造期逸出。
final class Listener {
Listener(EventBus bus) {
bus.register(this);
}
}
这里的注册操作把 this 交给了事件总线。若事件立即触发,其他线程就能在构造结束前调用它。可以先构造完整对象,再由工厂或外层生命周期方法完成注册。
正确构造且未发生构造期逸出的对象,其 final 字段具有初始化安全保证。普通字段则需要通过安全发布关系让其他线程看到:
- 使用同一把锁保护发布和读取。
- 通过
volatile引用发布对象。 - 在启动线程前完成初始化,由线程启动规则保证可见性。
- 通过具有相应可见性保证的并发容器交接对象。
发布完成后,如果字段还会被修改,后续读写也需要相应的同步。具体规则见 安全发布。
7. JOL 布局观察
计算对象大小时,可以用 OpenJDK JOL(Java Object Layout)观察目标 JVM 的实际布局。它既能显示字段布局,也能分析对象图,也就是对象及其引用连接起来的结构。下面的调用打印 Order 的实例布局:
System.out.println(ClassLayout.parseClass(Order.class).toPrintable());
JOL 会输出对象头、字段偏移和对齐填充。结果对应当前 JVM,因此记录时需要附上布局配置:
- JDK 版本和 JVM 实现版本。
- JVM 使用的 32 位或 64 位模式。
- 压缩对象指针与压缩类指针是否开启。
- 对象对齐参数及相关启动参数。
Instrumentation.getObjectSize 返回实现相关的浅大小,即对象自身占用的空间。引用字段所指向的其他对象要另外计算,才能估算整个对象图的占用。
8. 常见问题
8.2 身份哈希的存储
身份哈希可以在首次需要时才计算,例如调用 System.identityHashCode 时。因此,新对象的头中未必已经存有计算后的身份哈希值。锁状态和 GC 使用对象头的方式,也会影响这些信息的存储和编码。
8.3 空类对象的占用
空类实例虽然没有业务字段,仍需要保存类型关联以及同步、GC 所需的运行时状态。对象大小还要满足对齐要求,所以空类实例也占空间。具体大小由当前 JVM 的布局配置决定。
9 · 面试题卡片
9.1 Java 对象的创建过程是什么
出现公司:字节跳动、阿里巴巴
考察重点:解释类型检查、分配、字段默认值和构造的先后关系,并区分构造完成与安全发布。
参考回答
JVM 创建对象时会确认目标类可用、分配内存、建立字段默认值和对象头,随后执行构造过程。
HotSpot 可以借助 TLAB 降低多个线程同时分配的竞争。
Java 17、21 中,父类构造返回后,才执行当前类的字段初始化和实例初始化块,再执行构造器体的剩余部分。构造期间若让 this 逸出,其他线程就可能访问未完成的状态。因此,对象应构造完成后,再通过明确的安全发布关系交给其他线程。
9.2 Java 对象的内存布局包含哪些部分
出现公司:字节跳动
考察重点:说明对象头、实例字段和对齐各自的作用,解释数组长度与 JVM 配置如何影响布局。
相关内容:对象的内存布局。
参考回答
Java 17、21 的 HotSpot 对象包含对象头、实例字段和对齐填充,数组还保存长度。
对象头由标记字和类指针组成,分别承载运行时状态和类型关联。实例字段包含本类与父类的字段,对齐填充则补齐所需空间。
HotSpot 可以重排字段来减少空洞,压缩指针和对象对齐参数也会影响大小。因此,实际占用需要用 JOL 在目标 JVM 配置下核对。
9.3 TLAB 是线程私有堆吗,TLAB 放不下的对象会进入老年代吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分线程独立分配与对象共享访问,并解释 TLAB 空间不足后有哪些分配选择。
相关内容:内存分配。
参考回答
TLAB 是共享 Java 堆中供某个线程分配的一段缓冲区,空间不足也不意味着对象会进入老年代。
它让线程独立移动分配指针,减少共享竞争;对象发布后仍可由其他线程访问。
TLAB 放不下对象时,可以重新申请缓冲区,或走 TLAB 外的分配路径。具体分配到哪里,由收集器和分配策略决定。
9.4 父类构造器调用子类覆写方法,会读取到哪些字段值?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:结合动态分派和父子类构造顺序,解释子类方法为什么会读到字段默认值。
相关内容:构造器中的动态分派。
参考回答
父类构造器中的虚方法调用会分派到子类,但此时子类字段初始化器尚未执行,可能读到默认值。
例如,Child 声明 int value = 7,并在覆写的 show() 中打印 value。父类构造器调用 show() 时,实际执行的是 Child.show(),但 value = 7 要等父类构造返回后才执行,所以这里打印 0。构造期间调用可覆写方法,会让子类逻辑提前接触尚未完成初始化的状态。