《Java 虚拟机原理与性能调优》3.2 对象布局:对象头、压缩指针与字段重排

一个 Loan 到底占多少内存?字段宽度相加是 36 字节,JOL 说是 48。本节用 JOL 0.17 打印 Book、Small、Padded、int[3]、Loan 与 FinePolicy 的对象布局,解释 mark word、压缩类指针、字段重排与 8 字节对齐,再用 -XX:-UseCompressedOops 对照,最后核算 100 万 Loan 的缓存预算。

本节目标:能读 JOL 的对象布局输出,知道对象头的 12 字节、压缩指针和字段重排各自把对象撑大或压小了多少。
适用版本:JDK 25(Temurin 25.0.5);JOL 0.17;Maven 3.9.12;默认压缩 oops。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1,压缩 oops,默认 MaxHeapSize 8 GB。

承接的问题

libraryd 计划把最近 100 万个 Loan 放进 LoanCache,团队按字段估了一遍:long 8 字节,两个 String 引用各 4 字节,三个 LocalDate 引用各 4 字节,int 4 字节,合计 36 字节,100 万个就是 36 MB。有人加了一句「加上对象头最多 50 MB」,预算就这样定了。

这个估算漏了三件事:对象头不是 0,引用在开启压缩 oops 时是 4 字节但在关闭时是 8 字节,对象末尾还要按 8 字节对齐。最终 Loan 是 48 字节,不是 36。误差不大,但同样的错误放到 LoanEvent、FinePolicy 或数组上,会累积成几十 MB 的偏差。

这一节用 JOL(Java Object Layout)0.17 把布局打印出来,所有数字都能在本机复现。JOL 不进入生产依赖,只在 libraryd-benchmarks 或独立的 /tmp 工程里使用。

对象头与 8 字节对齐

先写一个最小的实验类,把要观察的类型都放进去:

package lab;

import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
import org.openjdk.jol.vm.VM;

public final class LayoutLab {

    public record Book(String isbn, String title, String author, int year) {}

    static final class Small {
        int a;
        boolean b;
    }

    static final class Padded {
        long a;
        long b;
        long c;
        int d;
    }

    record Loan(long id, String isbn, String memberId,
                java.time.LocalDate borrowedOn, java.time.LocalDate dueOn,
                java.time.LocalDate returnedOn, int renewCount) {}

    record FinePolicy(int centsPerDay, int graceDays) {}

    private static void show(String name, Object o) {
        ClassLayout layout = ClassLayout.parseInstance(o);
        System.out.println("=== " + name + " instanceSize=" + layout.instanceSize()
                + " headerSize=" + layout.headerSize());
        System.out.println(layout.toPrintable());
    }

    public static void main(String[] args) {
        System.out.println("classPointerSize=" + VM.current().classPointerSize()
                + " objectAlignment=" + VM.current().objectAlignment());
        System.out.println("objectHeaderSize=" + VM.current().objectHeaderSize()
                + " arrayHeaderSize=" + VM.current().arrayHeaderSize()
                + " addressSize=" + VM.current().addressSize());

        show("Book", new Book("978-7-111-54743-5", "Effective Java", "Joshua Bloch", 2018));
        show("Small", new Small());
        show("Padded", new Padded());
        show("int[3]", new int[3]);
        show("Loan", new Loan(1L, "978-7-111-54743-5", "m-001",
                java.time.LocalDate.of(2026, 10, 1),
                java.time.LocalDate.of(2026, 10, 15), null, 0));
        show("FinePolicy", new FinePolicy(50, 0));
    }
}

Maven 依赖只有一个:

<dependency>
  <groupId>org.openjdk.jol</groupId>
  <artifactId>jol-core</artifactId>
  <version>0.17</version>
</dependency>

构建后运行。注意 -Djol.magicFieldOffset=true,原因在「常见错误」里解释:

