Administrator
发布于 2026-08-14 / 593 阅读
9

JDK 25 LTS 正式发布:这是 Java 三十而立的一版

最后一个服务切完是上周五晚上

我们 47 个 Java 服务,从 JDK 21 升到 25,前后六周。最后一个切完是 8 月 7 号晚上十一点半,切完我在群里发了句"收工",然后第二天睡到中午。

JDK 25 从 GA 到现在十个月,这个时间点写"发布"有点晚。但好处是能用生产数据说话,而不是照着 release notes 念一遍。这篇按我们实际用到的顺序讲,不是按 JEP 编号。

先看支持周期,这决定了要不要升

技术特性之前,先把时间线摆清楚,这是架构决策的基础。

版本GA 时间主流支持到扩展支持到我们现在的状态
JDK 172021-092026-092029-09还剩 2 个老服务,Q4 清
JDK 212023-092028-092031-09已全部迁出
JDK 252025-092030-092033-09主力版本

这里最要紧的是 JDK 17 的主流支持 2026 年 9 月到期,也就是下个月。我们还有两个老服务在 17 上,是历史遗留的消息同步任务,依赖一个已经没人维护的库。这个月必须处理掉,否则要么买扩展支持,要么裸奔。

我们团队定的策略是:隔一个 LTS 升一次。17 → 25 → 33(如果到时候还叫这个号)。理由是每次大版本升级的边际收益在下降,而成本(回归测试、兼容性处理、团队学习)基本恒定。跳过 21 直接上 25 是合理的;但如果现在还在 17,我建议直接考虑 25,不要再走一遍 21。

我们真正拿到收益的三个特性

先说结论:这版里省钱和省机器的特性,比语言特性值钱。这个判断可能有点功利,但它符合我们现在的处境——AI 平台的机器成本涨得厉害,能省一点是一点。

紧凑对象头(JEP 519,正式转正)

对象头从 16 字节压到 8 字节。开启方式简单:

-XX:+UseCompactObjectHeaders

效果取决于你堆里小对象多不多。我们的服务差异很大:

服务堆占用变化GC 频率变化说明
Agent 编排服务-19.4%-23%大量短生命周期的 DTO 和消息对象
网关服务-14.7%-18%请求/响应对象多
向量同步任务-3.1%-2%主要是大数组,对象头占比小
推理代理服务-1.2%-1%堆里主要是 off-heap 的 buffer

整体看,我们 47 个服务的平均堆占用降了 11.3%。按当前的机器规格,相当于省下 6 台 16C32G,一个月 ¥1.4 万。

注意最后两行:如果你的堆里主要是大数组和 off-heap 内存,这个特性基本没用。我们的推理代理服务开了之后只降 1.2%,但它也没什么副作用,就统一开了。

兼容性上我们没遇到问题,但这个特性要求堆不超过 64GB(压缩指针的限制超过之后失效),超过的话 JVM 会忽略并打警告。

分代 Shenandoah(JEP 521,正式转正)

我们有两个服务对停顿敏感,之前用的是 ZGC。这次试了分代 Shenandoah,因为它在吞吐上比 ZGC 好一些。

-XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational

72 小时的对比数据(同一个服务,同样的流量录制回放):

GCP99 停顿P99.9 停顿吞吐损失CPU 占用
G1(升级前)42ms186ms3.1%基准
ZGC1.8ms3.2ms6.4%+11%
分代 Shenandoah7.9ms14.1ms4.2%+4%

最后选了分代 Shenandoah。理由是我们的业务对 8ms 的 P99 完全无感(网络往返都不止这个数),但 CPU 多占 7 个百分点是实打实的成本。ZGC 保留给那个真正需要亚毫秒停顿的行情推送服务。

AOT 命令行调优与方法分析(JEP 514、515)

这两个是这次升级里最惊喜的部分,也是宣传最少的。

原理不复杂:JVM 运行时会做方法 profiling 来指导 JIT,这个"预热"过程慢。AOT 的思路是——跑一次,把 profiling 数据和方法编译结果缓存下来,下次启动直接用。

