对象头少了 4 字节,为什么有的对象一点没变小
在 JDK 25 上打开压缩对象头,每个对象的头从 12 字节变成 8 字节。实测
Long从 24 字节变成 16 字节,Integer仍然是 16 字节,String也没变;一个 100 万条目的HashMap<Long, Order>省了 16.8% 的堆。省多少,要看对象的字段怎么排。
HotSpot 里每个对象前面都有一个对象头,记录锁状态、哈希码、GC 年龄和这个对象属于哪个类。对象越小、数量越多,头占的比例就越大。JDK 25 把「压缩对象头」(JEP 519)从实验特性转为正式特性,头从 12 字节缩到 8 字节。
「每个对象省 4 字节」是最常听到的说法,但它不成立。本文在 temurin 25.0.4 与 26.0.2 上,分别开关 -XX:+UseCompactObjectHeaders,逐个测量常见对象的大小和一个真实结构的堆占用,见文末配套实验。
一、对象头里有什么
默认配置下(64 位、开启压缩类指针),对象头由两部分组成:64 位的 mark word 放锁状态、哈希码、GC 年龄;后面跟着 32 位的压缩类指针,一共 12 字节。压缩对象头把类指针缩到 22 位,和其他信息一起塞进一个 64 位字里,一共 8 字节。
实测字段的起始位置跟着变了:
| 默认 | 压缩对象头 | |
|---|---|---|
第一个 int 字段的偏移 | 12 | 8 |
byte[] 第一个元素的偏移 | 16 | 12 |
long[] 第一个元素的偏移 | 16 | 16 |
long[] 没变,是因为 long 要按 8 字节对齐,数组头 12 字节(8 字节头加 4 字节长度)之后要补 4 字节才能放第一个元素。
二、谁变小了,谁没有
对象的总大小要向上取到 8 的倍数。头少了 4 字节,对象是否变小,取决于原来补齐时浪费了多少:
每种类型分配 100 万个实例,用 GC.class_histogram 取实例数和总字节数,算出单个实例的大小(单位:字节,JDK 25 与 26 结果相同):
| 类型 | 默认 | 压缩对象头 |
|---|---|---|
Object | 16 | 8 |
Long | 24 | 16 |
两个 int 字段 | 24 | 16 |
int 加三个引用(与 HashMap.Node 字段相同) | 32 | 24 |
byte[4] | 24 | 16 |
Integer | 16 | 16 |
一个 int 字段 | 16 | 16 |
三个 int 字段 | 24 | 24 |
byte[0]、byte[8] | 16、24 | 16、24 |
规律很简单:原来「头加字段」除以 8 余 1 到 4 的对象,现在正好少一个 8 字节单位;原来余 5 到 7 或者正好整除的对象,省下的 4 字节又被对齐补回去,大小不变。所以同样开启这个特性,有的数据结构省得多,有的几乎不省。
三、一个 HashMap 能省多少
用一个常见的结构看整体效果:100 万个条目的 HashMap<Long, Order>,Order 是一个包含 long、int 和一个 String 的 record,每条订单的 SKU 字符串形如 SKU-123。全量 GC 后的堆占用:
| 默认 | 压缩对象头 | |
|---|---|---|
| JDK 25 | 138.3 MB | 115.1 MB(−16.8%) |
| JDK 26 | 139.5 MB | 116.3 MB(−16.6%) |
按类拆开(JDK 25,每类 100 万个实例):
| 类 | 默认 | 压缩对象头 |
|---|---|---|
HashMap$Node | 32 字节 | 24 字节 |
Long(键) | 24 字节 | 16 字节 |
Order | 32 字节 | 24 字节 |
String(SKU) | 24 字节 | 24 字节 |
byte[](字符串内容,7 个字节) | 24 字节 | 24 字节 |
| 桶数组(1 个) | 8 MB | 8 MB |
省下的 23 MB 全部来自节点、键和订单这三类对象;字符串和它的字节数组一个字节也没省。数据里字符串越多,收益越小。OpenJDK 在 JEP 519 里给出的是在 SPECjbb2015 上堆占用减少 22%、CPU 时间减少 8%,那是另一种对象分布,不能直接套用到自己的服务上。
四、要不要打开
在 JDK 25 和 26 上,这个特性是正式参数但默认关闭,需要显式加 -XX:+UseCompactObjectHeaders。打开之前要确认几件事:
- 有没有代码依赖对象布局。 实测打开后字段偏移从 12 变成了 8。用
Unsafe按固定偏移读写字段的代码、自己计算对象大小的工具,都要重新验证;通过Unsafe.objectFieldOffset在运行时取偏移的代码不受影响。 - 类的数量和堆的大小。 类指针只有 22 位,JEP 给出的上限是约 400 万个已加载的类;使用 ZGC 之外的收集器时,堆大小上限是 8 TB。绝大多数服务离这两个上限都很远,但动态生成大量类的程序要留意。
- 在自己的服务上量一次。 对象大小的变化是确定的,吞吐、GC 频率和停顿的变化要看负载。评估方法是同一份流量下,分别在开关两种配置下看:
jcmd <pid> GC.class_histogram里占用最多的几个类是否变小,老年代占用、GC 次数与停顿有没有下降。
GC.class_histogram 的用法和堆转储分析,见 JVM 调优与线上排查;堆大小与收集器的选择,见 G1 与 ZGC。
配套实验
- codesphere-labs/java/compact-object-headers:temurin 25.0.4 与 26.0.2 上开关压缩对象头时的字段偏移、十种对象的实际大小,以及 100 万条目
HashMap<Long, Order>的堆占用与分类明细(验证记录)
参考资料
- JEP 519: Compact Object Headers(JDK 25 转为正式特性,默认不开启)
- JEP 450: Compact Object Headers (Experimental)(对象头各部分的位数与限制)