JDK 25 紧凑对象头实战:12GB 堆降到 9GB,只加了一个 JVM 参数

2026-09-30 0 681

先说结论:一个 32GB 的堆,GC 停顿时间没变,吞吐没变,代码一行没改,只是加了一个 JVM 参数,堆内存占用从 12GB 掉到了 9GB 出头。机器没换,加内存的计划倒是砍掉了。

参数是 -XX:+UseCompactObjectHeaders,特性是 JDK 25 里的紧凑对象头(Compact Object Headers,JEP 519)。

这篇把整个过程记下来,包括为什么一开始没人想到它、怎么验证收益、以及做这件事要注意什么。

起因:加内存成了唯一的选项

我们有一个订单快照服务,每个订单在状态发生变化的时候会把当前的完整快照写一份留档。快照对象比较大,一个订单三十几个字段,嵌套了地址、商品列表、支付信息这些子对象。一个订单平均下来三四十个对象,按日均两百万订单算,一天的快照数据就是几千万个对象。

这套服务用的是 G1,堆给了 24GB,平时稳定占用在 11GB 到 13GB 之间。QPS 稍微上一个台阶,堆占用动辄冲到 18GB 到 20GB,然后 Young GC 的频率明显上升,老年代也跟着涨。运维同事检查了半个月,从泄漏检测到对象引用的合理性都排了一遍,没有明显问题。最后的结论是”业务量涨了,内存得加”。所以就有了”给堆扩到 32GB”这个方案。

扩堆之前我提了一句:既然都准备改 JVM 参数了,不如先试试 JDK 25 那个紧凑对象头,反正也不麻烦。运维说”试试就试试”,然后就试出了上面那个结果。

对象头到底占了什么

要理解这个参数为什么有用,得先看一眼 Java 对象在内存里的样子。

一个 java 对象在堆里的布局分三段:

  • 对象头(Object Header):Mark Word + Klass Pointer
  • 实例数据(Instance Data):对象的所有字段
  • 对齐填充(Padding):把总长度补齐到 8 字节的整数倍

传统布局下,Mark Word 占 8 字节(在 64 位 JVM 上是这样),Klass Pointer 在开启压缩指针的情况下占 4 字节,加起来 12 字节。但如果对象没有足够多的字段,这个 12 字节加上实例数据后还要补到 8 的倍数,就经常出现”对象实际数据只有 4 字节,却因为对齐占用了 16 字节”的现象。

紧凑对象头做的事情是:把 Mark Word 和 Klass Pointer 合并到 8 字节里。Mark Word 保留 4 字节左右,Klass Pointer 压到 4 字节以内,正好填满第一个 8 字节。这样对象头就从 12 字节变成了 8 字节。

省的钱不多,但乘以几千万个对象就不一样了。

怎么开启,怎么验证

开启非常简单,加一个参数就行:

java -XX:+UseCompactObjectHeaders -jar order-snapshot.jar

JDK 25 里这个特性是正式特性,不需要加 --enable-preview。JDK 24 上也可以试,但要加 preview 标志。

验证方式我想了一个最直白的方法:写一段代码,创建若干对象,计算它们在堆里的实际占用。不过手工算容易出错,更靠谱的是用 jol-core 这个工具包,它能直接打印对象的内存布局。

import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;

public class LayoutDemo {
    public static void main(String[] args) {
        System.out.println(VM.current().details());
        System.out.println(ClassLayout.parseClass(OrderSnapshot.class).toPrintable());
    }

    static class OrderSnapshot {
        private long orderId;
        private int userId;
        private int status;
        private long createdAt;
    }
}

不开紧凑对象头的时候,OrderSnapshot 的输出大概是这样的(我只贴关键部分):

OrderSnapshot object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)      N/A
  8   4        (object header: class)     N/A
 12   4    int OrderSnapshot.status        0
 16   8    long OrderSnapshot.orderId       0
 24   8    long OrderSnapshot.createdAt     0
 32   4    int OrderSnapshot.userId        0
 36   4        (object alignment gap)
Instance size: 40 bytes

对象头占了 12 字节,加上字段和内存对齐,一个 OrderSnapshot 是 40 字节。

加上 -XX:+UseCompactObjectHeaders 之后:

