先说触发这件事的原因。
我们有个订单聚合服务,职责不复杂:从上游拉订单主表,关联出订单项、优惠信息、物流节点,组装成一个 DTO 返回给前端。读多写少,本地缓存了大概 30 万个订单的聚合结果,用的是 Caffeine,没有持久化。
这套服务从 JDK 17 升到 21 之后一直很稳,堆设的 12G,平时用掉 6 到 7G,Young GC 频率正常,一天顶多一次 Full GC。后来业务量涨了一倍多,缓存里的订单从 30 万涨到 80 万,情况就开始变了:堆用量冲到 10G 上下,Full GC 从一天 0 次变成一天三四次,每次 STW 两三秒。前端偶尔会感觉到接口突然变慢,客服也报过几次。
扩容当然是最省事的做法,但机器不是无限的。在决定加内存之前,我先把堆 dump 拉下来看了一遍,想搞清楚这 10G 里到底是什么。
一、对象头到底是个什么东西
用 JOL 之前,很多人对「对象占多少内存」的直觉是错的。直觉是:字段加起来多少就多少。实际不是。
JVM 里每个对象前面都有一段固定长度的元数据,叫对象头。它不存业务数据,但每个对象都得有。在 64 位 JVM 开启压缩指针(-XX:+UseCompressedOops,默认开启)的情况下,对象头由两部分组成:
- mark word:8 字节。存锁状态、偏向标志、GC 分代年龄,以及调用过
identityHashCode之后的哈希值 - klass pointer:4 字节。指向这个对象属于哪个类,也就是方法区里的 InstanceKlass
合起来 12 字节。但 JVM 要求对象起始地址按 8 字节对齐,所以实际留给字段的空间不是「12 后面随便接」,而是要先凑齐到 8 的倍数。
拿一个最简单的例子算一下。一个只有 4 个引用字段的对象(比如一个本地缓存里的键值对节点):
class CacheEntry {
Object key; // 4
Object value; // 4
Object expireAt; // 4 (简化表示,实际可能是 long)
Object owner; // 4
}
原始布局:
mark (8) + klass (4) + 字段 (16) = 28 字节
28 向上对齐到 8 的倍数 = 32 字节
你写了 16 个字节的字段,实际占了 32 字节。有一半是小费和填充。
开了紧凑对象头之后(下一节说怎么开):
header (8) + 字段 (16) = 24 字节
24 已经是对齐的 = 24 字节
32 变成 24,省了 8 字节,降幅 25%。这个数字不是随口说的,后面有 JOL 的实测输出。
二、用 JOL 把真实占用打出来
JOL 是 OpenJDK 官方维护的一个小工具库,专门用来观察对象在内存里的真实布局。它不依赖任何 JVM 参数,纯 Java 层调用,本地起个 main 方法就能跑。
依赖加上:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
<scope>provided</scope>
</dependency>
写一个探针类,把我们服务里几种典型对象都量一遍:
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
public class HeaderProbe {
// 场景一:四个引用字段,本地缓存用的键值节点
static class CacheEntry {
Object key;
Object value;
Object expireAt;
Object owner;
}
// 场景二:订单项,混合了 long 和引用
static class OrderItem {
long id;
int skuId;
int quantity;
long priceCents;
int status;
int type;
String name;
String skuCode;
}
// 场景三:只有一个 int 的包装对象
static class TinyFlag {
int flag;
}
public static void main(String[] args) {
System.out.println("=== JVM 信息 ===");
System.out.println(VM.current().details());
print("CacheEntry", CacheEntry.class);
print("OrderItem", OrderItem.class);
print("TinyFlag", TinyFlag.class);
}
private static void print(String name, Class<?> clazz) {
String layout = ClassLayout.parseClass(clazz).toPrintable();
System.out.println("--- " + name + " ---");
System.out.println(layout);
// 从输出里提取实例大小,也可以直接用这句话拿
System.out.println("instance size = "
+ ClassLayout.parseClass(clazz).instanceSize() + " bytes");
System.out.println();
}
}
先把 JVM 信息打出来,确认压缩指针是开着的。输出里会有类似这样一行:
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8
关键是 Objects are 8 bytes aligned。这一句说明对象大小只能是 8 的倍数,所有算出来的字节数都要往上取整。
然后看 CacheEntry 的默认输出:
com.example.probe.HeaderProbe$CacheEntry object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) N/A
8 4 (object header: class) N/A
12 4 java.lang.Object CacheEntry.key N/A
16 4 java.lang.Object CacheEntry.value N/A
20 4 java.lang.Object CacheEntry.expireAt N/A
24 4 java.lang.Object CacheEntry.owner N/A
28 4 (object alignment gap) N/A
Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
那行 object alignment gap 是决定性的证据。它明明白白写着有 4 个字节纯粹是为了对齐而浪费掉的,什么也没存。
再看 OrderItem,字段排列顺序和源码不一样——JVM 会按类型重排字段,把 8 字节的放前面,然后 4 字节的,最后才是引用,目的是减少对齐空洞。这是 HotSpot 的一个优化,源码里的声明顺序并不决定内存里的顺序,所以不要试图靠调整字段顺序来「手工省内存」,收益很有限而且难以预测。
Instance size: 56 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
再算一遍:字段总和是 8+4+4+8+4+4+4+4 = 40,加上对象头 12 就是 52,对齐到 56。对齐浪费 4 字节。
三、JEP 519 改了什么
Compact Object Headers 这个特性的目标很直接:把对象头从 12 字节压到 8 字节。
具体做法是重新设计 mark word 的位分配。原来的 mark word 8 字节里,有一部分是给锁状态和分代年龄的;klass pointer 是另外独立的一个 4 字节字段。紧凑头把两者合并成一个 8 字节的字:其中一部分位继续承担原来 mark word 的职责,剩下的位拿来存一个压缩得很厉害的类指针。
类指针为什么能压这么小?因为 JVM 里加载的类总数是有限的,几十万个算是很多了。如果用一个偏移量去寻址(所有 InstanceKlass 在 JVM 启动时会被分配到一个连续或分段连续的地址区间里),那么 22 位左右就够表达绝大多数场景。原来的 32 位压缩指针实际上是往「全地址空间」看的,紧凑头只需要往「类元数据区」看,所以能压得更狠。
这个特性在 JDK 24 里以实验特性的身份出现(JEP 450),需要同时开两个开关。到了 JDK 25 变成正式产品特性(JEP 519),直接一个开关就行。
JDK 24 的开启方式:
java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders ...
JDK 25 的开启方式:
java -XX:+UseCompactObjectHeaders ...
想确认到底有没有生效,可以用这条命令查一下最终值:
java -XX:+UseCompactObjectHeaders -XX:+PrintFlagsFinal -version
| grep -i compactobjectheaders
期望看到的是 bool UseCompactObjectHeaders = true。如果显示 false,通常是和别的参数冲突了,最常见的是关掉了压缩指针,或者用了某些需要完整 mark word 的调试参数。
四、同一个类,前后对比
把刚才的探针用两种方式各跑一遍,结果放一起看差别非常直观。
先跑默认配置:
java -cp target/classes:jol-core.jar com.example.probe.HeaderProbe
再跑开启紧凑头的版本(JDK 25):
java -XX:+UseCompactObjectHeaders -cp target/classes:jol-core.jar
com.example.probe.HeaderProbe
三次测量放在一张表里:
- CacheEntry(4 个引用字段):默认 32 字节,紧凑头 24 字节,省 8 字节,降幅 25%
- OrderItem(4 个 long/int + 2 个引用):默认 56 字节,紧凑头 48 字节,省 8 字节,降幅约 14%
- TinyFlag(单个 int):默认 16 字节,紧凑头 16 字节,省 0 字节,降幅 0%
第三个结果最值得说。
TinyFlag 的默认布局是:mark 8 + klass 4 + int 4 = 16,正好对齐,没有空洞。紧凑头之后:header 8 + int 4 = 12,对齐到 16。还是 16。一个字节没省。
这不是巧合。省下来的字节数等于「对齐空洞的减少量」,如果原本就没有空洞,紧凑头就无从发挥。所以下面这几个结论要记住:
字段总和越接近 8 的倍数减去 12 这个数,收益越小。当 (12 + 字段大小) 恰好能被 8 整除时,默认布局零浪费,紧凑头也省不出来。
引用类型的对象收益最稳定。每个引用 4 字节,引用字段多的对象容易凑出 4 的余数,对齐空洞概率高。DTO、缓存节点、链表节点、树节点这类结构通常吃得最饱。
基本类型大数组基本没有收益。数组的对象头是 mark + klass + 长度字段(4 字节),new int[10] 是 8+4+4+40 = 56 字节,本来就没有空洞,紧凑头之后是 8+4+40 = 52,对齐到 56,一样。这点别抱期望。
测量自己的业务对象时,一定不要拿一两个类下结论。跑一遍线上堆 dump,用 MAT 的「Histogram」看一眼对象数量和类型分布,把排名前 20 的类批量量一遍,算一个加权平均值,才知道这套改动对你的服务大概能省多少。
五、线上灰度:堆用量和 GC 时间的变化
我们把服务改了一行 JVM 参数,灰度到一台和线上同构的机器上,跑了一天,收集了下面这些数据。
灰度前的基线:堆上限 12G,稳态堆用量在 9.8G 到 10.4G 之间波动,Full GC 一天 3 到 4 次,每次 STW 2.3 秒左右,Young GC 平均每天 240 次。
灰度后:稳态堆用量落在 8.2G 到 8.7G 之间,算下来降了大概 17%。Full GC 从每天三四次降到每天一次,STW 时间稳定在 2 秒上下。Young GC 频率略有下降,从 240 次降到 210 次左右。
为什么是 17% 而不是 CacheEntry 那个 25%?因为堆里不是只有 CacheEntry。除了业务 DTO,还有大量的字符串、char 数组、int 数组、HashMap 的 Node 数组,这些要么收益很低,要么完全没有。加权平均下来的数字在 15% 到 20% 之间,和社区里看到的经验值基本吻合。这是个很健康的数字,因为它说明你不太可能通过「调整对象布局」再榨出更多——收益是从重复的引用承载和对齐浪费里挤出来的,已经从源头上省下来了。
CPU 我没有观察到明显上升。这一点一度让我有点担心,因为紧凑头读取 class pointer 需要一次额外的移位和掩码运算,理论上比直接读 32 位会多几个指令。在实际业务里,这点开销被内存带宽节省和 GC 压力下降抵掉了一部分,看不出差异。如果是在微基准里测,可能会看到一两个百分点的差异,但那不是真实场景。
这个特性还有一个隐藏的好处:它拉近了 Java 对象和 C 结构体的内存密度差距。一些对内存敏感的场景,比如本地缓存、批量计算、图计算,过去靠 off-heap 或者专门的布局库才能做到的密度,现在用普通的 Java 对象加一个参数就接近了。对这类系统来说,收益不只是「少买几台机器」,更可能是「原本装不下的数据现在装得下了」,那是质变。
六、绕不开的几个坑
1. identityHashCode 的调用开销变了
这是我认为最需要留意的一条,因为它在业务代码里出现得非常隐蔽。
默认布局下,mark word 里有足够的空闲位存哈希值。第一次调用 System.identityHashCode() 的时候,JVM 算一下,填进去,第二次直接读,代价很小。
紧凑头之后,mark word 里的位被压得很紧,没有这么多富余。JVM 换了一套做法,本质上需要一个间接的存储来承载哈希值,可能涉及一次额外的分配或查找。这意味着:第一次调用 identityHashCode 的开销变大了,后续读取也可能不再是纯内存读。
哪些地方会用到它:
Object.hashCode()在子类没有重写的时候,默认就是 identityHashCode- 任何把对象塞进
HashMap或HashSet时如果没重写 hashCode,走的就是这条路 - 一些框架在做对象标识、缓存 key、并发控制时用它
如果你的服务里有大量未重写 hashCode 的对象被丢进哈希容器,改完之后值得专门压一下这条路径。做法很简单,JFR 里看一下 ObjectAllocationSample 和 CPU profile,或者干脆在压测里对比一下。
反过来说,如果业务对象的 hashCode 都规规矩矩重写了,这条基本不用担心。这也是为什么我一贯主张 DTO 该重写 equals 和 hashCode 就重写——不只是语义正确,还顺手绕开了这个特性带来的不确定性。
2. 依赖 Unsafe 或对象头布局的工具要回归
有些库会直接读对象头,典型的是做序列化、对象池、内存布局分析的第三方组件。它们用 Unsafe.getInt(obj, 8) 这种方式去读 klass pointer,在紧凑头下读出来的东西完全不对。
更常见的是 APM 探针。很多商业 APM 的 Java agent 会做字节码增强,其中一部分会检查对象的类信息。老版本的 agent 在紧凑头下可能增强失败,表现为方法数据采集不到,或者干脆抛异常。我们这次灰度之前,专门把所有 agent 都在测试环境跑了一遍:APM、分布式追踪、数据库连接池的监控插件、日志框架的异步 appender。升级到各自的最新版本之后没有出现问题。
如果没法升级某个组件,那就只能不开这个特性,或者把它单独拎出来做隔离。这个取舍非常具体,要看你们的依赖清单。
3. 堆 dump 和 JFR 的格式有版本要求
JDK 25 自带的工具链能正确处理开启紧凑头的堆 dump。但是外面那些分析工具,比如老版本的 MAT、JProfiler、YourKit,可能不认。我们用的是 MAT 1.15 以上版本,读取正常,直方图和引用链都准确。
还有一点要注意:如果 dump 的机器和解析的机器 JDK 版本不同,一定要让解析方也升级到能识别的版本。这个坑在排查线上问题时特别致命,你打开 dump 发现所有类都识别错了,会先怀疑是这个参数引起的,白白浪费几个小时。
4. 参数依赖压缩指针
-XX:+UseCompactObjectHeaders 和 -XX:-UseCompressedOops 是冲突的。原因很清楚:紧凑头本身就是一种压缩指针的变体,把压缩指针关了,整个前提就不成立了。启动的时候如果两个参数都传了,JVM 会给出警告并且关闭紧凑头。
另外,32 位 JVM 上这个特性也没有意义,别折腾。
5. 不能运行时切换
这个参数是启动期决定的,不能像某些 GC 参数那样用 jinfo 动态改。所以灰度策略只能是「按机器灰度」——一部分机器开,一部分不开,用流量调度来对比。别想着在一台机器上开了关关了开,做不到。
七、我建议的灰度步骤
这套流程是我们实际走下来的,比较稳,可以直接抄。
第一步,把线上堆 dump 拉一份下来,用 MAT 打开直方图,把 Top 30 的类列出来,估算一下加权收益。如果算下来不到 5%,那就别折腾了,收益抵不上风险。如果超过 10%,继续。
第二步,本地用一个微基准把主要的业务对象量一遍,跑一遍 JOL,确认对象布局变化符合预期。这一步的意义不是精确算收益,而是清掉那些「以为会省其实不会省」的错误预期。
第三步,在测试环境用生产同构的启动参数、同等的负载跑一天,观察 GC 日志、堆曲线、CPU。同时把 JFR 采样打开,重点看 identityHashCode 相关的路径有没有变慢。
第四步,回到生产,挑一到两台低峰期的机器开参数,跑满 24 小时。对比关键指标:稳态堆用量、Full GC 次数和停顿、Young GC 频率、P99 延迟。同时观察有没有业务报错,尤其是依赖 agent 的那些监控是不是还正常。
第五步,全量推开。但保留回滚通道:因为这是启动参数,回滚就是重启一次,5 分钟的事。如果发现异常,把参数去掉重启即可,整个链路是可逆的。
整个过程里,最容易被跳过、但最不该跳过的是第一步。因为这个特性的收益不是均匀分布的,如果你的服务堆里主要是大数组和字符串缓冲区,可能压根看不到变化,那还不如把精力花在减少对象创建、优化缓存策略上。
八、清单
- JDK 24 需要
-XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders,JDK 25 及以后只需要后者 - 开启前用
-XX:+PrintFlagsFinal | grep CompactObjectHeaders确认最终值生效 - 用 JOL 量一遍主要业务对象,不要拿一两个类推断整体收益
- 引用字段多的对象收益最好,单字段对象和基本类型数组基本没收益
- 字段总和加上 12 恰好是 8 的倍数时,默认布局零对齐浪费,也就没有收益
- 关注未重写 hashCode 的对象,它们会走 identityHashCode 的新路径
- 读对象头的第三方库、老版本 APM agent 要提前回归,测不过就升级或放弃
- 堆 dump 的解析工具要升级到认识新布局的版本,否则排查问题时会误导自己
- 和
-XX:-UseCompressedOops冲突,不要同时开 - 参数启动期固定,只能按机器做灰度,不能运行时切换
- 回滚就是去掉参数重启,所以一定要保留一份不带这个参数的启动配置
回头看这次优化,最大的收获不是省了 17% 的内存,而是把「对象到底占多少字节」这件事从玄学变成了可测量的事实。以前讨论内存优化,大家凭感觉说得头头是道,谁也说服不了谁。现在有了 JOL 这个探针,任何优化想法都可以先用几十行代码量出来,再决定要不要投入工程成本。这个习惯本身,比一个 JVM 参数有价值得多。