# 第一次运行,记录 profiling 数据
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
     -cp app.jar com.example.Main

# 生成 AOT 缓存
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
     -XX:AOTCache=app.aot -cp app.jar

# 生产运行,加载缓存
java -XX:AOTCache=app.aot -cp app.jar com.example.Main

我们的 Agent 编排服务(Spring Boot 4.0,68 个 Bean,启动要扫描一堆配置):

指标JDK 21JDK 25(无 AOT)JDK 25 + AOT 缓存
启动到可服务2,417ms2,203ms1,684ms
达到 90% 峰值 QPS 的时间94s88s31s
启动期间 CPU 消耗基准-6%-61%

第二行是最有价值的。以前扩容时,新 Pod 要一分半才能达到正常处理能力,这期间要么流量打过去被打爆,要么得慢慢放量。现在 31 秒,我们的 HPA 策略可以激进很多。

生成的 .aot 文件有 38MB,我们把它打进了镜像。

语言层面:结构化并发和作用域值终于转正

这两个从 JDK 21 开始孵化/预览,到 25 才算定下来。转正意味着可以写进生产代码了,之前我们不敢用,怕 API 又变。

结构化并发在我们的 Agent 编排里用得最多(上一篇提过):

try (var scope = StructuredTaskScope.open(
        Joiner.<Result>allSuccessfulOrThrow(),
        cfg.withTimeout(Duration.ofSeconds(8)))) {

    Subtask<Result> order = scope.fork(() -> orderSvc.query(uid));
    Subtask<Result> risk  = scope.fork(() -> riskSvc.score(uid));

    scope.join();
    return assemble(order.get(), risk.get());
}
// 出了这个块,任何未完成的子任务都会被取消,不会有线程泄漏

配合作用域值(ScopedValue)替代 ThreadLocal,虚拟线程场景下这个组合几乎是必须的:

private static final ScopedValue<TenantContext> CTX = ScopedValue.newInstance();

public Response handle(Request req) {
    TenantContext ctx = auth.resolve(req);
    return ScopedValue.where(CTX, ctx)
                      .call(() -> doHandle(req));    // 虚拟线程内可读,不可变
}

ScopedValue 比 ThreadLocal 好在三点:不可变(不会被下游偷偷改掉)、有明确的生命周期(出了 call() 就没了)、在百万级虚拟线程下的内存开销小得多。我们之前用 ThreadLocal 存租户上下文,压测 50 万虚拟线程时那部分内存占了 1.2GB,换成 ScopedValue 之后降到 190MB。

顺便说一句,虚拟线程本身在 25 里没大变化,但我们这次把最后一批还在用平台线程池的异步任务也改了。唯一踩的坑是:不要在虚拟线程里做 CPU 密集的活。我们有个 embedding 后处理的计算逻辑,改成虚拟线程之后吞吐反而降了 8%,因为调度开销大于收益。

升级踩的三个坑

不是所有事情都顺利,记一下。

坑一:老的字节码增强库。我们有个服务用了 ByteBuddy 1.12,在 25 上启动直接报错:

java.lang.IllegalArgumentException: Unsupported class file major version 69
	at net.bytebuddy.jar.asm.ClassReader.<init>(ClassReader.java:196)
	at net.bytebuddy.utility.OpenedClassReader.of(OpenedClassReader.java:86)

升级到 1.17 解决。这个坑在我预期内,处理花了半天。教训是:升级前先扫一遍依赖树里所有做字节码增强的库,ByteBuddy、ASM、CGLIB、Javassist 一个都别漏。

坑二:一个 JDK 内部 API 被移除了。某服务用了 sun.misc.Unsafe 的一个方法做堆外内存操作,25 里行为变了(不是完全移除,是语义调整)。这个查了两天,最后换成 MemorySegment 的正式 API。

// 改前
long addr = unsafe.allocateMemory(1024);
unsafe.putByte(addr, (byte) 1);

