Skip to content

对象头少了 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,逐个测量常见对象的大小和一个真实结构的堆占用,见文末配套实验。

一、对象头里有什么 ​

默认:12 字节mark word:锁、哈希码、GC 年龄64 位压缩类指针32 位压缩对象头:8 字节类指针22 位哈希码31 位年龄4 位保留4 位标签3 位类指针缩到 22 位后,最多能区分约 400 万个已加载的类;加锁时不再覆盖对象头里的类型信息
图 1 · 默认情况下,对象头是 64 位的 mark word 加 32 位的压缩类指针,共 12 字节;压缩对象头把类指针缩到 22 位并塞进同一个 64 位字里,共 8 字节。哈希码仍是 31 位,另留 4 位给未来的值类型

默认配置下(64 位、开启压缩类指针),对象头由两部分组成:64 位的 mark word 放锁状态、哈希码、GC 年龄;后面跟着 32 位的压缩类指针,一共 12 字节。压缩对象头把类指针缩到 22 位,和其他信息一起塞进一个 64 位字里,一共 8 字节。

实测字段的起始位置跟着变了:

默认压缩对象头
第一个 int 字段的偏移128
byte[] 第一个元素的偏移1612
long[] 第一个元素的偏移1616

long[] 没变,是因为 long 要按 8 字节对齐,数组头 12 字节(8 字节头加 4 字节长度)之后要补 4 字节才能放第一个元素。

二、谁变小了,谁没有 ​

对象的总大小要向上取到 8 的倍数。头少了 4 字节,对象是否变小,取决于原来补齐时浪费了多少:

Longvalue:long 824 字节16 字节Integervalue:int 416 字节16 字节(不变)HashMap.Nodeint + 3 个引用32 字节24 字节Stringhash、coder 等 1024 字节24 字节(不变)默认头 12 字节压缩头 8 字节字段对齐补齐
图 2 · 对象大小要向上取到 8 的倍数。原来需要补齐 4 字节以上的对象,头省下 4 字节后刚好少一个 8 字节单位;原来正好对齐的对象,省下的 4 字节又被补齐吃掉。实测 Long 从 24 变成 16 字节,Integer 仍是 16 字节

每种类型分配 100 万个实例,用 GC.class_histogram 取实例数和总字节数,算出单个实例的大小(单位:字节,JDK 25 与 26 结果相同):

类型默认压缩对象头
Object168
Long2416
两个 int 字段2416
int 加三个引用(与 HashMap.Node 字段相同)3224
byte[4]2416
Integer1616
一个 int 字段1616
三个 int 字段2424
byte[0]、byte[8]16、2416、24

规律很简单:原来「头加字段」除以 8 余 1 到 4 的对象,现在正好少一个 8 字节单位;原来余 5 到 7 或者正好整除的对象,省下的 4 字节又被对齐补回去,大小不变。所以同样开启这个特性,有的数据结构省得多,有的几乎不省。

三、一个 HashMap 能省多少 ​

用一个常见的结构看整体效果:100 万个条目的 HashMap<Long, Order>,Order 是一个包含 long、int 和一个 String 的 record,每条订单的 SKU 字符串形如 SKU-123。全量 GC 后的堆占用:

默认压缩对象头
JDK 25138.3 MB115.1 MB(−16.8%)
JDK 26139.5 MB116.3 MB(−16.6%)

按类拆开(JDK 25,每类 100 万个实例):

类默认压缩对象头
HashMap$Node32 字节24 字节
Long(键)24 字节16 字节
Order32 字节24 字节
String(SKU)24 字节24 字节
byte[](字符串内容,7 个字节)24 字节24 字节
桶数组(1 个)8 MB8 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。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权