JDK 25 上的虚拟线程:锁不再卡住载体线程,瓶颈却转移到了下游

2026-07-31 21 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

JDK 24 消除了虚拟线程进入 synchronized 监视器时引发的载体线程固定问题。对准备迁移到 JDK 25 LTS 的团队来说,这清除了一类曾经阻碍 Java 21 生产落地的故障,但并不意味着并发从此没有上限。虚拟线程能够廉价地等待,数据库连接、HTTP 配额、文件描述符和第三方服务容量却不会随之增加。

生产环境中的核心问题因此发生了变化:过去要排查“载体线程为什么被固定”,现在更常见的问题是“应用为什么同时向下游发出了数万个请求”。

JDK 24 究竟改变了什么

虚拟线程执行阻塞操作时,JVM 通常可以卸载虚拟线程,把有限的载体线程交给其他任务。Java 21 的一个重要例外是:虚拟线程在 synchronized 代码块或方法中阻塞时,可能固定其载体线程。

当大量请求都走到这条路径,载体线程会被逐渐占满。应用表面上创建了许多虚拟线程,实际却没有足够的载体线程继续执行它们。这也是 Netflix 等团队在 Java 21 上谨慎推进虚拟线程的重要原因之一。

JDK 24 改进了监视器实现,使虚拟线程即使持有 Java 监视器,也能在需要等待时从载体线程上卸载。JDK 25 LTS 继承了这一变化,因此仅仅为了避免这类固定而把所有 synchronized 改写成 ReentrantLock,通常已不再必要。

边界仍然要说清楚:这项变化针对的是监视器相关固定,并不保证所有本地调用、外部函数调用或 JVM 无法安全卸载的执行路径都不会固定载体线程。升级 JDK 可以删除一类瓶颈,不能替代压测和运行时观测。

锁的问题退场后,资源饱和开始显形

传统线程池同时承担了两个职责:复用昂贵的平台线程,以及用池大小间接限制并发。换成“每任务一个虚拟线程”之后,第一个职责不再重要,第二个职责也随之消失。

假设服务过去使用 200 个工作线程,并且每个请求都会访问数据库。即使代码没有显式限流,同时竞争数据库的请求通常也不会远超 200。迁移后,应用可以迅速创建 20,000 个虚拟线程;如果这些任务都等待一个只有 50 条连接的连接池,系统可能出现以下现象:

  • 连接池等待队列膨胀,请求延迟从数据库查询时间变成排队时间。
  • HTTP 下游收到突发流量,开始返回 429503 或触发超时。
  • 大量等待任务继续占用堆内存、请求上下文和套接字。
  • 超时触发重试,重试又扩大下游压力,形成正反馈。
  • 吞吐量没有提升,但尾延迟、内存占用和故障恢复时间明显恶化。

这不是虚拟线程的缺陷。它只是让应用更容易表达高并发,也让原本藏在线程池大小中的容量限制失效了。新的设计原则是:虚拟线程负责等待,资源边界由应用显式表达。

可运行示例:用信号量限制稀缺资源

下面的示例可以直接在 JDK 25 上编译运行。它创建 1,000 个虚拟线程模拟请求,但只允许 32 个任务同时进入“下游调用”。实际项目中,应把 DOWNSTREAM_LIMIT 调整为数据库连接池容量、合作方并发配额或压测得到的安全值。

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

public final class BoundedVirtualThreads {
    private static final int REQUESTS = 1_000;
    private static final int DOWNSTREAM_LIMIT = 32;
    private static final Semaphore permits = new Semaphore(DOWNSTREAM_LIMIT);

    private static String callDownstream(int requestId) throws InterruptedException {
        if (!permits.tryAcquire(Duration.ofSeconds(1))) {
            return "rejected:" + requestId;
        }

        try {
            Thread.sleep(Duration.ofMillis(100));
            return "ok:" + requestId;
        } finally {
            permits.release();
        }
    }

