【JVM原理详解】18-对象内存布局-对象头与实例数据与对齐填充 对象内存布局对象头、实例数据与对齐填充引言在上一篇文章中我们跟随new指令走完了对象创建的五步流程。其中第四步设置对象头提到了对象头的存在但并未展开。实际上一个 Java 对象在内存中的布局远比想象中精巧——它由三部分组成对象头Object Header、实例数据Instance Data和对齐填充Padding。理解对象内存布局不仅能帮助我们在性能敏感场景下估算内存占用更是理解锁升级Mark Word 变化、指针压缩、字段重排等高级特性的前提。本篇将基于 HotSpot JVM用 JOL 工具实测对象在内存中的真实模样。一、对象内存布局总览在 HotSpot 虚拟机中对象的内存布局如下------------------------------------------------------------------ | Java 对象内存布局 | ------------------------------------------------------------------ | | | ------------------------------------------------------------ | | | 对象头 (Object Header) | | | | | | | | ------------------------------------ | | | | | Mark Word | 32bit / 64bit | | | | | ------------------------------------ | | | | | Klass Pointer | 32bit / 64bit | | | | | ------------------------------------ | | | | | 数组长度 | 32bit (仅数组) | | | | | ------------------------------------ | | | ------------------------------------------------------------ | | | 实例数据 (Instance Data) | | | | 父类字段 → 子类字段非绝对会重排优化 | | | ------------------------------------------------------------ | | | 对齐填充 (Padding) | | | | 补齐到 8 字节的整数倍 | | | ------------------------------------------------------------ | | | ------------------------------------------------------------------三部分中对象头和实例数据是核心对齐填充只是起占位作用。下面逐一深入。二、对象头详解对象头是 JVM 管理对象的核心元数据区域包含三部分Mark Word、Klass Pointer 和数组长度仅数组对象。2.1 Mark WordMark Word 用于存储对象自身的运行时数据它是实现轻量级锁、偏向锁、GC 分代年龄等功能的关键数据结构。在 64 位 JVM 中Mark Word 占8 字节64 bit在 32 位 JVM 中占4 字节32 bit。Mark Word 被设计为非固定的数据结构以便在极小的空间内存储更多信息它会根据对象的不同状态复用存储空间。64 位 JVM Mark Word 结构无指针压缩------------------------------------------------------------------ | 64 位 Mark Word 结构 | ------------------------------------------------------------------ | 锁状态 | Mark Word 内容 (64 bit) | |-----------------------------------------------------------------| | 无锁 | unused:25 | hash:31 | unused:1 | age:4 | 0 01 | | 偏向锁 | thread:54 | epoch:2 | unused:1 | age:4 | 1 01 | | 轻量级锁 | ptr_to_lock_record:62 | 00 | | 重量级锁 | ptr_to_heavyweight_monitor:62 | 10 | | GC 标记 | 空 | 11 | ------------------------------------------------------------------ | 最后 2 bit 为锁标志位偏向锁时倒数第 3 bit 为偏向标志 | ------------------------------------------------------------------32 位 JVM Mark Word 结构------------------------------------------------------------------ | 32 位 Mark Word 结构 | ------------------------------------------------------------------ | 锁状态 | Mark Word 内容 (32 bit) | |-----------------------------------------------------------------| | 无锁 | hash:25 | age:4 | 0 01 | | 偏向锁 | thread:23 | epoch:2 | age:4 | 1 01 | | 轻量级锁 | ptr_to_lock_record:30 | 00 | | 重量级锁 | ptr_to_heavyweight_monitor:30 | 10 | | GC 标记 | 空 | 11 | ------------------------------------------------------------------各字段含义字段说明hash对象的 identity hash code31 位首次调用hashCode()时计算并写入ageGC 分代年龄4 位最大值 15所以-XX:MaxTenuringThreshold最大为 15thread持有偏向锁的线程 IDepoch偏向锁时间戳用于批量重偏向优化ptr_to_lock_record指向栈中锁记录的指针轻量级锁ptr_to_heavyweight_monitor指向 ObjectMonitor 的指针重量级锁为什么 Mark Word 要设计成复用结构因为对象头在每个对象中都存在如果为每种状态预留独立空间对象头会变得很大造成严重内存浪费。通过复用同一块 64 bit 空间HotSpot 用最小的开销实现了多种运行时状态的记录。关键细节一旦对象调用过hashCode()Mark Word 中就会存入 hash 值此时该对象无法再进入偏向锁状态因为 31 位 hash 和 54 位 thread ID 无法共存。这是为什么有些对象天然不支持偏向锁。2.2 Klass PointerKlass Pointer 是类型指针指向方法区或元空间中该对象的类元数据。JVM 通过这个指针确定对象是哪个类的实例。在 64 位 JVM 中指针默认占8 字节。但从 JDK 6u23 开始HotSpot 默认开启指针压缩Compressed Oops将指针压缩为4 字节前提是堆大小不超过 32GB。# 查看指针压缩状态java-XX:PrintFlagsFinal-version|grepCompressedOops# bool UseCompressedOops true# 关闭指针压缩-XX:-UseCompressedOops# 开启指针压缩默认-XX:UseCompressedOops为什么 32GB 是指针压缩的上限压缩后的指针为 32 位但采用 8 字节对齐寻址每个对象地址是 8 的倍数因此可寻址空间为 4GB × 8 32GB。超过 32GB 后压缩指针无法表达所有地址必须回退到 64 位指针。2.3 数组长度如果对象是一个数组对象头中还会有一块记录数组长度的数据占 4 字节。因为 JVM 可以从对象的类元数据中确定对象的大小但无法从元数据中确定数组的长度所以需要额外存储。int[]arr1newint[0];// 对象头 16B (Mark 8B Klass 4B length 4B)int[]arr2newint[10];// 对象头 16B 实例数据 40B 56B2.4 对象头大小汇总配置普通对象头数组对象头32 位 JVM8B (Mark 4B Klass 4B)12B (Mark 4B Klass 4B length 4B)64 位 JVM 指针压缩12B (Mark 8B Klass 4B)16B (Mark 8B Klass 4B length 4B)64 位 JVM 无压缩16B (Mark 8B Klass 8B)20B (Mark 8B Klass 8B length 4B)注意64 位 JVM 无压缩时普通对象头 16B 已经是 8 的倍倍数但数组对象头 20B 不是 8 的倍数所以数组对象在无压缩时通常会被填充到 24B。三、实例数据实例数据是对象真正存储的有效信息即程序代码中定义的各种类型的字段内容包括从父类继承的和自身定义的。3.1 字段布局策略HotSpot 默认对字段的分配顺序如下可通过-XX:FieldsAllocationStyle调整longs/doubles → ints → shorts/chars → bytes → booleans → oops(引用)相同宽度的字段会被分配到一起存放。在满足上述顺序的前提下父类字段会出现在子类字段之前。但 HotSpot 并不是死板地遵循这个顺序——它会进行字段重排优化以最小化内存占用。# 字段分配风格默认 1# 0: 先放 longs/doubles再放 ints再放其他# 1: 先放 oops再放 longs/doubles再放 ints默认# 2: 按声明顺序分配-XX:FieldsAllocationStyle1# 开启紧凑字段布局默认开启-XX:CompactFieldsCompactFields 的作用允许子类中较窄的字段插入到父类字段的空隙中以节省内存。3.2 字段布局示例来看一个具体例子publicclassFieldLayoutDemo{// 故意打乱声明顺序privatebooleanflag;// 1Bprivatelongtimestamp;// 8Bprivateintcount;// 4BprivateStringname;// 4B (压缩指针)privatebytedata;// 1B}实际内存布局JOL 输出会按照 long → int → byte → boolean → oops 重排# OFFSET SIZE TYPE DESCRIPTION VALUE# 0 4 (object header: Mark) ...# 4 4 (object header: Mark) ...# 8 4 (object header: Klass) ...# 12 8 long FieldLayoutDemo.timestamp 0# 20 4 int FieldLayoutDemo.count 0# 24 1 byte FieldLayoutDemo.data 0# 25 1 bool FieldLayoutDemo.flag false# 26 2 (alignment padding)# 28 4 ref FieldLayoutDemo.name null# 32 0 (object size)# Instance size: 32 bytes可以看到字段在内存中的顺序与声明顺序完全不同而是按照类型宽度重新排列以减少填充浪费。3.3 父类与子类的字段布局classParent{inta;// 4Blongb;// 8B}classChildextendsParent{intc;// 4Bbooleand;// 1B}子类对象内存布局# OFFSET SIZE TYPE DESCRIPTION# 0 8 (object header)# 8 4 (object header: Klass)# 12 8 long Parent.b # 父类字段在前# 20 4 int Parent.a# 24 4 int Child.c # 子类字段在后# 28 1 bool Child.d# 29 3 (alignment padding)# Instance size: 32 bytes父类字段出现在子类字段之前。如果开启 CompactFields子类的窄字段可能会插入父类字段的空隙中。四、对齐填充对齐填充并不是必然存在的也没有特别的含义它仅仅起着占位符的作用。HotSpot 要求对象的大小必须是8 字节的整数倍。对象头部分已经被设计为 8 的倍数64 位 JVM所以对齐填充主要出现在实例数据区域之后。4.1 为什么要 8 字节对齐CPU 访问效率现代 CPU 以 8 字节64 位为单位读取内存对齐的数据可以一次读取完成未对齐的数据可能需要两次内存访问。指针压缩的前提8 字节对齐使得压缩指针可以用 32 位表示 32GB 地址空间每个地址是 8 的倍数地址值右移 3 位即可用 32 位存储。# 设置对象对齐字节默认 8可设为 8 的幂-XX:ObjectAlignmentInBytes8如果将对齐字节设为 16压缩指针可寻址空间会翻倍64GB但内存浪费会增加。4.2 对齐填充示例publicclassPaddingDemo{privateinta;// 4B}// 对象头: 12B (Mark 8B Klass 4B)// 实例数据: 4B (int a)// 总计: 16B → 已是 8 的倍数无需填充publicclassPaddingDemo2{privateinta;// 4Bprivatebyteb;// 1B}// 对象头: 12B// 实例数据: 5B (int a byte b)// 总计: 17B → 需填充 7B 到 24B五、使用 JOL 工具验证对象大小JOLJava Object Layout是 OpenJDK 提供的工具可以精确查看对象在内存中的布局。5.1 引入 JOL!-- Maven 依赖 --dependencygroupIdorg.openjdk.jol/groupIdartifactIdjol-core/artifactIdversion0.17/version/dependency5.2 基本使用importorg.openjdk.jol.info.ClassLayout;importorg.openjdk.jol.info.GraphLayout;publicclassJOLDemo{publicstaticvoidmain(String[]args){ObjectobjnewObject();System.out.println( new Object() );System.out.println(ClassLayout.parseInstance(obj).toPrintable());int[]arrnewint[5];System.out.println( new int[5] );System.out.println(ClassLayout.parseInstance(arr).toPrintable());Stringstrhello;System.out.println( \hello\ );System.out.println(ClassLayout.parseInstance(str).toPrintable());// 查看对象引用图的总大小包含引用的其他对象PersonpersonnewPerson(Bob,30);System.out.println( Person 对象引用图 );System.out.println(GraphLayout.parseInstance(person).toPrintable());System.out.println(Total size: GraphLayout.parseInstance(person).totalSize() bytes);}}classPerson{Stringname;intage;Person(Stringname,intage){this.namename;this.ageage;}}5.3 典型输出分析new Object()的输出64 位 JVM开启指针压缩# java.lang.Object object internals:# OFFSET SIZE TYPE DESCRIPTION VALUE# 0 4 (object header: Mark) 0x00000001# 4 4 (object header: Mark) 0x00000000# 8 4 (object header: Klass) 0x0000b005# 12 4 (alignment padding)# Instance size: 16 bytesnew Object()占 16 字节对象头 12B 对齐填充 4B。没有任何实例数据——这就是一个空对象的代价。new int[5]的输出# [I object internals:# OFFSET SIZE TYPE DESCRIPTION VALUE# 0 4 (object header: Mark) 0x00000001# 4 4 (object header: Mark) 0x00000000# 8 4 (object header: Klass) 0x0000c003# 12 4 (object header: length) 5# 16 20 int [I.elements N/A# 36 4 (alignment padding)# Instance size: 40 bytes数组对象头 16BMark 8B Klass 4B length 4B 5 个 int 共 20B 36B填充到 40B。5.4 观察锁状态对 Mark Word 的影响importorg.openjdk.jol.info.ClassLayout;publicclassLockStateDemo{staticObjectlocknewObject();publicstaticvoidmain(String[]args)throwsInterruptedException{Thread.sleep(5000);// 等待偏向锁启动JVM 启动 4s 后开启偏向锁System.out.println( 无锁/可偏向状态 );System.out.println(ClassLayout.parseInstance(lock).toPrintable());synchronized(lock){System.out.println( 轻量级锁状态 );System.out.println(ClassLayout.parseInstance(lock).toPrintable());}System.out.println( 释放锁后 );System.out.println(ClassLayout.parseInstance(lock).toPrintable());}}运行后可以看到 Mark Word 的最后 3 位锁标志位在不同状态下发生变化偏向锁101、轻量级锁000、无锁释放后001。这部分内容在后续锁升级专题会详细展开。5.5 使用 JOL 命令行除了代码方式也可以直接用命令行查看 VM 信息# 查看 JVM 的对象布局相关信息java-XX:PrintFlagsFinal-version|grep-iobjectalignment\|compressedoops\|fieldsallocation# 输出示例# bool UseCompressedOops true# intx FieldsAllocationStyle 1# intx ObjectAlignmentInBytes 8# bool CompactFields true实践要点小对象的内存开销不容忽视一个new Object()占 16B其中 12B 是对象头有效载荷为 0。在设计大规模集合如百万级元素的 HashMap时优先考虑基本类型数组或第三方库如 Eclipse Collections、fastutil以减少对象头开销。包装类比基本类型昂贵得多Integer对象占 16B对象头 12B int 4B而int只占 4B。ArrayListInteger的内存开销是int[]的 4 倍以上还要算 ArrayList 内部的 Object[] 引用。在内存敏感场景优先使用基本类型。指针压缩的 32GB 边界堆超过 32GB 后指针压缩自动失效每个引用从 4B 变为 8B对象头从 12B 变为 16B。这意味着从 31GB 到 33GB实际可用内存可能不增反减。如果业务需要大堆建议考虑 16GB × 2 的多 JVM 方案。字段顺序影响内存占用虽然 JVM 会自动重排字段但在极端场景下如关闭 CompactFields合理的字段声明顺序仍能减少填充浪费。将相同类型的字段声明在一起是良好实践。数组对象比想象中大一个new byte[1]占 24B对象头 16B 1B 数据 7B 填充。频繁创建小数组会造成大量内存浪费考虑使用预分配的缓冲区或对象池。用 JOL 验证假设在优化内存布局时永远用 JOL 实测不要凭经验猜测。JVM 版本、参数配置、字段类型都会影响最终布局。小结Java 对象在内存中由三部分组成对象头、实例数据、对齐填充。对象头包含Mark Word运行时数据如 hash/age/锁状态64 位占 8B、Klass Pointer类型指针压缩后 4B和数组长度仅数组4B。Mark Word 采用复用设计在不同锁状态下存储不同信息是轻量级锁和偏向锁的实现基础。实例数据按long/double → int → short/char → byte/boolean → oops顺序布局HotSpot 会进行字段重排以最小化填充。对齐填充将对象补齐到8 字节整数倍这是 CPU 访问效率和指针压缩的共同要求。JOL 工具是验证对象内存布局的利器在生产优化中应养成用数据说话的习惯。下一篇我们将探讨对象创建后如何被访问——句柄访问与直接指针两种方式的权衡。更多内容JVM调优实战

本月热点