// 改后
try (Arena arena = Arena.ofConfined()) {
    MemorySegment seg = arena.allocate(1024);
    seg.set(ValueLayout.JAVA_BYTE, 0, (byte) 1);
}   // 自动释放,不用手动 free

改完还顺带修了一个潜在的内存泄漏。

坑三:JFR 事件配置。JDK 25 的 JFR 加了方法计时与追踪(JEP 520),我们有个服务默认开启了 MethodTiming,结果 CPU 涨了 4%。默认配置下这个事件是关的,是我们自己的启动脚本里从网上抄了一段配置带进去的。去掉就好了。

升级流程:我们怎么控制风险

47 个服务六周,平均每周 8 个。流程固定成四步,每一步都有卡点。

  1. 编译期检查。CI 里加一个 JDK 25 的构建 job,先跑两周,只编译不发布,把编译告警收集起来;
  2. 依赖扫描。用 jdeps --jdk-internals 扫内部 API 依赖,这一项在第一步之前做,能提前发现坑二那类问题;
  3. 灰度单实例。每个服务先切一个实例,观察 24 小时,重点看 GC 日志、堆占用、错误率。这一步拦下了 5 个服务的问题;
  4. 全量 + 保留回滚镜像。全量切完后,旧的 JDK 21 镜像保留 7 天,随时可回滚。实际回滚了 2 次。
$ jdeps --jdk-internals --multi-release 25 -cp "libs/*" app.jar
app.jar -> JDK removed internal API
   com.example.OffHeap   -> sun.misc.Unsafe   JDK internal API (JDK removed)
   com.example.Encoder   -> sun.nio.cs.UTF_8  JDK internal API (not exported)

Warning: JDK internal APIs are unsupported and private to JDK implementation

两次回滚的原因:一次是 GC 参数没同步改(分代 Shenandoah 的堆大小建议最小 4GB,我们有个小服务只给了 2GB,频繁 full GC);一次是上面说的 JFR 配置。

给还在观望的人的具体建议

按我们的情况排个优先级。

如果还在 JDK 17 或更早:现在就该动了,别等。17 的主流支持下个月到期,而且 17 → 25 之间积累的变化(虚拟线程、结构化并发、模式匹配的完整化、AOT)足够多,值得投入。建议分两步走:先升级到 21 跑一个月(风险最小),再上 25。我们有个团队直接跨 8 个版本,遇到的问题是分两步走的两倍。

如果在 JDK 21:不着急,但今年内建议动。21 的支持到 2031,时间充裕,但紧凑对象头和 AOT 这两个特性省的钱,越早用越早受益。我们的账是:47 个服务,每月省 ¥1.4 万(堆内存)+ ¥0.6 万(启动期 CPU),六个月回本升级投入。

如果是新项目:直接用 25,没有任何理由选别的。唯一例外是你的核心依赖还不支持,这种情况现在应该极少了。

不要为了用新语言特性而升级。模式匹配、记录模式这些语法糖锦上添花,但它们不构成升级理由。真正的理由是内存、GC、启动性能和支持周期。这个观点可能有人不同意,但我们这次复盘下来,团队投入产出比最高的三项全是运行时特性。

还没做完的

两个服务还在 JDK 17,卡在一个停更的支付 SDK 上,它依赖 javax.xml.bind 的 JDK 8 行为。这个月要么自己 patch,要么换 SDK,倾向于换。

另外,稳定值(StableValue,JEP 502)这次还是预览,我没敢用。它的思路很好——延迟初始化但能被 JIT 常量折叠,很适合我们那些"启动时算一次、之后只读"的路由表和配置。等它转正我会第一时间试。

Vector API 孵化到第十轮了,我们有一个 embedding 后处理的场景理论上能用,实测下来收益不明显(数据规模不够大),暂时搁置。

就写到这。如果哪天你也被《JDK 25 LTS 正式发布:这是 Java 三十而立的一版》里同一个坑绊住,回来翻这篇,能省半小时。

参考