mvn -q -DskipTests package
java -Djol.magicFieldOffset=true \
     -cp target/classes:$HOME/.m2/repository/org/openjdk/jol/jol-core/0.17/jol-core-0.17.jar \
     lab.LayoutLab

本机输出的探针行:

classPointerSize=4 objectAlignment=8
objectHeaderSize=12 arrayHeaderSize=16 addressSize=8

64 位 JVM 的地址是 8 字节,但类指针只有 4 字节,这是压缩类指针(compressed class pointers)的结果。普通对象头由两部分组成:

  • mark word:8 字节,存哈希码、GC 年龄、锁状态。JDK 25 已经移除偏向锁,JOL 打印 (non-biasable; age: 0)。
  • klass pointer:4 字节,指向类元数据。若关闭压缩类指针,这一项会变回 8 字节,对象头变成 16 字节。

对象头合计 12 字节,对象总大小再按 8 字节对齐。Small 的布局把这两条都体现出来了:

=== Small instanceSize=24 headerSize=12
lab.LayoutLab$Small object internals:
OFF  SZ      TYPE DESCRIPTION               VALUE
  0   8           (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4           (object header: class)    0x010c68f0
 12   4       int Small.a                   0
 16   1   boolean Small.b                   false
 17   7           (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 7 bytes external = 7 bytes total

int 在 12,boolean 在 16,17 到 23 是 7 字节对齐填充。字段本身只占 5 字节,对象却要 24 字节:12 字节头 + 5 字节字段 + 7 字节填充。

压缩 oops:引用从 8 字节变 4 字节

Book 有三个 String 引用和一个 int:

=== Book instanceSize=32 headerSize=12
OFF  SZ               TYPE DESCRIPTION               VALUE
  0   8                    (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4                    (object header: class)    0x01056f10
 12   4                int Book.year                 2018
 16   4   java.lang.String Book.isbn                 (object)
 20   4   java.lang.String Book.title                (object)
 24   4   java.lang.String Book.author               (object)
 28   4                    (object alignment gap)
Instance size: 32 bytes

压缩 oops 把对象引用从 8 字节压到 4 字节。原理是堆地址按 8 字节对齐,地址的低 3 位恒为 0,所以可以存「地址右移 3 位」的 32 位值,寻址范围是 4 GB × 8 = 32 GB。本机 -Xmx1g 时 VM.info 显示 Compressed Oops mode: Zero based, Oop shift amount: 3。

Book 的字段宽度相加是 12(三个引用)+ 4(int)= 16,加 12 字节头是 28,向上对齐到 32。如果没有压缩 oops,三个引用各 8 字节,总大小会变成 40。

用 -XX:-UseCompressedOops 再跑一次,对比很清楚:

类型默认(压缩 oops)-XX:-UseCompressedOops差异来源
Book32403 个引用从 4 字节变 8 字节
Loan48645 个引用从 4 字节变 8 字节
Small2424只有基本类型
Padded4040只有基本类型
int[3]3232基本类型数组
FinePolicy2424两个 int

注意 classPointerSize 在关闭 oops 后仍然是 4,因为 UseCompressedClassPointers 默认仍为 true:

classPointerSize=4 objectAlignment=8
objectHeaderSize=12 arrayHeaderSize=16 addressSize=8

只有再加 -XX:-UseCompressedClassPointers,类指针才会变成 8 字节,对象头变成 16 字节。这两个开关独立,是常见的误解来源。

字段重排:Padded 的 int 为什么在偏移 12

Padded 声明顺序是三个 long 加一个 int,但 JOL 打印的顺序是 int 在前:

=== Padded instanceSize=40 headerSize=12
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0x010c6b00
 12   4    int Padded.d                  0
 16   8   long Padded.a                  0
 24   8   long Padded.b                  0
 32   8   long Padded.c                  0
Instance size: 40 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

对象头结束在 12,而 long 要求 8 字节对齐,下一个合法偏移是 16。如果按声明顺序放,12 到 15 会浪费 4 字节,最后对象还是 40 字节,但中间多一个洞。HotSpot 的字段布局会把字段按宽度分组重排:先放 long/double,再放 int/float,再放 short/char,最后放 byte/boolean,同时把窄字段塞进对齐缝隙。这里 int 被放到偏移 12,正好填满 12 到 15 的缝,三个 long 从 16 开始连续排列。

重排是 JVM 实现细节,不是 Java 语言规范。它带来两个实际后果:

  • 不能假设字段偏移,也不能序列化原始内存当协议。
  • 字段声明顺序会影响对象大小。把 boolean 和 long 混在一起时,声明顺序不同可能差 8 字节。

Padded 是「重排后没有浪费」的例子;Small 是「窄字段多、填充多」的例子。设计高频小对象时,把窄字段集中声明通常不会变差,但不要为了省几字节去手工重排——HotSpot 已经做了。

数组、record 与真实类型

int[3] 展示了数组的额外 4 字节长度字段:

=== int[3] instanceSize=32 headerSize=16
[I object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0x00177288
 12   4        (array length)            3
 16  12    int [I.<elements>             N/A
 28   4        (object alignment gap)
Instance size: 32 bytes

数组头是 16 字节:8 字节 mark + 4 字节类指针 + 4 字节长度。12 字节数据加 4 字节填充,总共 32。数组长度字段是 int,所以单个数组最多约 21 亿个元素;实际还受堆大小限制。

record 没有额外开销。Book 的 32 字节和普通类完全一样,Loan 的 48 字节也是:

=== Loan instanceSize=48 headerSize=12
OFF  SZ                  TYPE DESCRIPTION               VALUE
  0   8                       (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4                       (object header: class)    0x010c6d10
 12   4                   int Loan.renewCount           0
 16   8                  long Loan.id                   1
 24   4      java.lang.String Loan.isbn                 (object)
 28   4      java.lang.String Loan.memberId             (object)
 32   4   java.time.LocalDate Loan.borrowedOn           (object)
 36   4   java.time.LocalDate Loan.dueOn                (object)
 40   4   java.time.LocalDate Loan.returnedOn           null
 44   4                       (object alignment gap)
Instance size: 48 bytes

renewCount 被重排到偏移 12,id 从 16 开始,五个引用从 24 到 43,最后 4 字节填充。FinePolicy 只有两个 int,24 字节:12 字节头 + 8 字节字段 + 4 字节填充。

LocalDate 本身也不小:

=== LocalDate instanceSize=24 headerSize=12
OFF  SZ    TYPE DESCRIPTION               VALUE
  0   8         (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4         (object header: class)    0x003bce90
 12   4     int LocalDate.year            2026
 16   2   short LocalDate.month           10
 18   2   short LocalDate.day             15
 20   4         (object alignment gap)
Instance size: 24 bytes

所以「一个 Loan 48 字节」只是浅大小。它引用的每个 LocalDate 还要 24 字节,每个 String 至少 24 字节再加底层 byte[]。

在 libraryd 里落地

LayoutLab 放在 libraryd-benchmarks 或 /tmp 工程,不进入 libraryd-domain。它的用途不是运行时检查,而是给出一次性的布局基线,写进容量规划文档。

用 GraphLayout 看整张对象图,Book 的浅大小是 32,保留大小是 208:

bookGraphTotal=208
lab.LayoutLab$Book@33f88abd footprint:
     COUNT       AVG       SUM   DESCRIPTION
         3        34       104   [B
         3        24        72   java.lang.String
         1        32        32   lab.LayoutLab$Book
         7                 208   (total)

3 个 String 各 24 字节,3 个 byte[] 共 104 字节,加上 Book 自己 32 字节。Loan 在 returnedOn == null 时保留大小也是 208:48 字节对象 + 2 个 String(48)+ 2 个 byte[](64)+ 2 个 LocalDate(48)。

回到 100 万 Loan 的预算。如果 isbn 和 memberId 的 String 与 Book/Member 缓存共享,LocalDate 每行独立:

部分单个100 万个
Loan 对象4848 MB
引用数组或 HashMap 节点约 40约 40 MB
borrowedOn/dueOn(未归还)2 × 2448 MB
isbn/memberId 字符串(若共享)00
合计约 136 MB

如果 JDBC 每行返回新的 String,每个 String 还要 24 字节加 32 到 40 字节的 byte[],两个字段再增加约 120 MB。这就是「对象图比对象大」的具体数字。

因此 LoanCache 的键和值要分开设计:用 long id 或紧凑的 isbn 编码做键,避免把整行 Loan 连同三个日期对象一起缓存;日期可以用 long 的 epoch day 代替 LocalDate,把每个 Loan 从 48 字节降到 40 字节左右,也去掉两个 24 字节的 LocalDate。

常见错误与边界

「JOL 在 record 上取不到字段偏移。」 直接运行会得到:

Exception in thread "main" java.lang.RuntimeException: Cannot get the field offset, try with -Djol.magicFieldOffset=true
	at org.openjdk.jol.vm.HotspotUnsafe.fieldOffset(HotspotUnsafe.java:533)
Caused by: java.lang.UnsupportedOperationException: can't get field offset on a record class: private final java.lang.String lab.LayoutLab$Book.isbn
	at jdk.unsupported/sun.misc.Unsafe.objectFieldOffset(Unsafe.java:903)

record 的字段是 private final,Unsafe.objectFieldOffset 在 JDK 25 上拒绝为 record 计算偏移。-Djol.magicFieldOffset=true 让 JOL 改用另一种方式读取字段位置。这个开关只影响实验工具,不影响生产代码。

「把 JOL 的 Unsafe 告警当成错误。」 启动时还会看到:

WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::arrayBaseOffset has been called by org.openjdk.jol.vm.HotspotUnsafe
WARNING: sun.misc.Unsafe::arrayBaseOffset will be removed in a future release

JOL 0.17 仍通过 sun.misc.Unsafe 读取数组基址和字段偏移。这是工具的依赖,不是 libraryd 的代码问题;升级 JOL 或换用 VarHandle 后才能消除。不要把这条告警加进应用的 --sun-misc-unsafe-memory-access=deny 白名单。

「关掉压缩 oops 就能精确控制对象大小。」 -XX:-UseCompressedOops 只影响对象引用,类指针仍由 UseCompressedClassPointers 控制。两者都关掉后,对象头从 12 字节变成 16 字节,Book 会从 32 变成 48,而不是 40。另外,关闭压缩 oops 会让堆地址空间需求变大,-Xmx 超过 32 GB 时本来也无法启用压缩 oops;不要为了「布局可预测」在生产关闭它。

小结

  • 对象头默认 12 字节:8 字节 mark word 加 4 字节压缩类指针;数组再加 4 字节长度,头变成 16 字节。
  • 对象总大小按 8 字节对齐,Small 的 5 字节字段最终占 24 字节,其中 7 字节是填充。
  • 压缩 oops 把引用从 8 字节压到 4 字节;Book 从 40 降到 32,Loan 从 64 降到 48,基本类型布局不变。
  • HotSpot 会重排字段,Padded 的 int 被放到偏移 12 填补对齐缝隙;字段偏移不是语言规范。
  • record 没有额外对象头开销;Book 32 字节、Loan 48 字节、FinePolicy 24 字节。
  • 浅大小不等于缓存成本:Book 浅 32、保留 208;100 万 Loan 的对象图约 136 MB 起,字符串不共享时更高。

知道了对象多大,下一个问题是这些对象怎么被分配出来:TLAB 如何让 4 个线程各自碰撞指针,大对象又为什么绕过 TLAB 直接进 Humongous Region。

阅读导航:上一节:3.1 运行时数据区:堆、栈、元空间与代码缓存 · 下一节:3.3 分配与 TLAB:指针碰撞、线程本地缓冲与大对象 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发