Administrator
发布于 2026-06-02 / 1536 阅读
23

JDK 25 紧凑对象头与内存占用优化

从 JDK 21 切到 25,同样的服务堆占用降了 19%

我们一个文档问答服务在 JDK 21 上跑了快两年,堆峰值 4.8 GB,一直想降但找不到好办法——该做的代码优化都做了,GC 参数也调过了。

四月份我们把它切到了 JDK 25,只加了一个参数,堆峰值降到 3.9 GB。这个改动是 JEP 519 带来的:紧凑对象头。这篇是完整的实测数据和我们踩到的兼容性问题。

JEP 519 到底改了什么

先说清楚背景。HotSpot 里每个对象的对象头分两部分:

  • mark word:64 位,存哈希码、GC 年龄、锁状态、偏向锁信息等;
  • klass pointer:指向类元数据的指针,开启压缩指针(CompressedOops)后是 32 位。

所以开启压缩指针时对象头是 96 位(12 字节),不开启是 128 位(16 字节)。

JEP 519 的做法是:把 klass pointer 从 32 位压到 22 位,塞进 mark word 里没用满的空间。这样整个对象头就是 64 位(8 字节),省了 4 字节。

传统布局(开启 CompressedOops,96 bits)
┌──────────────────────────────────────────┬──────────────┐
│  mark word (64 bits)                     │ klass (32b)  │
│  哈希码 / GC 年龄 / 锁状态                 │ 类元数据指针  │
└──────────────────────────────────────────┴──────────────┘

紧凑布局(64 bits)
┌──────────────────────────────────────────────────────────┐
│  mark word + 压缩后的 klass (22 bits)                     │
│  22 bits 最多表示 4,194,304 个类                          │
└──────────────────────────────────────────────────────────┘

注意那个「22 位」的约束:它意味着一个类最多能表示 419 万个类。这个数字对绝大多数应用够了,但后面会说到坑。

还有个前提:紧凑对象头要求压缩类指针(CompressedClassPointers)开启,也就是堆不能超过 32 GB(实际上压缩指针的寻址上限)。超过 32 GB 堆的应用用不上这个特性。

怎么开

JDK 25 里这个特性已经从实验特性转正,默认是开启的。但因为它改变了对象布局,官方还是保留了显式开关:

# 显式开启(默认就是开的,写出来是为了明确意图)
java -XX:+UseCompactObjectHeaders -jar myapp.jar

# 关闭(出问题时的回退手段)
java -XX:-UseCompactObjectHeaders -jar myapp.jar

验证是否生效:

$ java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -i compact
   bool UseCompactObjectHeaders    = true    {product} {default}

# 或者运行时看
$ jcmd <pid> VM.flags | grep -i compact
-XX:+UseCompactObjectHeaders

用 JOL(Java Object Layout)直接看对象大小差异,这个最直观:

public class ObjectSizeDemo {
    record Point(int x, int y) {}
    static class Plain { int a; int b; }

    public static void main(String[] args) {
        System.out.println(ClassLayout.parseClass(Plain.class).toPrintable());
        System.out.println(ClassLayout.parseInstance(new Point(1, 2)).toPrintable());
    }
}

JDK 21 输出:

Plain object internals:
OFF  SZ   TYPE DESCRIPTION
  0   8        (object header: mark)
  8   4        (object header: class)      ← klass 单独占 4 字节
 12   4    int Plain.a
 16   4    int Plain.b
 20   4        (object alignment gap)
Instance size: 24 bytes

JDK 25 输出:

Plain object internals:
OFF  SZ   TYPE DESCRIPTION
  0   8        (object header: mark and class)   ← 合并了,总共 8 字节
  8   4    int Plain.a
 12   4    int Plain.b
Instance size: 16 bytes

24 字节降到 16 字节,省了 33%。 但这是极端小对象的情况,实际收益要看对象大小的分布。

实测:我们的服务

测了三个有代表性的服务。条件:同样机器(16C32G)、同样流量回放、跑 30 分钟取稳态。

服务对象特征JDK 21 堆峰值JDK 25 堆峰值降幅
文档问答(RAG)大量小对象:短 String、HashMap、float[]4.82 GB3.91 GB-18.9%
订单聚合中等对象:DTO、List3.14 GB2.78 GB-11.5%
批处理(对账)大对象:批量数组、长字符串6.40 GB6.12 GB-4.4%

完全符合预期:对象越小、数量越多,收益越大。批处理服务因为主要内存是大数组,对象头占比本来就低,所以只有 4.4%。

验证一下这个解释。我用 JFR 的 jdk.ObjectCount 事件统计了文档问答服务的对象大小分布:

$ jcmd <pid> JFR.start duration=60s settings=profile filename=/tmp/obj.jfr
$ jfr print --events ObjectCount /tmp/obj.jfr | head -20

