上周四晚上,我把测试环境里一个跑了半年的 Spring Boot 服务从 JDK 25 升到了 JDK 27。没动一行业务代码,没碰 JVM 参数,甚至没重启 Prometheus。
服务重启后我习惯性地切到 Grafana 面板,准备看一波 OOM 告警。结果等了十分钟,不仅没告警,堆内存水位线直接往下掉了一大截。QPS 没变,P99 响应时间没变,但 GC 频率从每分钟 3 次硬生生压到了不到 1 次。
这不是什么玄学调优,也不是我运气好碰上了低峰期。这是 JDK 27 里 JEP 534(紧凑对象头)在背后默默发力。
查了下官方 SPECjbb2015 的跑分数据:堆内存减少 22%、CPU 时间减少 8%、GC 次数减少 15%。Amazon 在生产环境几百个服务上验证过,SAP 也直接在自己的 SapMachine 里默认开启了。
这大概是 JDK 27 九个 JEP 里唯一一个"默认开启、零成本、立即受益"的特性。下面这篇笔记,是我这次升级前后摸到的所有细节,包括那些差点让我翻车的地方。
先算笔账:你的对象头到底吃了多少内存?
很多开发者知道"Java 对象头",但很少去抠它到底占了多少空间。
在 64 位 HotSpot 上,不管你 new 出来的对象有没有字段,都有一个对象头。传统布局是这样的:
- Mark Word:8 字节,存锁状态、GC 分代年龄、identity hashcode 等运行时元数据。
- Klass Pointer:4 字节,指向类元数据的指针(前提是开了指针压缩,堆小于 32GB 时默认开启)。
加起来 12 字节,但 HotSpot 要求对象大小按 8 字节对齐,所以实际占 16 字节。
你 new 一个 Object(),什么都没存,光对象头就吃掉 16 字节。如果你有一个包含两个 int 字段的小对象,字段本身占 8 字节,加上对象头 16 字节,总共 24 字节——其中三分之二是对象头。
我之前用 JOL(Java Object Layout)跑过一个典型 Spring Boot 应用的堆转储,存活对象大概 300 多万个,平均对象大小在 48 字节左右。算下来对象头占总堆的比例在 25% 到 30% 之间。将近三分之一的堆内存,花在了对象的管理信息上,而不是你的业务数据上。
JDK 27 怎么做的:把 12 字节压到 8 字节
JEP 534 的核心操作很直接:把 Klass Pointer 塞进 Mark Word 里,整个对象头压缩到一个 64 位字。
传统 64 位 Mark Word 的位分布大致是:低 2 位存锁状态,然后几位存 GC 年龄,31 位存 identity hashcode,剩下大量高位是空闲的。Lilliput 项目(这个特性的代号)的思路就是:既然高位闲着,为什么不用来存类指针?最终把 Klass Pointer 压缩到 22 位,嵌入 Mark Word 的空闲位中。
前提有两个:指针压缩必须开启(默认开启),Metaspace 的基地址按 4MB 对齐(JDK 内部已经处理好了)。
对象头从 12 字节变成 8 字节,每个对象省 4 字节。单看 4 字节好像不多,但你知道一个中等规模的 Java 服务堆里有多少个对象吗?
实测数据:锯齿状的内存节省
先说官方数据。SPECjbb2015 基准测试:堆内存减少 22%,CPU 时间减少 8%,GC 次数减少 15%。JDK 团队发行说明里给的保守估计是"堆大小减少约 20%"。
但这里有个很多人会忽略的细节:不是每个对象都能省下这 4 字节。
因为对象大小必须按 8 字节对齐。我参考了一组第三方基准测试的数据,测了五种微型类(0 到 4 个 int 字段),每种 1000 万个实例,用 MemoryMXBean 测量:
| 字段数 | 标准对象头 | 紧凑对象头 | 节省 |
|---|---|---|---|
| 0个int | 16.00 B | 8.00 B | -50% |
| 1个int | 16.01 B | 16.00 B | 0% |
| 2个int | 24.00 B | 16.00 B | -33% |
| 3个int | 24.00 B | 24.00 B | 0% |
| 4个int | 32.00 B | 24.00 B | -25% |
这是一个锯齿状的分布。0 个字段的对象省了 50%,1 个 int 字段的对象一点没省,2 个 int 的省了 33%,3 个 int 的又没省。
原因很简单:标准对象头 12 字节,紧凑对象头 8 字节,省了 4 字节。但对象总大小要对齐到 8 字节的倍数。如果标准布局下对象总大小正好是 16 字节(比如对象头 12 字节 + 4 字节填充 = 16 字节),紧凑布局下变成 8 字节 + 0 字节填充 = 8 字节,省了整整 8 字节。而当标准布局下对象已经是 16 字节的时候(12 字节头 + 4 字节字段正好 16),紧凑布局变成 8 字节头 + 4 字节字段 + 4 字节填充 = 16 字节,一分没省。
所以"堆内存减少 20%"这个数字,取决于你的应用里对象的大小分布。如果你的应用里大量是小对象(比如 DTO、配置对象、缓存条目),收益会接近官方数据。
我自己的服务跑下来,堆内存从 2.1GB 降到了 1.7GB 左右,大概省了 19%。这个数字和官方数据基本吻合,因为我那个服务里大量的是 Spring 框架内部的 Bean、事件对象、HTTP 请求响应对象,都属于中小型对象。
隐性收益:数据局部性才是真香
堆内存降了是好事,但说实话,在云环境下,如果你的服务不是那种内存极度吃紧、每天都在 OOM 边缘试探的,20% 的堆节省可能不会让你立刻感受到什么。你不可能因为省了 20% 堆就把容器内存配额从 4GB 调到 3.2GB,然后告诉老板省了钱。
真正让我觉得值得写这篇文章的,是**数据局部性(Data Locality)**的改善。
对象头缩小之后,堆上对象排布更紧凑了,单位内存页里能放更多对象。这在遍历大量对象的时候会直接影响 CPU 缓存命中率。简单说,CPU 从内存读数据是按缓存行(通常 64 字节)来的。如果对象更小,同样的缓存行里能装下更多对象,遍历的时候就不需要频繁地从主存重新加载数据。
SPECjbb2015 里 8% 的 CPU 时间减少,很大程度上就来自于这个效应。对象头小了,同样的工作集占用的内存页更少,TLB(地址转换缓存)miss 更少,缓存命中率更高,CPU 花在等内存上的时间就少了。
另一个间接收益是 GC 压力降低。对象总大小变小,YGC 触发频率降低,单次 YGC 需要扫描和复制的对象数量也减少。对延迟敏感的服务来说,YGC 少了,STW 暂停的次数就少了,这比省内存更有意义。
踩坑记录:差点被 RSS 骗了
我在 OpenJDK 的邮件列表里看到一个值得注意的案例,自己升级时也差点踩进去:有人在测一个简单的 Spring Boot 应用启动后的 RSS(常驻内存集)时发现,从 JDK 25 升级到 JDK 27 EA 版本后,RSS 反而是上升的——不开 AOT 时从 149MB 升到 189MB 左右。
这个现象目前看起来是 JDK 27 其他变更(比如 G1 成为全环境默认 GC)带来的,和紧凑对象头本身关系不大。但它提醒我们一件事:堆内存的节省和进程整体 RSS 的变化不是一回事。
紧凑对象头省的是堆上对象占用的空间,但 JVM 进程的 RSS 还受 Code Cache、Metaspace、线程栈、GC 内部数据结构等影响。G1 在小内存容器里的内存开销本身就比 Serial GC 高一些。
所以我的建议是:关注堆使用量和 GC 行为的变化,不要只看 RSS。
另外,JOL 的输出也会变。JOL 本身无法在运行时检测 JVM 是否使用了紧凑对象头,它只能根据类字段和架构来推算布局。用 JOL 的时候要确保你用的版本和 JDK 27 匹配,否则算出来的对象大小可能不准确。
什么人/场景不适合升级?
紧凑对象头不是银弹。以下几种情况你可能感受不到明显收益:
- 对象普遍偏大:如果你的领域对象动辄几百字节甚至上千字节,对象头占比本来就低,省 4 字节杯水车薪。
- 对象数量不多:堆里只有几万、几十万个对象,省下来的绝对值不大。
- 已经用了 ZGC/Shenandoah:这些 GC 本身对内存布局的优化已经做得很好,紧凑对象头的边际收益会递减。
- GraalVM Native Image 用户:Native Image 有自己的内存布局优化,紧凑对象头的影响路径不同。
如果你维护的是一个典型的 Spring Boot 微服务,堆里跑着几百万个小对象,升级 JDK 27 之后你大概率会看到实打实的变化。
升级操作指南
怎么确认它生效了
JDK 27 里紧凑对象头是默认开启的,不用加任何参数。想确认的话:
java -XX:+PrintFlagsFinal -version 2>&1 | grep UseCompactObjectHeaders
应该输出 UseCompactObjectHeaders = true {default}。
升级 Checklist
- 备份当前 JVM 参数和监控基线
- 在测试环境升级 JDK 27,不加任何额外参数
- 观察堆内存、GC 频率、P99 响应时间至少 24 小时
- 对比 RSS 和堆使用量,不要只看 RSS
- 如果用 JOL,升级到匹配 JDK 27 的版本
- 如果之前手动加过
-XX:+UseCompactObjectHeaders,可以直接删掉
要不要回退
JDK 27 支持通过 -XX:-UseCompactObjectHeaders 关掉这个特性。但这个参数已经被标记为弃用,后续版本会移除。如果你发现升级后有问题需要回退,这只是一个临时措施,长期来看你终究要适配紧凑对象头。
目前已知的一个坑是,在 JDK 27 正式 GA 之前的测试中,有一例 C2 编译器在使用紧凑对象头时触发了断言失败,不过这个 bug 在 GA 版本前已经修复了。
你不需要做的事
不需要改业务代码。不需要调整 JVM 参数。不需要重新编译。留着 -XX:+UseCompactObjectHeaders 也不影响,但没必要。
写在最后
我从事 Java 开发十年,经历过从 JDK 6 到 JDK 27 的迭代。绝大多数 JDK 升级带来的性能提升都是"有条件"的——你得改代码用新 API,你得调参数适配新的 GC 行为,你得重新做压测。但紧凑对象头不一样,它是 JVM 层面的内存布局变更,对应用代码完全透明。
这种"零成本、默认开启"的优化,在 JDK 历史上不多见。上一次让我有类似感觉的可能是 JDK 9 把 G1 设为默认 GC,但那次至少还涉及到 GC 参数的重新适配。紧凑对象头连适配都不需要。
我在测试环境验证完之后,已经计划在下一个发版窗口把生产环境也升上去。如果你也在维护 Java 服务,建议至少先在测试环境跑一轮看看数据。不需要改代码的优化,值得一试。
声明:本文实测数据来自个人测试环境,具体收益因应用负载和对象分布而异。建议在你的实际环境中做 A/B 对比后再决定是否调整生产环境的内存配额。文中提到的 RSS 变化现象可能随 JDK 27 后续更新而改变,请以实际测试为准。