    public static void main(String[] args) throws Exception {
        long started = System.nanoTime();
        int succeeded = 0;
        int rejected = 0;

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<java.util.concurrent.Future<String>> futures = new ArrayList<>();
            for (int i = 0; i < REQUESTS; i++) {
                int requestId = i;
                futures.add(executor.submit(() -> callDownstream(requestId)));
            }

            for (var future : futures) {
                if (future.get().startsWith("ok:")) {
                    succeeded++;
                } else {
                    rejected++;
                }
            }
        }

        long elapsedMs = Duration.ofNanos(System.nanoTime() - started).toMillis();
        System.out.printf(
                "success=%d rejected=%d elapsedMs=%d maxConcurrent=%d%n",
                succeeded, rejected, elapsedMs, DOWNSTREAM_LIMIT);
    }
}

保存为 BoundedVirtualThreads.java 后运行:

java -version
javac BoundedVirtualThreads.java
java BoundedVirtualThreads

这里有三个值得保留到真实服务中的细节:

  1. 不要再创建一个固定大小的虚拟线程池。虚拟线程不是需要复用的稀缺资源,真正需要限制的是下游调用。
  2. 使用有超时的 tryAcquire,不要让等待队列无限增长。拿不到许可时,可以返回过载响应、降级结果或进入有界队列。
  3. 必须在 finally 中释放许可,否则异常路径会永久吃掉容量。

如果一个请求依次访问数据库、支付服务和对象存储,应为三种资源分别设置边界,而不是用一个全局信号量把它们混在一起。全局限制虽然简单,却可能让缓慢的对象存储请求占满许可,连健康的数据库请求也无法执行。

压测时不要只看吞吐量

来源提到的公开基准测试所支持的关键方法,不是照搬某个并发数字,而是比较升级前后不同限流策略下的失效方式。由于摘要没有给出具体测试参数和结果,生产团队可以按相同思路建立自己的基线。

至少要记录以下指标:

  • 请求吞吐量,以及 p50p95p99 延迟。
  • 活跃虚拟线程数量和任务排队时间。
  • 数据库连接池的活跃连接、空闲连接、等待者数量和获取超时。
  • HTTP 下游的并发请求、超时、4295xx 和重试次数。
  • 堆占用、GC 暂停、套接字数量和文件描述符使用率。
  • 限流许可的使用量、等待时间和拒绝次数。

可以在压测期间启动一段 Java Flight Recorder 记录。把 PID 替换成目标 Java 进程号:

jcmd PID JFR.start name=virtual-threads settings=profile duration=60s filename=virtual-threads.jfr
jcmd PID Thread.dump_to_file -format=json threads.json

线程转储适合确认大量虚拟线程究竟在等待连接池、HTTP 响应还是应用锁;JFR 则更适合关联 CPU、分配、阻塞和固定事件。不同 JDK 构建提供的事件可能有差异,因此应在实际部署的 JDK 25 发行版上验证采集配置。

迁移到 JDK 25 的实施顺序

一次稳妥的迁移不应止于把基础镜像中的版本号改成 25。可以按下面的顺序推进:

  1. 升级测试环境,确认依赖、agent、APM 和启动参数支持 JDK 25。
  2. 保留现有流量模型,比较平台线程与虚拟线程下的吞吐量和尾延迟。
  3. 盘点每一种稀缺资源,包括连接池、外部 API 配额、文件描述符和内部队列。
  4. 在资源入口设置独立的并发边界,并为等待设置超时。
  5. 明确定义过载行为:快速失败、返回 429、降级,或者进入容量确定的队列。
  6. 在压测中加入慢下游、连接池耗尽、超时和重试场景,而不只是正常响应。
  7. 通过金丝雀发布观察拒绝率、队列时间、连接池等待和 p99,再逐步扩大流量。

JDK 24 解决了虚拟线程落地过程中的一个关键运行时障碍,JDK 25 LTS 因而成为更现实的生产目标。但虚拟线程带来的不是无限容量,而是更直接的并发模型。升级后的工程重点,应从“用线程池控制一切”转向“让虚拟线程承载等待,并在每个有限资源前明确设置预算”。


相关推荐