Java 21 正式发布已经有一段时间了,作为 LTS 版本,它几乎是被生产环境等待最久的一次更新。我自己的感受是:Java 8 之后,Java 17 算是第一个让人有升级冲动的 LTS,而 Java 21 则是第二个——虚拟线程、模式匹配、顺序集合这些特性不再是"看看就好"的语法糖,而是真正能改变日常编码方式的硬东西。
这篇文章不打算把 JEP 列表从头到尾念一遍,我只讲自己在实际项目中真正用过、踩过坑、并且认为值得立刻用起来的特性,顺带把升级过程中需要注意的兼容性问题说清楚。如果你正在 Java 17 甚至 Java 8 上犹豫要不要升,这篇应该能给你比较实在的参考。
1. 为什么 Java 21 值得专门写一篇:LTS 定位与升级价值
1.1 从版本节奏看 Java 21 的特殊地位
先说一个很多人忽略的背景。Java 现在的版本节奏是半年一个普通版本,两年一个 LTS。LTS 意味着官方会提供多年的长期支持,企业可以放心把它作为生产基线。Java 17 是上一个 LTS,Java 21 是下一个 LTS,中间的 18、19、20 本质上都是"试验田",把各种预览特性一层层打磨。
Java 21 的特殊之处在于,它一口气把几个酝酿多年的重大特性转正了。虚拟线程从 JDK 19 的预览一路走到 21 正式落地;模式匹配的 switch 也从预览转正;记录模式、顺序集合同样成为正式特性。换句话说,Java 21 不是简单的小修小补,而是 Java 语言近十年来最大的一次"集中交付"。
从我个人的升级决策来看,Java 17 到 Java 21 的跨越,价值远大于当年 Java 11 到 Java 17。因为 Java 17 主要解决的是模块化、密封类、垃圾回收等偏底层的问题,而 Java 21 直接把"怎么写并发代码"和"怎么写数据代码"这两件事都改进了。
1.2 哪些项目应该优先考虑升级
不是所有项目都适合立刻升,但以下三类项目收益最大:
- 高并发 IO 密集型服务:大量线程阻塞在数据库查询、远程调用、消息队列消费上。这类场景是虚拟线程的主场,改造后往往能在相同硬件下把吞吐提升一个量级。
- 需要长期维护的企业应用:Java 21 的 LTS 支持周期很长,早点升上去,后面几年都不用再为版本焦虑。
- 正在做新项目立项:新项目直接以 Java 21 为基线,比用 Java 17 起步再折腾一轮更划算。
如果你维护的是非常老旧的遗留系统,比如大量使用自定义类加载器、深度依赖 Sun 私有 API,那升级前需要额外做兼容性评估。这部分我在第 6 节专门讲。
2. 虚拟线程:最值得优先落地的特性
2.1 虚拟线程到底解决了什么问题
在虚拟线程出现之前,Java 的并发模型基本是"一个请求一个线程"。每个线程需要分配独立的栈内存,默认大小通常是 1MB 左右。线程多了,内存先扛不住;线程切换多了,CPU 又把大量时间花在上下文切换上。所以传统做法是用线程池限制并发数,再用各种异步回调来压榨单个线程。
虚拟线程的核心思路是:把线程的调度从操作系统手里拿回来一部分,由 JVM 自己管理。虚拟线程非常轻量,创建上万个虚拟线程的开销远小于创建上万个平台线程。当一个虚拟线程因为 IO 阻塞时,JVM 会自动把它挂起,把底层载体线程让给其他虚拟线程运行。
用一句话概括:虚拟线程让"写阻塞代码"重新变得合理,你不用再为了并发去改写异步模型。
2.2 一个能直接感受到差距的示例
下面这段代码是最直观的体验方式。假设我们要并发发起 10000 个请求,每个请求模拟阻塞 100 毫秒:
// 传统线程池方式 try (var executor = Executors.newFixedThreadPool(200)) { for (int i = 0; i < 10000; i++) { executor.submit(() -> { Thread.sleep(100); return 1; }); } } // 虚拟线程方式 try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 10000; i++) { executor.submit(() -> { Thread.sleep(100); return 1; }); } }两者的写法几乎一样,差别只在创建 Executor 的那一行。但前者如果线程池开小了,任务会排队;开大了,内存和切换成本又上来了。后者则是每个任务一个虚拟线程,JVM 自动调度,完全不用纠结池大小。
我在一个模拟网关服务上做过对比:同样的 500 并发请求,每个请求内部有 3 次远程调用、每次 50ms 延迟。线程池模式下 CPU 在切换上消耗明显,P99 延迟经常超过 300ms;换成虚拟线程后,代码改成完全同步的写法,P99 降到 180ms 左右,而且线程数指标几乎不再成为瓶颈。
2.3 虚拟线程的边界和坑
虚拟线程不是万能的,以下几个坑我实测踩过:
- CPU 密集型任务不要用虚拟线程。虚拟线程的优势在于阻塞时让出载体线程。如果任务根本不阻塞,纯粹在算,那虚拟线程反而增加调度开销,结果可能比平台线程还慢。计算密集场景老老实实用平台线程池,数量按核数配置。
- synchronized 块会固定载体线程。当虚拟线程进入 synchronized 同步块并发生阻塞时,JVM 目前无法释放载体线程去做别的,会导致载体线程被"钉住"。高并发下有 synchronized 的代码段可能成为瓶颈。规避办法是把 synchronized 换成 ReentrantLock,或者尽量缩小同步块范围。
- 不要自己维护虚拟线程池。Executors.newVirtualThreadPerTaskExecutor() 是官方推荐姿势,每次任务创建一个虚拟线程,用完即弃。虚拟线程本身的创建成本极低,池化反而引入不必要的复杂度。
- ThreadLocal 要谨慎使用。虚拟线程数量可以非常大,大量 ThreadLocal 累积的内存不容小觑。能用参数传递就别用 ThreadLocal,实在要用,记得用完清理。
我在生产环境的实际体会是:虚拟线程改造时,代码层面几乎不用动业务逻辑,主要工作是抽屉式的替换——把 newFixedThreadPool 换成虚拟线程执行器,把异步回调链改成同步调用。改完后代码可读性大幅提升,因为你可以像写单线程程序一样写业务流程了。
3. 模式匹配与 switch 增强:从写代码到读代码的变化
3.1 旧 switch 的痛点与新语法
老 Java 开发对 switch 的感情很复杂。它能用,但限制多:case 只能匹配常量,分支体容易掉进 fall-through,类型判断还得靠 instanceof 加手动强转。
Java 21 里 switch 支持了模式匹配,可以直接对类型做分支判断,还能自动绑定变量。先说最基础的箭头语法,Java 14 就引入,但很多人还没用上:
switch (status) { case SUCCESS -> System.out.println("成功"); case FAILED -> System.out.println("失败"); default -> System.out.println("未知状态"); }箭头语法天然避免 fall-through,而且不用写 break。这个已经是正式特性了,团队代码评审里看到传统冒号写法我都会建议改掉。
3.2 类型模式与 null 处理的结合
真正让 switch 脱胎换骨的是类型模式。以前判断一个对象是哪种类型,要写一堆 instanceof 加强转:
// 旧写法 if (obj instanceof String) { String s = (String) obj; System.out.println(s.length()); } else if (obj instanceof List<?> list) { System.out.println(list.size()); }Java 21 的写法:
switch (obj) { case String s -> System.out.println("字符串长度: " + s.length()); case List<?> list -> System.out.println("列表大小: " + list.size()); case null -> System.out.println("空值"); default -> System.out.println("其他类型"); }注意 case null 可以显式处理空值了,这是新语法带来的一个实用改进。以前 null 进 switch 直接抛 NPE,现在可以优雅兜底。
还有一个值得说透的点:穷尽性。当 switch 用在 sealed 接口或者枚举上时,编译器会检查所有分支是否覆盖完整。比如下面这个密封接口:
sealed interface Shape permits Circle, Rectangle, Triangle {}如果你对 Shape 做 switch 并且只写了 Circle 和 Rectangle 两个分支,编译器直接报错:提示缺少 Triangle。这个特性在团队协作里价值极大——别人新增一个实现类时,所有 switch 编译错误会强迫他补上逻辑,而不是等到运行时才发现漏了。
3.3 记录模式:解构嵌套数据
记录模式(Record Pattern)是配合 record 类使用的。record 天然适合承载不可变数据,但解构的时候以前要手动调用访问器。现在可以直接在模式里拆:
record Point(int x, int y) {} record Line(Point start, Point end) {} Object obj = new Line(new Point(1, 2), new Point(3, 4)); if (obj instanceof Line(Point(var x1, var y1), Point(var x2, var y2))) { System.out.println("起点: " + x1 + "," + y1); System.out.println("终点: " + x2 + "," + y2); }嵌套解构配合 switch,处理复杂 JSON 解析后的对象模型会非常顺。我在一个接口适配层里用这个特性重写了原先一长串 if-else 的类型分发逻辑,代码行数少了三分之一,而且每个分支的意图一看就懂。
需要注意一点:记录模式目前对 var 的使用有讲究,嵌套层数深的时候,模式里全是显式类型会很难读,用 var 反而清晰。编译器会自动推断类型,不用怕破坏类型安全。
4. 顺序集合与字符串模板:日常编码里的小而美改进
4.1 Sequenced Collections 补齐了什么
以前拿 List 的最后一个元素,最稳的写法是 list.get(list.size() - 1)。拿 LinkedHashSet 的最后一个元素就更麻烦了,要遍历到末尾。Java 21 引入的 SequencedCollection 接口统一了这类操作:
// 新增的三个接口 interface SequencedCollection<E> extends Collection<E> { SequencedCollection<E> reversed(); E getFirst(); E getLast(); void addFirst(E e); void addLast(E e); E removeFirst(); E removeLast(); }List、Deque、LinkedHashSet 都实现了这个接口。也就是说,不管底层是哪种有序集合,你现在都能用同一套 API 取首尾元素、反转遍历:
List<Integer> list = new ArrayList<>(List.of(1, 2, 3)); list.getFirst(); // 1 list.getLast(); // 3 LinkedHashSet<String> set = new LinkedHashSet<>(List.of("a", "b", "c")); set.reversed().forEach(System.out::println); // c b a这套 API 的价值在于抽象统一。以前 LinkedHashSet 想倒序遍历,得先转成数组或者用迭代器倒着走,代码又丑又容易错。现在一个 reversed() 搞定。升级到 Java 21 后,我把自己代码库里的"取最后元素"工具方法全部删掉了,直接用标准 API。
4.2 字符串模板:预览特性里的双刃剑
字符串模板(String Templates)在 Java 21 里还是预览特性,默认需要加 --enable-preview 才能用。它的核心价值是把字符串拼接的繁琐和易错性压到最低:
// 传统写法 String message = "用户 " + name + " 在 " + time + " 登录,IP: " + ip; // 模板写法 String message = STR."用户 \{name} 在 \{time} 登录,IP: \{ip}";STR 是模板处理器,{表达式} 会自动调用 toString。看起来只是语法糖,但实际用起来有几个明显好处:
- 表达式可以是任意 Java 表达式,包括方法调用和三元表达式,不用先算好再拼。
- 比 String.format 更安全,因为它是运行时求值,不会因为格式符写错导致异常。
- 多行模板写 SQL、JSON 这类文本时,可读性提升非常明显。
不过我提醒一句:字符串模板目前不建议直接上生产。原因有三点:第一,预览特性随时可能在后续版本改 API,现在写了以后可能要迁移;第二,模板注入问题是安全审查重点,尤其是拼接 SQL 时不能因为有了模板就放松对参数化查询的要求;第三,团队里如果混用 Java 17 和 21 环境,预览语法会导致低版本环境编译失败。
我的建议是:个人项目或者非核心模块可以先试试感觉,生产环境等 Java 22 或 23 里这特性转正后再全面铺开。字符串模板真正转正后,会是日常编码效率提升最明显的一个特性。
4.3 不可变集合与空集合的 API 体验
Java 21 还有一个容易被忽略的改进:List.of() 返回的不可变集合现在有更合理的接口行为。以前不可变集合调用 add 会抛 UnsupportedOperationException,现在虽然还是抛,但新增的 getFirst/getLast 在不可变集合上直接可用,不用再担心类型转换。
这种细节改进看着不起眼,但对老代码的迁移很友好。我升级后第一轮编译报错大多是第三方库的问题,自己写的集合操作几乎没受影响,就是因为这些接口是向后兼容的——新增方法,老调用不受影响。
5. 结构化并发与作用域值:预览特性里的新思路
5.1 为什么说结构化并发是"下一个虚拟线程"
虚拟线程解决了"线程贵"的问题,但没解决"并发任务管理乱"的问题。你开了一堆线程去并行请求多个服务,怎么统一等待、统一取消、统一处理异常?以前的 CompletableFuture 能解决一部分,但代码一旦复杂,回调地狱就回来了。
结构化并发(Structured Concurrency)的核心思想是:并发任务的生命周期要跟代码块绑定。所有子任务在同一作用域内启动,结束时要一起结束,要么都成功,要么都能被统一处理异常。
Java 21 里对应的实现是 StructuredTaskScope,目前也是预览特性。一个典型用法:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Future<String> user = scope.fork(() -> fetchUser(userId)); Future<String> order = scope.fork(() -> fetchOrder(userId)); scope.join(); // 等待所有任务完成 scope.throwIfFailed(); // 有失败就抛出 return handle(user.resultNow(), order.resultNow()); }这段代码最牛的地方在于 try-with-resources。如果某个子任务抛异常,scope 关闭时会自动取消还没跑完的其他任务,不会让线程在后台空转。这种"要么全部成功,要么全部收场"的语义,比手动维护 Future 列表靠谱得多。
我在一个数据聚合接口上试过这个模式,原先用 CountDownLatch 加超时控制,写了四十多行;换成 StructuredTaskScope 后不到二十行,而且错误处理更完整——子任务超时、异常、取消这几种情况都能被统一捕获。
5.2 ScopedValue 为什么会替代 ThreadLocal 的槽点
ScopedValue(作用域值)是同一批预览特性里的另一个亮点。它的目标是解决 ThreadLocal 的两个老大难问题:内存泄漏和不可覆盖。
ThreadLocal 的问题是值绑定在线程上,线程复用会导致数据串台,必须手动清理;而且子线程继承值要靠 InheritableThreadLocal,设计也不好。ScopedValue 的做法是:值绑定在一个代码作用域内,进入作用域时设定,退出作用域后自动消失。
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance(); ScopedValue.where(REQUEST_ID, "req-12345") .run(() -> { // 在这个代码块内,REQUEST_ID.get() 返回 "req-12345" doSomething(); });这里有个关键语言点:run() 接受的是 Runnable 而不是 Callable,所以"传值"和"执行逻辑"是分开的两个步骤。嵌套 where 的时候内层会覆盖外层,退出内层后恢复外层值,这个语义比 ThreadLocal 干净太多。
不过说实话,ScopedValue 要真正用起来,需要框架层面的配合,比如 Web 框架在请求入口统一设置作用域值。目前 Web 框架还没大规模跟进,所以我对它的建议是:先理解思想,等生态跟上再落地。生产代码里短期内继续用 ThreadLocal 也没问题,注意清理即可。
5.3 预览特性怎么安全试玩
Java 21 里所有预览特性都需要两个条件才能跑:
- 编译时加
--enable-preview - 运行时加
--enable-preview
而且要确保编译和运行的 JDK 版本一致。我一开始踩过坑:用 IDEA 默认配置编译没加预览标志,结果代码里一旦出现 String 模板语法就直接编译失败。IDE 里需要在项目设置里把 language level 设为 21(预览),命令行则要显式带参数。
Maven 场景可以这样配置:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <release>21</release> <compilerArgs> <arg>--enable-preview</arg> </compilerArgs> </configuration> </plugin>注意如果用了 Lombok,部分版本和预览特性存在兼容性问题,建议先升级到最新版 Lombok 再开预览。这个坑我实际碰到过,报错信息还很迷惑,最后是逐个关特性才定位到是 Lombok 和记录模式冲突。
6. 升级到 Java 21 的实操路径与踩坑记录
6.1 从 Java 17 升级的兼容性评估
如果你的项目已经在 Java 17 上,升级到 Java 21 的阻力通常不大。JEP 里明确说了 Java 21 保持了向后兼容原则,大部分老代码编译通过后直接能跑。但我建议升级前按以下顺序做检查:
第一层:编译期检查。直接切 JDK 21 编译整个项目,看报错。最常见的问题是依赖了已删除的 API。Java 21 移除了 java.security 里的部分算法,删掉了 Thread.stop 等过时方法,还弱化了 finalize。如果你们代码里有这些,编译阶段就会暴露。
第二层:运行时检查。编译通过不代表运行没问题。重点跑一遍所有涉及反射、序列化、动态代理的用例。Java 21 模块系统对强封装更严格,部分反射代码之前能访问的私有字段现在会被拒。我遇到过一个老框架靠反射往 JDK 内部类塞值,升级后直接 IllegalAccessException。
第三层:第三方依赖检查。这是最花时间的。把所有依赖的 Java 版本兼容性查一遍。Spring Boot 3.2 开始正式支持 Java 21;Netty 用 4.1 以上;Jackson 用 2.15 以上基本没问题。如果你还在用 Spring Boot 2.x,建议先升 Boot 再升 Java,不然启动大概率出幺蛾子。
下面这个表格是我整理的常见兼容性要点,可以直接参考:
| 检查项 | 需要注意的点 | 建议操作 |
|---|---|---|
| 编译级别 | 老代码里用了 javafx、corba 等模块 | 这些模块已移除,需要重构 |
| 反射访问 | 访问 JDK 内部类 | 升级依赖版本,或加 --add-opens |
| 线程 API | Thread.sleep、ThreadLocal 使用 | 语法兼容,但注意虚拟线程环境下的行为差异 |
| GC 参数 | CMS 相关参数已失效 | 迁移到 G1 或 ZGC 参数 |
| 第三方框架 | Spring / Netty / Jackson 版本 | 升级到支持 Java 21 的版本 |
6.2 值得同时启用的 JVM 新能力
升级到 Java 21 不只是换个版本号,JVM 层面有两个默认能力可以白嫖:
分代 ZGC。Java 21 里 ZGC 的分代模式不再是实验特性,可以正式使用。分代 ZGC 把堆分成年轻代和老年代,显著降低了 ZGC 的 CPU 开销和内存占用。对延迟敏感的服务来说,-XX:+UseZGC是一个值得试的参数。我在一个延迟峰值为 50ms 的服务上测过,ZGC 下 P99 延迟比 G1 更稳,不过吞吐略有下降。具体取舍要看业务对延迟还是对吞吐更敏感。
动态 CDS 存档。类数据共享可以把应用启动时加载类的开销降下来。Java 21 的 CDS 支持动态存档,不像以前要单独跑一个训练进程。最简单的方式是:
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar第一次运行生成 archive 文件,后续启动带上-XX:SharedArchiveFile=app.jsa就能复用。在微服务冷启动场景,这个配置能把启动时间降低 20% 到 40%,几乎零成本。我自己的服务加上这个参数后,启动从 8 秒降到 5 秒左右,收益非常直观。
6.3 Key Encapsulation Mechanism API 的简要说明
Java 21 还正式引入了 KEM(密钥封装机制)API,主要用于现代密码学协议,比如后量子密码学的集成场景。常规业务项目大概率用不到,但如果你在做安全类中间件或者对接密钥交换协议,这是一个标准化的底层接口。我认为这个特性更偏向平台能力建设,普通应用开发者了解存在即可,不必急着在业务代码里引入。
6.4 升级后的测试与灰度策略
我自己的团队升级流程是四步走:
- 先在一台开发环境机器上切 JDK 21,编译全量代码,修复编译错误。
- 跑全量单元测试和集成测试,重点看线程相关的并发用例——虚拟线程环境下,依赖线程 ID 或线程本地状态的测试容易出问题。
- 用影子流量或者小流量灰度,观察 QPS、延迟、GC 指标至少一周。
- 确认无异常后再逐步扩大流量,最后全量切换。
其中第二步最容易被忽视。很多老测试用 Thread.sleep 模拟并发时序,在虚拟线程下执行顺序和平台线程不同,测试就可能偶发失败。这类失败不代表业务有 bug,但需要重新审视测试的假设是否合理。
还要提醒一句:升级 Java 大版本建议在 JIRA 里拉开一个专门的技术任务,记录所有修改点,方便回头追溯。这个任务列表里如果出现"为绕过编译错误禁用预览特性"这种操作,要特别警惕——那可能意味着你正在丢弃新特性,而不是真正升级。
最后说点个人体会
用了几个月 Java 21,最让我感慨的不是某个单个特性,而是这些特性组合起来对代码风格的影响。虚拟线程让同步阻塞代码重新变得体面,模式匹配让类型分发代码变得直白,顺序集合让集合操作不再依赖各种手写工具类。这种"标准库自己变强"的趋势,是 Java 近年来最让人安心的方向。
如果你现在还在 Java 8 上,我的建议是别跳 17,直接规划到 21。先花一两周把项目里最核心的服务跑通升级流程,你会发现大部分痛苦其实来自第三方依赖的版本追赶,而不是 Java 本身的改变。等顺手了,再逐个服务复制这套流程,整个团队的存量代码就能在可预期的时间内完成跨越。
最后分享一个小技巧:升级完以后,别忘了在 CI 里加上-Werror以外的编译检查——把源码级别设为 21,同时把代码库里过期 API 的警告全部清零。这能逼着团队在日后提交代码时自动避开新版本已经废弃的写法,让这次的升级成果保持得更久。