Java 21特性

Java 21的稳定语言与并发特性、预览边界,以及在Java 25运行时审查旧示例时必须识别的版本漂移。

难度 进阶 时长 标准深度约12分钟
版本 Java 25 LTS
what

Java 21正式交付了虚拟线程、记录模式、模式switch和有序集合接口,让阻塞式并发、数据解构和有顺序的集合操作更直接。

trap

Java 21发布说明中的特性不都属于稳定API;结构化并发与字符串模板当时仍是预览特性,后者后来被撤回。

fix

用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分支必须按从具体到一般的顺序排列,否则无守卫分支会支配加急分支。

Java
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 delivery

Delivery只有两个允许的实现,两个记录分支已经覆盖所有非空值。 显式case null补上输入边界,所以这里不需要含糊的default。 这段程序使用javac --release 21 -Xlint:all编译并运行,无需预览开关。

为每个阻塞任务创建虚拟线程

这个批处理为每个订单读取创建一个虚拟线程,并通过Future.get()按固定顺序收集结果。 Thread.sleep()只模拟数据库或网络等待;输出中的virtual=true验证任务确实在虚拟线程上运行。

Java
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获取首尾元素,并保留它的反向视图。 先修改原列表,再通过视图删除元素,可以看到两个方向共享同一份内容。

Java
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道找错题

下一篇 Virtual threads 即将上线 记录类 密封类 Collections 即将上线 Concurrency 即将上线
复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?