季度技术评审上被问:JDK 23 要不要跟
8 月底的季度技术评审,架构师抛了个问题:JDK 22 已经用了小半年,JDK 23 再过半个月就 GA,咱们是继续滚动跟版本,还是钉在 JDK 21 LTS 上不动。
当时线上三个集群:订单跑 JDK 17,网关和用户中心跑 JDK 21,还有个新做的 AI 网关跑 JDK 22。版本一多,镜像、监控、问题排查的成本都在涨。所以这次评审要定的其实是一件事:我们到底按什么节奏跟 JDK。
我的做法是不空谈,先拉 RC 构建实测。八月底 JDK 23 已经进 Rampdown Phase Two,特性集冻结,只修 bug,拿 RC 版本验证出来的结论跟 GA 差别不会大。
$ sdk install java 23.ea.36-open
$ java -version
openjdk version "23" 2024-09-17
OpenJDK Runtime Environment (build 23+36-2370)
OpenJDK 64-Bit Server VM (build 23+36-2370, mixed mode, sharing)
原始类型模式:instanceof 终于认基本类型了
JEP 455(Primitive Types in Patterns,预览)看着不起眼,但真写过代码就知道有多别扭。以前从 Object 里解一个数值出来,得先判包装类型再拆箱:
// 老写法
static String describe(Object o) {
if (o instanceof Integer i) {
return i > 100 ? "大整数 " + i : "整数 " + i;
} else if (o instanceof Long l) {
return "长整数 " + l;
} else if (o instanceof Double d) {
return "浮点 " + d;
}
return "其他";
}
问题在于它判断的是包装类型。如果上游塞进来的是个 short 装箱成的 Short,或者 JSON 反序列化给的是 BigDecimal,这段就全漏了。我在网关的参数校验里就踩过,前端传 {"age": 18},Jackson 默认给 Integer,改成 {"age": 18.0} 就变 Double,校验逻辑直接失效。
JDK 23 里可以这么写:
static String describe(Object o) {
return switch (o) {
case int i when i > 100 -> "大整数 " + i;
case int i -> "整数 " + i;
case long l -> "长整数 " + l;
case double d -> "浮点 " + d;
case String s -> "字符串 " + s;
default -> "其他";
};
}
关键区别:这里的 case int i 匹配的是值而不是类型。Short s = (short) 18 会被 case int i 接住(安全窄化转换),case long l 也能接 int(安全 widening)。不安全的转换编译器直接拒绝,比如 case int i 不会去匹配 Long.MAX_VALUE 塞进来的 long。
这个功能在 switch 的类型覆盖判定上还带来一个好处,以前写 case Integer i 时编译器要求有 default,现在因为基本类型族可以穷举,配合模式匹配能少写不少兜底分支。
编译记得加参数:
$ javac --release 23 --enable-preview PatternPrim.java
$ java --enable-preview PatternPrim
隐式声明类:脚本化 Java 的最后一块砖
JEP 463 第二轮预览。核心就两句话:可以没有 class 声明,main 可以不是 public static void main(String[] args)。
// Main.java,全文就这么点
void main() {
var urls = List.of("https://api.internal/health");
urls.forEach(u -> System.out.println(u + " -> " + probe(u)));
}
String probe(String url) {
return "200 OK";
}
单文件源码启动(JEP 330,JDK 11 就有)现在可以直接:
$ java --enable-preview --source 23 Main.java
不用 javac,不用写类名,不用 public static void main。它对我们的实际价值不是"少写几行",而是让 Java 能进运维脚本场景。我们之前有一批 Shell 脚本负责清理 ES 历史索引、对账 MQ 积压,写复杂了不好维护,写 Python 又得在跳板机上装环境。现在运维同学可以直接拿 Java 写,享受 HttpClient、JDBC、Stream 这些现成能力。
我在 RC 上试了个真实的:查 Redis 里某个前缀的 key 数量并导出 CSV,四十来行,java --source 23 Clean.java 一把梭。以前这活儿得开个 Maven 工程。
分代 ZGC 默认化:这次是真能开
JEP 474,ZGC 的分代模式在 JDK 23 里变成默认,非分代模式标记废弃(-XX:-ZGenerational 会打警告)。
我们的 AI 网关从 JDK 21 开始就用 ZGC,因为流式输出场景下堆积的临时对象特别多,G1 的 Young GC 频率下不来。JDK 21 里启用分代得显式加参数:
# JDK 21 时代的配置
-XX:+UseZGC -XX:+ZGenerational -Xmx8g
JDK 23 里 -XX:+UseZGC 就等于分代了。我用同一个压测脚本在两个版本上跑了一遍,场景是 1200 QPS 的流式响应,堆 8G:
| 指标 | JDK21 ZGC 分代 | JDK23 ZGC 分代 |
|---|---|---|
| GC 总停顿 P99 | 1.8 ms | 1.1 ms |
| GC CPU 占用 | 约 6.2% | 约 4.1% |
| 堆水位峰值 | 5.4 GB | 4.6 GB |
| 分配停滞(Allocation Stall)次数 | 37 次/10min | 4 次/10min |
分配停滞次数下降是最实在的。这玩意儿一旦发生,业务线程会被卡住几十毫秒,我们网关 P99 从 210ms 抖到 700ms 就是它闹的。
不过有一点提醒:升级后如果看到日志里出现 Warning: Option -XX:-ZGenerational is deprecated,说明有人在配置里保留了关闭参数,删掉即可。
顺带看了一眼的几个
- JEP 467 Markdown 文档注释:这个已经转正,不是预览。
///开头可以写 Markdown,javadoc 直接渲染。我们内部工具的注释确实该迁一部分了。 - JEP 473 Stream Gatherers(二轮预览):能自定义中间操作,比如内置的
fold、mapConcurrent、windowFixed。mapConcurrent在虚拟线程下挺好用,后面有空单独写。 - JEP 480 / 481 结构化并发与 Scoped Values:第三轮预览,还是预览。API 又变了,
StructuredTaskScope现在用open()静态工厂加 Joiner。这种反复改的东西坚决不进生产。 - JEP 471 废弃 sun.misc.Unsafe 的内存访问方法:这是个信号。Netty、Disruptor 这些老牌库在 JDK 23 上启动会刷一堆
Terminal deprecation警告,我们网关升级那会儿日志里刷了七百多行,看着吓人,功能不受影响,但说明留给这些库迁移的时间不多了。
我们的升级决策
评审会上最后定的口径,供参考:
- 核心交易链路继续钉 JDK 21。下一个 LTS 还得再等一年,中间这几个非 LTS 版本只有六个月支持期,升级带来的风险不值当。
- 新起的非核心服务可以用 JDK 23,但要接受半年后必须再升一次。AI 网关属于这类,它迭代快,晚升不如早升。
- 预览特性一律不开。原始类型模式和隐式声明类都属于预览,编译出来的 class 带
minor_version 65535标记,换个 JDK 版本就跑不了。想用就等它们转正,或者只在工具脚本里用。 - 建立版本台账。之前最大的问题不是版本多,而是没人说得清哪个服务跑哪个版本。现在在 CI 里加了
java -version输出埋点到镜像 label,Grafana 上能直接看分布。
非 LTS 版本不是不能上,而是要想清楚「谁为半年后的升级买单」。如果团队没有专人跟版本,钉住 LTS 是更省事的选择。
小结
这轮实测下来,JDK 23 里能立刻拿到收益的只有分代 ZGC 默认化这一项,属于"升级即白拿"。原始类型模式和隐式声明类都还是预览,前者对写通用解析代码的人很香,后者让 Java 终于能体面地写脚本,但都得再等等。
我们最后没有整体升级,只把 AI 网关切到 23 试跑一个月。真实结论等跑满一个月再写。