从 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 GB | 3.91 GB | -18.9% |
| 订单聚合 | 中等对象:DTO、List | 3.14 GB | 2.78 GB | -11.5% |
| 批处理(对账) | 大对象:批量数组、长字符串 | 6.40 GB | 6.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 21 | JDK 25 | 变化 |
|---|---|---|---|
| 堆峰值 | 4.82 GB | 3.91 GB | -18.9% |
| Young GC 频率 | 1.24 次/秒 | 0.91 次/秒 | -27% |
| Young GC 单次 | 31ms | 27ms | -13% |
| GC 停顿占比 | 3.8% | 2.5% | -34% |
| 老年代增长 | 34 MB/min | 28 MB/min | -18% |
| 请求 P99 | 268ms | 231ms | -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 万个类。正常应用远远够用,但有两种情况要小心:
- 大量动态生成类的应用。比如重度使用 CGLib、ByteBuddy、Groovy、或者某些 ORM 的动态代理。我们有个服务用了动态数据源 + MyBatis 的复杂插件链,加载了约 8.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>
我们的升级流程
把实际走过的步骤列一下,供参考。
- 依赖扫描。列出所有用了 Unsafe、ASM、ByteBuddy、CGLib 的依赖,逐个确认版本支持;
- 分析工具升级。APM agent、Profiler、JOL 全部升到支持 JDK 25 的版本。这一步要在业务代码之前做,否则测试环境的数据不可信;
- 测试环境压测。同样流量、同样参数,对比 JDK 21 和 25 的堆占用、GC、延迟、CPU。我们跑了 72 小时(要覆盖多次 GC 周期和定时任务);
- 正确性回归。重点是涉及反射、序列化、缓存的部分。我们专门补了一批针对序列化(JSON、Hessian、Kryo)的测试;
- 灰度。先灰度 1 台,观察 48 小时,重点看类数量增长曲线和堆占用;
- 参数固化。在启动参数里显式写
-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 GB | 0 | 考虑拆分实例 |
判断方法很简单,用 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 版本等第二个补丁版。这个规矩执行了五年,没让我们吃过亏,也没让我们落后什么。