对象大小分布(按实例数)
  16 bytes   :  38.2%   ← 收益最大(24→16,省 33%)
  24 bytes   :  22.7%   ← 32→24,省 25%
  32 bytes   :  14.1%
  48 bytes   :   9.8%
  64 bytes   :   6.3%
 >128 bytes  :   8.9%

60.9% 的对象在 24 字节及以下,所以 18.9% 的整体降幅是合理的。

连带收益:GC 也变好了

堆占用降了,GC 自然跟着受益。文档问答服务(G1,8G 堆):

指标JDK 21JDK 25变化
堆峰值4.82 GB3.91 GB-18.9%
Young GC 频率1.24 次/秒0.91 次/秒-27%
Young GC 单次31ms27ms-13%
GC 停顿占比3.8%2.5%-34%
老年代增长34 MB/min28 MB/min-18%
请求 P99268ms231ms-14%

GC 停顿占比从 3.8% 降到 2.5%,这个提升比堆占用降幅(18.9%)还大。原因是双重的:一是对象少了,二是对象变小了,同样大小的 Young 区能装下更多对象,GC 周期自然拉长。

P99 从 268ms 降到 231ms,主要是 GC 频率下降带来的尾延迟改善。

CPU 基本没变化(61.2% → 60.8%)。这说明紧凑对象头本身几乎没有运行时开销——它只是在对象布局上做了手脚,读写路径没有额外成本。

兼容性问题(这部分要认真看)

我们踩到的和排查出来的问题,按严重程度排。

一、依赖 unsafe 或对象布局假设的代码会挂

这是最危险的一类。任何硬编码了对象头偏移量的代码都会出问题。

我们遇到的是 Jackson 的一个老版本(2.13.x)里的一处优化代码,它用 Unsafe 直接算字段偏移量:

// 问题代码(某三方库内部)
long offset = UNSAFE.objectFieldOffset(field);
// 2.x 时代的假设:对象头 12 字节,这里做了硬编码偏移计算
long adjusted = offset - 12;

升级后对象头是 8 字节,减 12 就错了。表现是偶发的字段读错值,非常难排查——我们花了两天才定位到。

排查建议:升级后如果看到「偶发的、无法解释的数据错误」,优先怀疑这类代码。可以用这个命令扫一遍依赖里谁在用 Unsafe:

$ jdeps --multi-release 25 --print-module-deps -R libs/ | grep -i unsafe

# 或者运行时看哪些类在调
$ jcmd <pid> JFR.start duration=120s \
       settings=profile \
       jdk.UnsafeMemoryAccess#enabled=true \
       filename=/tmp/unsafe.jfr

我们的处理是升级 Jackson 到 2.19(它早就修了这个问题),并且把「依赖 Unsafe 的库」列了个清单重点回归。

二、JOL 分析的结论要重新测

如果你之前用 JOL 做过对象大小分析(我们做过,用来估算缓存容量),那些数字全部作废。

// 我们 2024 年做容量估算时的记录,现在全部要重算
// HashMap<String, Integer> with 1000 entries
//   JDK 21: 68,432 bytes
//   JDK 25: 56,208 bytes   (-17.9%)

好消息是只会变小不会变大,所以基于旧数据做的容量估算偏保守,是安全的。但如果你做过精细的内存预算,值得重算一遍来释放空间。

三、类数量上限

前面提到的 22 位限制:最多 419 万个类。正常应用远远够用,但有两种情况要小心:

  1. 大量动态生成类的应用。比如重度使用 CGLib、ByteBuddy、Groovy、或者某些 ORM 的动态代理。我们有个服务用了动态数据源 + MyBatis 的复杂插件链,加载了约 8.2 万个类,离上限还远;
  2. 长时间运行且不重启的应用,如果有类加载器泄漏,类数量会缓慢增长。

监控一下类数量很有必要:

$ jcmd <pid> VM.class_hierarchy 2>/dev/null | wc -l
# 或者更准
$ jcmd <pid> GC.class_stats | tail -1
Total 82,413 classes

# JMX 方式,接进监控
ManagementFactory.getClassLoadingMXBean().getTotalLoadedClassCount()

我们给所有服务加了这个指标的告警,阈值设在 200 万(离上限还有一倍余量)。

四、和 CompressedOops 的关系

紧凑对象头要求压缩类指针开启,而压缩类指针在堆 > 32 GB 时会自动关闭。这意味着超过 32 GB 堆的应用享受不到这个特性。

# 堆设 40G 时会看到
$ java -Xmx40g -XX:+PrintFlagsFinal -version 2>/dev/null | grep -i compressed
   bool UseCompressedClassPointers = false   ← 关了
   bool UseCompressedOops          = false

# 结果
$ java -Xmx40g -XX:+PrintFlagsFinal -version 2>/dev/null | grep -i compact
   bool UseCompactObjectHeaders    = false   ← 跟着关了

我们有个离线分析服务堆设 48 GB,切到 JDK 25 后完全没有收益。后来我们把它拆成了两个 24 GB 的实例,才拿到这个优化。