OrderSnapshot object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)      N/A
  8   4    int OrderSnapshot.status        0
 12   4    int OrderSnapshot.userId        0
 16   8    long OrderSnapshot.orderId       0
 24   8    long OrderSnapshot.createdAt     0
Instance size: 32 bytes

对象头变成 8 字节,对齐填充也没了,一个对象 32 字节。单看这一个类,从 40 降到 32,少了 20%。乘以几千万,就是那个 12GB 掉到 9GB 的来源。

在真实服务上做了什么

有了本地验证之后,我们做了下面的步骤。

第一步,先在预发环境打开。 不加别的参数,只在 JDK 25 基础上加 -XX:+UseCompactObjectHeaders,跑一周观察。预发环境的信息比生产要淡,但足够发现”有没有大问题”。

第二步,观察 GC 日志。 主要看三个指标:Young GC 频率、Full GC 频率、平均停顿时间。我们用的是 JDK 25 自带的统一日志(-Xlog:gc*:file=gc.log),直接抓就行。一周下来,Young GC 频率从每分钟 4 到 5 次降到 3 次左右,其余指标基本持平。这是好事——堆占用降了,年轻代回收能装下的对象变多,自然不用回收那么频繁。

第三步,抓一次堆信息对比。 用 jcmd <pid> GC.heap_info,也可以在服务启动后立刻抓一次 heap dump 用 MAT 分析。MAT 的支配树和直方图都能看出来”每种对象占多少内存”,对比开关前后的差异,一目了然。

第四步,上生产。 灰度两个实例,跟老参数跑了一周,各项指标都平稳之后再全量。这里顺手提一句:灰度的时候别只看内存,延迟、CPU 也要看。我们观察到微小的 CPU 下降(1% 到 2%),估计是因为对象越小,GC 扫描时遍历的对象也越少。

有收益的前提:你用得着这个特性

不是所有 Java 应用都能从紧凑对象头里拿到同样比例的收益。我总结了一下适用场景,帮大家判断值不值得试。

收益大的场景:

  • 堆里对象数量极多,单个对象很小(比如 POJO、事件、DTO、集合里的元素)
  • 这些对象生命周期比较长,会进入老年代
  • 应用本身对堆内存敏感,经常需要扩堆
  • 用了大量 HashMap、ArrayList、String 等小对象聚合的场景

我们的订单快照服务就完全符合这几点。

收益小的场景:

  • 堆里主要是大对象,比如大数组、大字符串、缓存了图片或者二进制数据的 byte 数组
  • 对象数量少,堆里几百兆就能装下
  • 应用是 CPU 密集型,堆不是瓶颈

举一个极端的例子:如果你的堆里就是几个大的 byte[] 和一个大 HashMap,那这个参数基本不会有什么变化。

一个容易忽略的联动:压缩指针

紧凑对象头的工作前提是开启压缩指针(UseCompressedOops),这在 JDK 8u 之后是默认开启的,堆小于 32GB 时自动生效。如果你把堆开到 32GB 以上,压缩指针会被自动关掉,那 Klass Pointer 就变回 8 字节,紧凑对象头该做的事就做不成了。

这条很重要。如果你现在堆是 32GB,为了用紧凑对象头把堆调到 31GB,往往反而能获得更好的总内存效率——因为压缩指针 + 紧凑对象头的组合比”32GB 堆 + 未压缩指针”要省得多。

我们那个服务本来就是 24GB 的堆,没碰到这个问题。但如果是 40GB 或者 64GB,就得重新盘算一下。

踩过的坑

整个过程还算顺利,但也有几个值得记一下的地方。

坑一:一些 profiling 工具会读错对象布局

打开这个参数之后,一些比较老的 JVM agent 或者内部写死的对象大小计算工具会出问题。它们的算法里默认对象头是 12 字节,读到实际 8 字节的时候就会算错。轻则数字不对,重则直接的 Unsafe 越界。

我们项目里有一个自研的缓存框架,用来估对象大小做淘汰决策,启动的时候就报了 IllegalStateException。排查后确认就是它内部写死了 12 字节的对象头。改法很简单,把它对接 JDK 25 提供的新 API,或者干脆换成不再依赖对象头大小的估算方式。

