本节目标:能读 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 | 差异来源 |
|---|---|---|---|
Book | 32 | 40 | 3 个引用从 4 字节变 8 字节 |
Loan | 48 | 64 | 5 个引用从 4 字节变 8 字节 |
Small | 24 | 24 | 只有基本类型 |
Padded | 40 | 40 | 只有基本类型 |
int[3] | 32 | 32 | 基本类型数组 |
FinePolicy | 24 | 24 | 两个 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 对象 | 48 | 48 MB |
引用数组或 HashMap 节点 | 约 40 | 约 40 MB |
borrowedOn/dueOn(未归还) | 2 × 24 | 48 MB |
isbn/memberId 字符串(若共享) | 0 | 0 |
| 合计 | 约 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没有额外对象头开销;Book32 字节、Loan48 字节、FinePolicy24 字节。- 浅大小不等于缓存成本:
Book浅 32、保留 208;100 万Loan的对象图约 136 MB 起,字符串不共享时更高。
知道了对象多大,下一个问题是这些对象怎么被分配出来:TLAB 如何让 4 个线程各自碰撞指针,大对象又为什么绕过 TLAB 直接进 Humongous Region。
阅读导航:上一节:3.1 运行时数据区:堆、栈、元空间与代码缓存 · 下一节:3.3 分配与 TLAB:指针碰撞、线程本地缓冲与大对象 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。