这也给了我们一个额外的结论:与其用一个大堆,不如拆成多个小堆。32 GB 这条线现在又多了一个意义。

五、JFR / 分析工具的兼容性

老版本的分析工具(比如某些 APM agent、JOL 老版本)可能读不懂新的对象布局。我们遇到过一个内部的性能分析 agent 报「无法解析对象头」,升级到支持 JDK 25 的版本后解决。

还有个具体的:JOL 要 0.17 以上版本才能正确识别紧凑对象头。用老版本会输出错误的大小。

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>   <!-- 或者更新 -->
</dependency>

我们的升级流程

把实际走过的步骤列一下,供参考。

  1. 依赖扫描。列出所有用了 Unsafe、ASM、ByteBuddy、CGLib 的依赖,逐个确认版本支持;
  2. 分析工具升级。APM agent、Profiler、JOL 全部升到支持 JDK 25 的版本。这一步要在业务代码之前做,否则测试环境的数据不可信;
  3. 测试环境压测。同样流量、同样参数,对比 JDK 21 和 25 的堆占用、GC、延迟、CPU。我们跑了 72 小时(要覆盖多次 GC 周期和定时任务);
  4. 正确性回归。重点是涉及反射、序列化、缓存的部分。我们专门补了一批针对序列化(JSON、Hessian、Kryo)的测试;
  5. 灰度。先灰度 1 台,观察 48 小时,重点看类数量增长曲线和堆占用;
  6. 参数固化。在启动参数里显式写 -XX:+UseCompactObjectHeaders,虽然默认开着,但显式写出来能防止有人误关,也方便回退。

第 4 步那批序列化测试是有针对性的。因为紧凑对象头不改变字段布局,理论上不影响序列化,但我们还是想验证——结果是 4,200 个测试用例全过,没问题。

最后的配置

# 文档问答服务,JDK 25 + G1
-Xms6g -Xmx6g                       # 从 8g 降到 6g,因为堆占用降了
-XX:+UseCompactObjectHeaders        # 显式写明,默认也是开
-XX:+UseG1GC
-XX:MaxGCPauseMillis=150
-XX:G1NewSizePercent=40
-XX:G1MaxNewSizePercent=60
-XX:G1HeapRegionSize=8m
-XX:InitiatingHeapOccupancyPercent=30
-XX:+UseStringDeduplication
-XX:NativeMemoryTracking=summary
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=10,filesize=64m

注意第一行:我们把堆从 8G 降到了 6G。既然实际占用降了 19%,就没必要给那么多。机器资源省下来给别的服务。

这个服务的容器配置也跟着改了:

resources:
  requests:
    memory: "10Gi"    # 从 12Gi 降下来
    cpu: "6"
  limits:
    memory: "12Gi"    # 从 14Gi 降下来
    cpu: "10"

三个服务(文档问答、订单聚合、客服 Agent)全部升级完之后,整体的容器内存申请量降了 23%,按我们的机器成本算,一个月省约 1.4 万元。

哪些情况收益最大

总结一下适用判断。

应用特征预期收益建议
大量小对象(DTO、包装类、Short String)15~22%强烈建议
集合类占比高(HashMap、ArrayList、Node)14~19%强烈建议
缓存型应用(堆里存大量小条目)16~24%最值得做
大数组为主(向量、矩阵、字节缓冲)3~6%收益有限
堆 > 32 GB0考虑拆分实例

判断方法很简单,用 JFR 看一眼对象大小分布,如果 24 字节及以下的对象占比超过 40%,收益就会很明显。

# 快速判断
$ jcmd <pid> JFR.start duration=60s settings=profile filename=/tmp/obj.jfr
$ jfr summary /tmp/obj.jfr | grep ObjectCount
# ObjectCount 的平均对象大小 < 48 bytes 就值得做

小结

紧凑对象头是那种「改一行配置就能拿到收益」的优化,性价比极高。但它也有典型的 JVM 层面优化的特征:收益是统计性的、依赖应用特征,而风险是隐蔽的、集中在少数代码路径上

我们这次最大的收获不是那 19% 的内存,而是借这个机会把依赖里的 Unsafe 使用情况梳理了一遍。以前从来没系统看过,这次发现了 3 处风险点(其中 1 处已经在生产上产生了偶发错误,只是我们一直没定位到)。

给准备升级的人一个建议:先把分析工具链升上去,再动业务代码。如果 APM 和 Profiler 读不懂新的对象布局,你在测试环境看到的所有数据都可能是错的,那时候判断「升级有没有问题」就全靠运气了。

还有一点:JDK 25 是 LTS,2025 年 9 月发布,到现在大半年了,我们等到 25.0.2 才上核心服务。这是我们的一贯做法——LTS 版本等第二个补丁版。这个规矩执行了五年,没让我们吃过亏,也没让我们落后什么。

参考