Java 21 升级判断:虚拟线程的收益与预览 API 的边界

区分 Java 21 中已正式交付的虚拟线程和模式匹配,以及仍处于预览阶段的结构化并发,围绕下游容量、超时、pinning 和依赖兼容制定升级判断。

·7 min
大量轻量任务在少数载体线程上调度,访问数据库前经过并发额度闸门。

服务若把大部分时间花在等待数据库或 HTTP 响应上,Java 21 的虚拟线程值得评估。CPU 已被压缩、编解码或计算任务占满时,换线程模型不会增加处理器算力。两类负载应分开测量。

Java 21 同时包含正式特性和预览 API。虚拟线程与 switch 模式匹配已经交付,结构化并发仍处于预览阶段。运行时升级与采用预览 API 是两项独立决定。

虚拟线程改变等待成本,容量限制仍在

传统线程池经常同时承担两件事:复用昂贵的平台线程,以及限制并发请求数量。切换到每任务一个虚拟线程的执行器后,第一项需求减轻了,第二项约束不能丢。

假设数据库只允许这个服务占用一部分连接,工作线程再多,也无法让数据库同时完成无限多次查询。请求可能改在连接池里排队,堆内存里还保留着各自的参数和上下文。此时平台线程数下降,但尾延迟和内存可能变差。

JEP 444 建议按任务创建虚拟线程,不要再为复用虚拟线程而建立池。需要限制稀缺资源时,使用独立的并发控制。具体调度与 JDK 21 的限制见 虚拟线程 JEP

下面是 Java 21 的局部示例。许可数 24 只是演示值,应按数据库预算和压测决定。Callable 代表一次已设置驱动超时的下游操作:

import java.util.concurrent.Callable;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

final class DownstreamGate {
    private final Semaphore permits = new Semaphore(24);

    <T> T call(Callable<T> operation) throws Exception {
        if (!permits.tryAcquire(40, TimeUnit.MILLISECONDS)) {
            throw new RejectedExecutionException("downstream saturated");
        }
        try {
            return operation.call();
        } finally {
            permits.release();
        }
    }
}

调用方在应用级复用一个 DownstreamGate,不要为每次请求创建新的门限对象。40 毫秒只限制等待许可的时间,不能限制 operation.call() 的执行时间。数据库查询超时、HTTP 超时与请求总截止时间还需分别传递和执行。

这个门限也没有解决入口无限排队。若瞬时进入的任务数很大,应在接受工作时限制总在途量,并定义拒绝响应。允许更多线程等待,是一种资源取舍,不是过载保护。

在 Java 21 上调查 pinning,不做全局替换

JDK 21 的虚拟线程在某些阻塞场景下无法从承载它的平台线程卸载,包括持有 synchronized 监视器时阻塞,以及涉及本地调用的部分路径。这会限制并发扩展能力,通常不改变程序的业务正确性。

应该先确认是否存在频繁且持续时间长的 pinning。一个只访问内存、持锁时间很短的临界区,与一个持锁等待远程响应的方法,不应得到同样的整改优先级。可在受控诊断环境使用 JFR 的相关事件或 -Djdk.tracePinnedThreads=full 辅助定位,再决定是否改变锁或调用位置。

JDK 24 通过 JEP 491 改进了 synchronized 场景,见 OpenJDK 团队的版本说明。这项后续变化不能用来描述 Java 21 的行为,也不能概括为“所有 pinning 已消失”。遇到供应商回移补丁时,需再核对具体发行版。

迁移还应检查每线程上下文。过去缓存在线程本地变量里的大对象,如果随着虚拟线程数量增长而创建,可能带来新的内存压力。日志上下文、追踪代理和线程诊断工具也需要验证,尤其不能把“平台线程很少”当成系统负载很低。

结构化并发先隔离在可替换的边界内

一个页面同时读取账户资料和订单信息,其中一个任务失败后,另一个任务是否继续运行?请求已经超时,后台子任务是否仍占用连接?结构化并发关注这些任务生命周期问题。

Java 21 的 StructuredTaskScope 可以表达共同作用域里的子任务和失败处理,但它仍是预览 API。使用时需要相应的编译与运行参数,后续版本也可能改变接口。应以 Java SE 21 的 API 文档为准,不要拿新版本示例直接套入 21。

对不接受预览特性的服务,升级 Java 21 仍然有价值。团队可以继续采用现有并发工具,把取消、超时和异常传播做完整。愿意试用结构化并发的团队,则应把它封装在小范围的适配层,避免预览类型扩散到公共业务接口。

取消也需要下游配合。向线程发出中断信号,不等于远程请求已撤销;远端可能已经提交操作。涉及写入时,还需要业务幂等和结果查询,不能由一个任务作用域承担分布式一致性。

模式匹配适合清楚表达有限状态

相比线程模型,模式匹配的价值更容易在代码评审中判断。对于返回值存在几个明确变体的接口,可以用封闭类型表达状态,并让编译器检查分支是否覆盖完整:

sealed interface Lookup permits Found, Missing {}
record Found(String title) implements Lookup {}
record Missing(String slug) implements Lookup {}

static String describe(Lookup result) {
    return switch (result) {
        case Found found -> found.title();
        case Missing missing -> "Not found: " + missing.slug();
    };
}

这是 Java 21 语言片段,放在合适的类或源文件结构中使用;调用约定要求 result 非空。它展示了结果状态与分支的关系,不承担网络错误或鉴权失败的完整建模。正式特性状态可对照 Java 21 语言变更

如果原来的几个 if 已经清晰,没有必要为了使用新语法引入一整套类型层次。更值得改写的是那些把“未找到”“调用失败”“无权访问”都塞进一个空值的接口。

升级计划按可归因的变化拆开

先在不改变线程模型的情况下升级运行时,观察依赖兼容、GC、启动和诊断链路;然后选择等待占比较高、下游预算明确的路径评估虚拟线程。语法重构和预览 API 试用可以独立推进。

比较时固定请求分布和并发压力,同时记录吞吐、尾延迟、拒绝量、堆占用与数据库等待时间。新方案若只是在入口接受了更多工作,却让更多请求在下游超时,就不能按吞吐数字宣布成功。回退需要保留旧运行时兼容的制品;用 Java 21 字节码编译的新包,不能指望直接放回旧 JDK 启动。