Java 21正式交付了虚拟线程、记录模式、模式switch和有序集合接口,让阻塞式并发、数据解构和有顺序的集合操作更直接。
Java 21发布说明中的特性不都属于稳定API;结构化并发与字符串模板当时仍是预览特性,后者后来被撤回。
用javac --release 21锁定稳定源码边界,不启用预览;再按实际运行时版本复核虚拟线程固定和预览API的行为。
是什么,为什么存在
Java 21是2023年9月发布的Java平台版本,也是许多供应商提供长期支持(LTS)的迁移基线。 LTS是发行商的支持政策,不是Java语言中的兼容性开关;支持期限要以实际JDK发行商的承诺为准。 对源码而言,关键问题始终是项目声明的最低版本,以及是否允许预览特性。
这个版本同时改进了并发模型、模式匹配和集合API。
虚拟线程(virtual thread) 让一个阻塞任务对应一个线程的写法可以扩展到大量并发任务; 记录模式(record pattern) 与 模式匹配(pattern matching) switch把类型检查、解构和分支放进同一个编译器检查的结构。
有序集合(sequenced collection) 则统一了首元素、尾元素和反向视图的访问方式。
“Java 21特性”不等于“在Java 21中出现过的每项提案都已成为永久API”。
虚拟线程、记录模式、模式switch和有序集合在Java 21中正式交付,无需预览开关。
结构化并发与字符串模板在该版本中只是预览;截至Java 25,结构化并发仍是预览API,而字符串模板的后续提案已经撤回。
因此,这个主题是一份版本边界指南,而不是完整发布说明。 它聚焦普通应用最常遇到的稳定能力,并明确标出旧教程最容易误导生成代码的预览历史。 垃圾收集器、密码学和平台移植等其他JDK 21变化,应按各自的运行时或安全主题评估。
工作原理
稳定特性与预览提案
下面的状态描述Java 21源码边界,并补充Java 25时可观察到的后续变化。
只有“正式”行可以在--release 21且不带--enable-preview时使用。
| 能力 | Java 21状态 | Java 25视角 | 核心边界 |
|---|---|---|---|
| 虚拟线程,JEP 444 | 正式 | 稳定API | 提高阻塞式任务的可扩展性,不提高CPU数量 |
| 有序集合,JEP 431 | 正式 | 稳定API | 提供两端操作与反向视图 |
| 记录模式,JEP 440 | 正式 | 稳定语法 | 解构记录类,可嵌套使用 |
模式switch,JEP 441 | 正式 | 稳定语法 | 支持类型模式、when守卫和case null |
| 结构化并发,JEP 453 | 预览 | 第五次预览 | API在后续版本中继续变化 |
| 字符串模板,JEP 430 | 预览 | 不在Java 25中 | 后续第三次预览提案已撤回 |
预览特性(preview feature) 已经完整实现,但尚未成为Java SE的永久组成部分。 它需要显式选择加入,让开发者试用并反馈设计;语法或API可以在下一版变化,也可以完全撤回。 预览不是“默认关闭的稳定功能”,因此不能把带预览开关的成功编译当作稳定Java 21兼容性证明。
虚拟线程共享载体
虚拟线程仍是java.lang.Thread实例,使用同样的阻塞式调用栈、异常处理和中断模型。
区别在于,它由JDK调度,不会在整个生命周期里独占一个操作系统线程。
虚拟线程运行Java代码时会挂载到一个 载体线程(carrier thread) ;许多JDK阻塞操作会让它卸载,载体随后可以运行别的虚拟线程。
Executors.newVirtualThreadPerTaskExecutor()为每个提交的任务创建一个新虚拟线程。
它不是需要调节大小的线程池,也不会替数据库连接、远端配额或内存建立背压。
当下游资源只能承受固定并发量时,应使用信号量、连接池或服务自身的限额单独约束资源访问。
虚拟线程主要改善等待占比高的工作负载。
CPU密集任务仍受处理器核心数限制,创建更多虚拟线程不会让计算本身更快。
它们还支持ThreadLocal,但“支持”不代表适合给每个短命任务缓存大型对象。
记录模式与模式switch
记录模式依据记录组件的声明顺序解构值,并在模式成功时绑定带类型的局部变量。 模式可以嵌套,因此一个分支能同时验证外层记录类型和内部记录形状。 这不会绕过构造器校验,也不会让记录组件引用的可变对象自动变成不可变对象。
模式 switch表达式(switch expression) 按源码顺序选择第一个适用标签。
较宽的无守卫类型模式会支配后面的较窄模式,编译器会把不可到达的分支判为错误。
对密封层次进行穷尽匹配时通常不需要default,这样新增分支会在重新编译消费者时暴露出来。
null仍需要明确策略。
存在case null时会选择该分支;不存在时,选择器为null仍会抛出NullPointerException,default不会替它兜底。
守卫用when写在模式之后,适合表达模式已经匹配时才有意义的额外条件。
有序集合提供反向视图
JEP 431引入SequencedCollection、SequencedSet和SequencedMap,统一表示定义了遇见顺序的集合。
List、Deque、LinkedHashSet、SortedSet及相应映射类型由此获得一致的两端或反向访问接口。
具体类型仍保留自己的约束,例如某些不可修改集合和排序集合不支持在任意一端插入。
reversed()返回顺序相反的视图,不是元素副本。
原集合的变化会出现在反向视图中;如果该操作受支持,通过视图进行的变化也会写回原集合。
需要快照时,应显式复制视图,并同时决定副本是否必须不可修改。
按顺序应用版本边界
迁移时先确定源码级别,再区分正式与预览特性, 最后检查部署运行时带来的实现变化。 这个顺序能避免两种常见混淆:把“能在新JDK上运行”当成“能为旧版本编译”,以及把“曾在发布说明中出现”当成“今天仍存在”。
构建必须覆盖生产源码和测试源码,因为测试辅助类同样可能混入新语法或新API。 Maven、Gradle、IDE和CI也要表达同一个发布目标;只设置字节码目标版本,未必会同时限制语言语法和标准库表面。
升级运行时后再复核性能建议与诊断开关。 虚拟线程的公共API在Java 21已经稳定,但固定行为在Java 24得到改进,这类变化不会由源码级别检查提示。 把源码契约和运行时调优记录成两个字段,比笼统写“项目使用Java 21”更准确。
示例
解构一个密封领域模型
第一个示例把记录模式、when守卫、case null和密封层次的穷尽检查放在一起。
两个Shipment分支必须按从具体到一般的顺序排列,否则无守卫分支会支配加急分支。
public class PatternDelivery {
sealed interface Delivery permits Pickup, Shipment {}
record Pickup(String store) implements Delivery {}
record Shipment(String city, int days) implements Delivery {}
static String promise(Delivery delivery) {
return switch (delivery) {
case null -> "invalid delivery";
case Pickup(String store) -> "collect at " + store;
case Shipment(String city, int days) when days <= 2 ->
"express to " + city;
case Shipment(String city, int days) ->
days + " days to " + city;
};
}
public static void main(String[] args) {
System.out.println(promise(new Pickup("Central")));
System.out.println(promise(new Shipment("Lyon", 2)));
System.out.println(promise(new Shipment("Nice", 4)));
System.out.println(promise(null));
}
}collect at Central
express to Lyon
4 days to Nice
invalid deliveryDelivery只有两个允许的实现,两个记录分支已经覆盖所有非空值。
显式case null补上输入边界,所以这里不需要含糊的default。
这段程序使用javac --release 21 -Xlint:all编译并运行,无需预览开关。
为每个阻塞任务创建虚拟线程
这个批处理为每个订单读取创建一个虚拟线程,并通过Future.get()按固定顺序收集结果。
Thread.sleep()只模拟数据库或网络等待;输出中的virtual=true验证任务确实在虚拟线程上运行。
import java.util.concurrent.Executors;
public class VirtualThreadBatch {
record Result(int orderId, boolean virtual) {}
static Result loadOrder(int orderId) throws InterruptedException {
// 这里用休眠模拟阻塞式数据库或网络 I/O。
Thread.sleep(20);
return new Result(orderId, Thread.currentThread().isVirtual());
}
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var first = executor.submit(() -> loadOrder(101));
var second = executor.submit(() -> loadOrder(102));
var third = executor.submit(() -> loadOrder(103));
System.out.println(first.get());
System.out.println(second.get());
System.out.println(third.get());
}
}
}Result[orderId=101, virtual=true]
Result[orderId=102, virtual=true]
Result[orderId=103, virtual=true]try结束时会关闭执行器,并等待已提交任务完成。
这个生命周期边界很重要:如果执行器长期存活且提交速度没有限制,虚拟线程本身的低成本仍无法保护下游系统。
真实服务还必须为取消、中断、超时和部分失败制定策略。
观察反向视图的写穿行为
最后一个示例从ArrayList获取首尾元素,并保留它的反向视图。
先修改原列表,再通过视图删除元素,可以看到两个方向共享同一份内容。
import java.util.ArrayList;
import java.util.List;
public class SequencedStops {
public static void main(String[] args) {
var stops = new ArrayList<>(List.of("Paris", "Lyon", "Nice"));
var reverseView = stops.reversed();
System.out.println(stops.getFirst() + " -> " + stops.getLast());
System.out.println(reverseView);
// 反向集合是视图,因此两个方向都能看到修改。
stops.addFirst("Lille");
System.out.println(reverseView);
reverseView.removeFirst();
System.out.println(stops);
}
}Paris -> Nice
[Nice, Lyon, Paris]
[Nice, Lyon, Paris, Lille]
[Lille, Paris, Lyon]reverseView.removeFirst()删除的是原列表的最后一个元素Nice。
如果调用方需要与原列表脱离,应在明确的时刻创建new ArrayList<>(stops.reversed())。
复制只改变集合容器的所有权,不会递归复制其中的可变元素。
陷阱
**修复:**先把每项特性标为正式、预览、孵化或已撤回,再按项目源码级别查对应JEP。 稳定Java 21代码不应出现字符串模板或结构化并发API;确实采用预览时,要把编译器、运行时和测试都绑定到同一JDK版本。
**修复:**为每个独立阻塞任务创建虚拟线程,让调度器管理载体。 用信号量、连接池或速率限制器约束稀缺资源;CPU密集工作则按处理器并行度设计和测量。
**修复:**盘点所有线程局部值及其生命周期,只保留确实属于任务上下文的小值。 大型缓冲区应采用有界共享池或显式所有权,并用真实并发量进行堆内存测试。
**修复:**按部署运行时审查固定。 Java 24的JEP 491消除了几乎所有监视器导致的固定;Java 25应按语义选择锁,并通过JFR排查剩余的原生调用、类加载或初始化固定,而不是机械改写。
**修复:**由当前模块拥有的闭合层次应列出全部分支并省略default,同时单独决定null策略。
把更具体或带守卫的模式放在无守卫父类型模式之前,并让CI编译所有消费者。
**修复:**把变量命名为reverseView,并在API契约中说明它是活视图。
需要独立结果时显式复制;需要不可修改结果时再用合适的复制或包装API建立该保证。
版本基线与运行时演进
源码、类文件与运行时是三条轴
javac --release 21同时采用Java 21语言规则、面向Java 21记录的标准库API,并生成Java 21可识别的类文件。
本地执行示例得到的类文件主版本是65。
仅在Java 25上运行未带--release的javac,不能证明产物能在Java 21上加载,也不能阻止代码引用Java 22之后加入的API。
第三方依赖另有自己的最低运行时和类文件版本。
--release 21不会把一个只提供Java 25字节码的依赖降级,也不会证明反射、代理、服务加载或原生库在旧运行时行为相同。
迁移验证需要同时检查源码编译、依赖解析,并在实际支持的运行时上执行测试。
预览类文件绑定发布版本
Java 21预览源码需要在编译时同时使用--release 21 --enable-preview,运行时也要提供--enable-preview。
预览类文件会被标记为依赖该发布版本,不能把它当作普通稳定类文件交给另一主版本JVM。
构建脚本中隐藏的预览开关因此属于公开兼容性契约,而不是开发者个人偏好。
结构化并发说明了这种风险。 它在Java 21是第一次预览,到了Java 25仍是第五次预览,并且API形状已经演进;从旧文章复制构造器和子任务访问方式不可靠。 如果生产代码需要长期源码稳定性,应等所需API正式化,或把版本绑定、迁移成本和测试矩阵写入决策记录。
字符串模板更直接地证明预览可能消失。 JEP 430的语法只能代表Java 21的试验设计,不能作为Java 25当前语法教授。 参数化SQL、JSON序列化和上下文相关的HTML转义仍应由各自API完成,普通字符串插值从来不会自动提供这些安全边界。
固定建议必须带运行时版本
Java 21中,虚拟线程在synchronized块或方法内阻塞时可能固定到载体,因此频繁而长时间的此类固定会限制可扩展性。
当时的诊断包括jdk.tracePinnedThreads系统属性和JFR事件,适合定位真正长期阻塞的临界区。
短暂的内存内同步不需要为了虚拟线程而一律改写。
Java 24交付的JEP 491改变了这个结论。
监视器内阻塞的虚拟线程现在几乎都能卸载,jdk.tracePinnedThreads也不再是有效诊断开关;JFR事件仍用于剩余固定情形。
这正是文章以Java 25复核Java 21特性的原因:语言特性保持稳定,不代表JVM实现建议永远不变。
原生代码回调Java后再阻塞、类加载和类初始化仍可能产生固定。 固定不等同于数据竞争或结果错误,它是可扩展性风险;应通过目标运行时上的观测确认其频率和持续时间。 锁的选择仍以正确性、可读性、可中断获取和条件等待需求为主。
虚拟线程不是资源许可证
一个虚拟线程代表可调度任务,不代表数据库连接、文件描述符、远端请求额度或堆内存已经可用。 如果每个请求同时访问只能承受40个调用的后端,那么创建40,000个虚拟线程只是把等待位置移动到系统内部。 明确的信号量或连接池能表达真正的容量,并为超时和拒绝提供可测试的边界。
关闭每任务执行器会等待任务结束,但不会自动设计取消策略。 调用方仍要决定首个失败是否取消兄弟任务、超时是否中断操作,以及阻塞库是否响应中断。 这些需求与结构化并发试图解决的问题有关,但稳定Java 21代码不能假装其预览API已经固定。
穷尽检查仍有二进制边界
密封层次的模式switch在编译时根据当时可见的允许直接子类型判断穷尽性。
省略default能让新增分支在消费者重新编译时变成明确错误,比静默旧逻辑更安全。
但已经发布的旧消费者类文件不会自行重新编译,因此部署流程仍需二进制兼容性测试。
编译器会为没有兜底标签的穷尽switch准备异常路径,避免运行时出现新类型时无分支却正常结束。
这是一道最后防线,不是版本治理方案。
领域模型、消费者和部署单元的升级顺序仍应由API兼容性策略明确规定。
延伸阅读
5个问题 · 1道输出预测题 · 1道找错题