为什么我们决定在非 LTS 版本上跑生产
JDK 24 今年三月发布,是非 LTS 版本。我们公司原来的规矩是只用 LTS,所以 JDK 24 刚出来时没人提议升级。
改变想法是因为三个具体需求:AI 服务里的虚拟线程 pinning 问题、推理结果回写服务的大堆小对象内存压力、以及函数计算场景的冷启动。这三件事恰好分别对应 JDK 24 里的三个 JEP。我们选了四个服务做试点,跑了四个月,这篇是完整的实测数据和升级过程。
先说结论:我们升级了什么
不是所有服务都升。目前的状态:
| 服务 | JDK | 升级理由 |
|---|---|---|
| AI 网关 | 21 → 24 | JEP 491 解决虚拟线程 pinning |
| 推理结果回写 | 21 → 24 | JEP 404 分代 Shenandoah + JEP 450 紧凑对象头 |
| 文档解析入口 | 21 → 24 | JEP 483 AOT 缓存降冷启动 |
| 核心交易链路 | 21(不动) | 非 LTS 不进核心链路 |
核心交易链路不动,这是底线。非 LTS 版本只有 6 个月的免费支持窗口,我们评估不了过期后的风险,所以只在"挂了可降级、可快速回退"的服务上用。
JEP 491:虚拟线程不再被 synchronized 卡住
这是对我们最有价值的一个改动,也是我推动升级的主要原因。
背景是这样的:JDK 21 的虚拟线程有个著名的限制——在 synchronized 块或方法里执行阻塞操作时,虚拟线程无法从载体线程上卸载。因为 JVM 的对象监视器实现依赖载体线程的身份,卸载会导致监视器归属错乱。这时虚拟线程会"钉住"(pin)载体线程,虚拟线程带来的并发优势就没了。
这个问题在 AI 服务里特别要命,因为我们重度依赖各种第三方 SDK(向量库客户端、HTTP 客户端、对象存储 SDK),这些库内部到处是 synchronized。我们排查时用 JFR 抓过:
$ jcmd 1 JFR.start duration=60s settings=profile filename=pin.jfr
$ jfr summary pin.jfr | grep -i virtualthreadpinned
jdk.VirtualThreadPinned Event Count: 47,213
6 万次请求里有 4.7 万次发生了 pinning。查看具体位置,绝大部分来自 Redis 客户端和 MongoDB 驱动的 synchronized 方法。
JDK 21/22/23 时代的规避办法是把 synchronized 换成 ReentrantLock。但第三方库里的 synchronized 你改不了。
JEP 491 重写了对象监视器的实现,让监视器的归属跟着虚拟线程而不是载体线程走,从根上解决了 pinning。升级到 JDK 24 之后:
| 指标 | JDK 21 | JDK 24 |
|---|---|---|
| pinning 事件数(6 万请求) | 47,213 | 0 |
| 5000 并发时 P99 | 1840ms | 412ms |
| 载体线程峰值占用 | 16(打满) | 7 |
| 5000 并发时 CPU | 5.8 核 | 3.1 核 |
P99 从 1840ms 降到 412ms,这个提升是纯粹的——我们一行代码都没改,只是换了 JDK 版本。升级之后我把之前为了规避 pinning 改成的 ReentrantLock 全改回了 synchronized,代码更自然。
顺便说一句,如果你还在 JDK 21 上用虚拟线程,强烈建议抓一次
jdk.VirtualThreadPinned事件看看。这个坑很安静,不会报错,只是让你的性能达不到预期。
JEP 404:分代 Shenandoah 转正成实验特性
我们的推理结果回写服务有个特点:大量短命的小对象。每条 Kafka 消息会创建几十个 DTO、JSON 节点、临时集合,处理完立刻丢弃。堆内存 8GB,Young GC 每分钟 40 多次。
原来用的是 G1,STW 时间在 25~60ms 之间,对下游消费有轻微影响。换成 Shenandoah 的低暂停特性试了试,但老版本的 Shenandoah 是非分代的,对短命对象的回收效率低,反而更慢。
JEP 404 在 JDK 24 里把分代模式加进了 Shenandoah(实验特性),开启方式:
java -XX:+UnlockExperimentalVMOptions \
-XX:+UseShenandoahGC \
-XX:ShenandoahGCMode=generational \
-Xms8g -Xmx8g \
-jar infer-writer.jar
实测对比(同样的生产流量回放,30 分钟):
| GC | 平均 STW | 最大 STW | GC 总耗时占比 | 吞吐 |
|---|---|---|---|---|
| G1(默认) | 38ms | 127ms | 4.1% | 基准 |
| Shenandoah(非分代,JDK 21) | 4ms | 11ms | 9.7% | -8% |
| Shenandoah(分代,JDK 24) | 3ms | 9ms | 3.6% | +2% |
分代模式解决了 Shenandoah 的老问题:既保住了低暂停(最大 STW 从 127ms 降到 9ms),又不牺牲吞吐。这个组合以前在 HotSpot 上是没有的——要低暂停就得接受吞吐损失。
需要说明的是它还是实验特性,我们观察了四个月没有出现问题,但官方不建议在关键业务上用。我们的判断是:这个服务挂了影响的是推理结果的回写时效(延迟几分钟可接受),不是资金或交易,可以承受。
顺带一提 JEP 450:紧凑对象头
同一个服务上我们还开了紧凑对象头(也是实验特性):
java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders ...
它把对象头从 128 位(16 字节)压缩到 64 位(8 字节)。对于大量小对象的场景收益明显——我们那个服务堆内存占用从 5.2GB 降到 4.1GB(降低 21%),Young GC 频率也降了。开启没有任何代码改动,算是白捡的。
JEP 483:提前类加载与链接
这个我在 Leyden 那篇里详细写过,这里只给数据。文档解析入口服务开启 AOT 缓存后:
# 三步流程
$ java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar
$ java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot
$ java -XX:AOTCache=app.aot -jar app.jar
| 配置 | 冷启动 | 类加载耗时 |
|---|---|---|
| JDK 21 + AppCDS | 3240ms | 1810ms |
| JDK 24 AOT 缓存 | 2604ms | 940ms |
类加载耗时降了 48%,整体启动降 20%(相对 AppCDS)。单独看不算惊艳,但配合 GraalVM 用在最需要的地方,函数计算那块的月度成本降了 62%。
几个面向 AI 场景有用的小特性
除了上面三个大头,还有几个特性在我们的 AI 代码里用上了。
JEP 485:Stream Gatherers(正式特性)
这是 JDK 24 里唯一转正的语言/库级特性。它给 Stream API 增加了自定义中间操作的能力,我们在 RAG 的切片流水线里用得很顺手。
以前按 token 预算动态分批(每段不超过 512 token,但不要把一句话切断)要手写循环,现在可以这样:
// 按累计 token 数分块,且不切断句子
List<String> chunks = sentences.stream()
.gather(Gatherer.ofSequential(
AtomicInteger::new,
(state, sentence, downstream) -> {
int n = estimateTokens(sentence);
if (state.get() + n > 512 && state.get() > 0) {
state.set(0);
downstream.push(current.toString());
current.setLength(0);
}
current.append(sentence);
state.addAndGet(n);
return true;
}))
.toList();
代码不算短,但比手写循环的状态管理清晰,而且能跟 Stream 的其他操作无缝组合。我们在文档切片、批量 embedding 请求分批、结果流式聚合三处都用了。
JEP 487 / 499:ScopedValue 和结构化并发(仍是预览)
这两个都还是预览特性(第四次预览),我们没有在生产用,但在内部工具里试过。
ScopedValue 对 AI 服务的价值很直接:链路追踪的 traceId、用户身份、租户信息这些需要跨层传递但不想污染方法签名的东西,用 ThreadLocal 在虚拟线程下有个问题——虚拟线程可能上百万个,每个都持有一份 ThreadLocal Map 副本,内存浪费。ScopedValue 是不可变的、有作用域的,更适合这个场景。
private static final ScopedValue<RequestContext> CTX
= ScopedValue.newInstance();
void handle(Request req) {
ScopedValue.where(CTX, buildContext(req))
.run(() -> process()); // process() 内部任意深度都能读 CTX.get()
}
但预览特性要用得加 --enable-preview,而且每次 JDK 升级 API 都可能变。我们的策略是再等等,等它转正。目前链路追踪还是老老实实用 ThreadLocal + 显式清理。
JEP 489:Vector API(第九次孵化)
老实说,这个我没能用上。它的想法很好——用 SIMD 指令加速向量运算,理论上对 embedding 的余弦相似度计算、矩阵乘法这些有帮助。我们的相似度计算是向量数据库做的,不在 Java 侧,所以没机会用。
但我做过一个 benchmark,纯 Java 计算 768 维向量的余弦相似度,100 万次:
// 标量实现
for (int i = 0; i < dim; i++) dot += a[i] * b[i];
// Vector API(使用 FloatVector,128 位)
var species = FloatVector.SPECIES_PREFERRED;
for (; i < species.loopBound(dim); i += species.length()) {
var va = FloatVector.fromArray(species, a, i);
var vb = FloatVector.fromArray(species, b, i);
acc = acc.add(va.mul(vb));
}
| 实现 | 100 万次耗时 |
|---|---|
| 标量循环 | 1240ms |
| Vector API | 385ms |
3.2 倍提升,很可观。但这是第九次孵化了,至今没转正,而且 API 一直在变。如果你现在需要高性能数值计算,我建议用现成的库(比如 ND4J、ojAlgo),别赌这个 API。
升级过程遇到的坑
记录几个实际问题,给要升级的人参考:
- Spring Boot 版本要够新。Spring Boot 3.4 官方支持 JDK 24,3.2 及以下没测过。我们有个服务在 3.2 上升 JDK 24,启动报
Unsupported class file major version 68,是因为里面的 Byte Buddy 版本太老; - 字节码增强类库要升级。Byte Buddy、ASM、CGLIB、Javassist 这几个都需要较新版本才能识别 JDK 24 的 class 文件版本(68)。我们的 SkyWalking agent 从 9.2 升到 9.4 才正常;
- 移除了 Windows 32 位支持(JEP 479),对我们没影响,但如果你有相关构建流水线要留意;
- Security Manager 彻底禁用(JEP 486)。我们有个老依赖在启动时会尝试设置 Security Manager,现在会直接抛异常。用
-Djava.security.manager=allow可以临时绕过,但建议直接改代码; - 紧凑对象头会影响 JOL 类的内存分析工具。我们用 JOL 排查过一次对象布局,结果跟实际不一致,排查半天才发现是开了紧凑对象头。
关于"面向 AI 时代的演进"的一点看法
JDK 24 官方并没有把 AI 作为主题,但客观上,这一轮的很多改进恰好踩在 AI 应用的痛点上:
- 虚拟线程解决的是 AI 服务高并发 IO 编排的问题,JEP 491 补上了最后一块拼图;
- AOT 缓存解决的是 Serverless 场景下 AI 推理函数冷启动贵的问题;
- 低延迟 GC + 紧凑对象头解决的是 AI 数据管道里海量小对象的内存效率问题。
这些改进没有一个是专门为 AI 设计的,它们本来就是 JVM 演进的自然方向。AI 应用恰好是"高并发 IO + 内存密集 + 弹性伸缩"的典型负载,把这些 JVM 的长期改进全部激活了。
我个人的判断是:Java 在 AI 时代的机会不在"用 Java 训练模型",而在"用 Java 承载 AI 系统"。而这件事能不能做好,取决于 JVM 在高并发、低延迟、快速启动这些传统指标上的表现,跟 AI 本身关系不大。从这个角度看,JDK 21 到 24 这一轮的改进,其实让 Java 在 AI 工程领域的竞争力变强了不少。
小结
四个服务升级 JDK 24,跑了四个月,没有出现因 JDK 版本导致的问题。最大的收益来自 JEP 491(P99 降 78%),其余是稳步改善。
给想升级的人的建议:
- 如果用了虚拟线程,JEP 491 值得单独为此升级,这是 JDK 24 最有价值的一个改动;
- 大量小对象 + 对延迟敏感的服务,可以试试分代 Shenandoah + 紧凑对象头,但要接受它们是实验特性;
- 冷启动敏感的服务开 AOT 缓存,别忘记录制脚本要覆盖业务路径;
- 核心链路先别动,非 LTS 版本的支持周期是真实风险;
- 升级前检查所有字节码增强类库的版本,这是最常见的问题来源。
最后提醒一点:JDK 24 的免费支持窗口只有 6 个月。我们的计划是在下一个 LTS 发布后评估迁移,在那之前这几个服务保持每月关注安全公告。非 LTS 上生产是可以的,但一定要有明确的退出计划。