坑二:本地压测和生产不一致

我们本地开发用的是 M1 的 Mac,装的是 Azul 的 JDK 25。本地验证一切正常,跑到预发环境(x86 Linux)的时候发现启动报错,提示不支持这个参数。查了一下,是预发环境打包镜像的时候用了另一个镜像源,装的是一个早期构建版本,那个版本的紧凑对象头还没合并进去。

虽然看着像是一个环境问题,但它暴露了一件事:新特性和环境版本强绑定,一定要在生产所用的 JDK 版本上做验证。本地环境再新,也可能和生产略有差异。

坑三:某些序列化库会依赖对象头布局

我们项目用了 Kryo 做本地缓存序列化,Kryo 里有一些底层优化用到了 sun.misc.Unsafe 和直接内存偏移。开紧凑对象头之后,缓存的读取速度反而慢了一点。

原因是 Kryo 的 FieldSerializer 是基于对象头之后第一个字段的偏移量来计算字段位置的。对象头变短,字段偏移整体前移,虽然理论上应该更快,但实测下来因为对齐方式变了,某些类的缓存行 miss 率变高,反倒慢了一点。

这个影响很小,大概 1% 到 2%,我们最后没有处理。但如果你对序列化性能极度敏感,测试的时候要把它单独跑一遍。

从 12GB 到 9GB 是怎么算出来的

有人可能好奇”省了 3GB”这个数字是怎么得出的。我记录一下我们实际测的方式。

方法一,看 GC 日志里的堆占用峰值。在同样 QPS 下,把 gc.log 里 “Pause Young (Normal)” 前后两次的 used 数值画成曲线,对比开关前后的平均峰值。这个方法比较粗,但最贴近实际运行状态。

方法二,用 jcmd <pid> GC.heap_info,它会给一个 used 的实时快照。我们每隔十分钟打一次日志,运行一天之后求均值。开紧凑对象头之前的均值是 11.8GB,之后是 9.1GB。差值 2.7GB,占比 23%,和单个对象减小 20% 基本对齐。

方法三,抓一次 heap dump 用 MAT 分析,看”同类对象的实例总数 × 每个对象的大小”。这个方法最准确,但是分析一次要花不少时间,我只做了抽样验证。

三种方法互相印证之后,我们才敢相信这个参数确实在起作用,而不是一次巧合的监控波动。

要不要升到 JDK 25

最后说一个现实的判断。这个参数本身很简单,但你要用它,前提是把 JDK 升到 25。升 JDK 的成本远大于加这个参数。

我们的项目是从 JDK 21 升上来的。升级过程中影响面最大的是几个第三方库的兼容性,特别是那些依赖 Unsafe 做底层操作的(Netty、Kryo、部分缓存库)。SQL 层和业务代码基本没动,编译层面也几乎没有需要改的地方。

如果你们正在准备升 JDK 25,那么紧凑对象头可以作为一个”顺带拿到”的收益,不用单独为它做任何改造。如果你们现在 JDK 版本还比较老,升不升主要看其他特性(虚拟线程、结构化并发、ScopedValue 等)对你们的价值,这个特性本身不足以单独驱动一次升级。

写在最后

我挺喜欢这种特性的。它不改变程序员写代码的方式,甚至不需要理解它的细节,只要知道它对小对象多、堆吃紧的应用有好处,加个参数就能拿到收益。

相比那些需要大量重构才能用上的新特性,这种”无侵入式”的改进反而更容易推广。你不需要说服团队改代码,不需要做架构评审,改一行启动脚本就完事了。

当然,前提是你对它有正确的预期。它不是”内存优化银弹”,只是省了对象头那几字节。如果你的堆里主要都是大对象的浪费、或者是缓存的命中策略不合理、又或者是真正意义上的对象泄漏,那这个参数帮不了你。

但在”小对象海量聚集”这个典型场景里,它是真的有用。我们省下的那 3GB,本来是要花一笔钱买新机器的。

JDK 25 紧凑对象头实战:12GB 堆降到 9GB,只加了一个 JVM 参数
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java JDK 25 紧凑对象头实战:12GB 堆降到 9GB,只加了一个 JVM 参数 https://www.taomawang.com/server/java/2841.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务