Ohhnews

分类导航

$ cd ..
InfoQ Java原文

JDK 24之后的虚拟线程:生产环境Java发生了哪些变化

#虚拟线程#jdk#java#性能优化#生产环境

关键要点

  • JDK 24 的 JEP 491 移除了与监视器(monitor)相关的载体线程钉扎问题,这一直是 Java 21 上采用虚拟线程时,同步阻塞代码风险较高的原因。残余钉扎仍然存在于原生帧、类加载以及 Linux 上的本地文件 I/O 中,因此 JDK 飞行记录器(JFR)的 jdk.VirtualThreadPinned 事件仍是上线检查清单的一部分。
  • 主要生产风险已从载体线程耗尽转变为下游资源耗尽。一旦虚拟线程移除了 servlet 线程上限,连接池、速率限制、文件描述符和下游服务就成了真正的并发边界。
  • 在虚拟线程下,ThreadLocal 缓存会悄悄失效。在配套基准测试中,一个 ThreadLocal.withInitial() 缓存在同一工作负载下于平台线程中初始化了 200 次,在虚拟线程中却初始化了 443,267 次,相差 2,216 倍,并首先以无法解释的 GC 压力形式出现。
  • Scoped Values 在 JDK 25 中通过 JEP 506 定稿,是应用自有请求上下文的正确替代方案。它避免了 InheritableThreadLocal 传播中的意外,并会自动传播到 StructuredTaskScope 的子任务。定稿时有一个 API 变更:ScopedValue.orElse(null) 不再被允许。
  • 虚拟线程简化了阻塞式请求-响应服务,但并不能替代响应式背压。Spring MVC 加虚拟线程是阻塞式 I/O 服务的强力默认选择;Spring WebFlux 仍然是流式、SSE、WebSocket 和背压敏感系统的正确选择。

引言

在 Spring Boot 3 服务中启用虚拟线程只需一行配置变更。但在变更之后的一周内会发生什么,差异可能很大:这取决于依赖栈中有多少同步代码、你对 ThreadLocal 的使用是否假设了线程复用,以及你的连接池是按几十个操作系统线程还是按数万个虚拟线程来配置的。

我们针对高吞吐、I/O 密集型服务评估了虚拟线程的采用。这些服务的大部分墙钟时间都花在等待下游 API 和数据库查询上,而不是计算上。早期观察结果与 JEP 444 的虚拟线程 预测一致:I/O 密集的端点在相同的堆和线程配置下扩展得更好,之前需要仔细调整的线程池上限不再成为约束。

同时出现的是迁移指南没有重点标注的失败模式:不会抛异常的静默缓存失败、在开发环境中正常但在负载下消失的上下文传播,以及任何 JDK 升级都无法单独修复的下游资源瓶颈形态。本文介绍这些观察、它们在运维上的意义,以及应对方法。

相关赞助商

本文引用的数字和仪表盘来自一个公开基准测试。它以一种可控、可复现的形式呈现了同样的失败模式:相同的 JVM 参数、相同的依赖、相同的主机,并明确记录了配置差异。对于 ThreadLocal 场景,实际差异来自线程模型;对于 JDBC 场景,连接池上限被有意调整,以展示瓶颈会转移到哪里。这样做的目的是让任何读者都可以在自己的硬件上重现本文描述的模式,而不必依赖无法共享的生产遥测数据。

你可以在这个 GitHub 仓库 中找到源代码、仪表盘和原始输出。图 1 展示了基准测试拓扑。

[LOADING...]

图 1:基准测试拓扑。(图片来源:作者制作)

下面这个核心效果的量级和形态,来自 c7i.2xlarge(8 vCPU、15 GiB RAM)上运行 Ubuntu 24.04、使用 Temurin 25.0.3 的可控场景:五百个并发用户持续六十秒访问一个会触发后文所述 ThreadLocal 失败模式的端点:

指标平台线程虚拟线程Δ
吞吐量(req/s)3,5947,426+107%
p99 延迟(ms)14753-64%
错误率(%)0.000.00---
SimpleDateFormat ThreadLocal 初始化次数200443,2672,216×

这个端点是一个 I/O 风格请求路径,由 WireMock 下游提供三百毫秒的模拟响应延迟。它并不是虚拟线程的通用吞吐量声明。该端点还带有一个被埋点的 ThreadLocal 缓存,因此可以观察到分配行为,同时请求会走与真实服务相同的 servlet 调度路径。图 2 展示了基准测试窗口期内的并排运行遥测数据。

[LOADING...]

点击此处以上图全尺寸查看

图 2:基准测试窗口期内的并排运行遥测数据,平台模式先运行,虚拟模式后运行。(图片来源:作者制作)

在这张图中,吞吐量(顶部)在平台模式下峰值约为每秒四千个请求,而虚拟模式下约为每秒一万个请求。此外,在 p99 延迟(第二张图)方面,两种模式在负载下都进入数百毫秒区间,虚拟模式(橙色)始终低于平台模式(绿色),p999(蓝色)则在两者之上,这正是持续负载下的预期表现。

堆使用量(第三张图)揭示了一个二阶效应:平台模式(绿色)显示缓慢、平滑的爬升,GC 周期不频繁;虚拟模式(黄色)则呈现更剧烈的锯齿状。虚拟模式分配更快,因为每个请求都会重建自己的 ThreadLocal 缓存(图 3 量化了同一现象)。垃圾收集(GC)暂停时间(底部)也证实了这一点:虚拟模式在压力运行期间触发了大约 4.5 毫秒的 GC 集群,而平台模式则保持安静。

这里的结论是双重的:虚拟模式做了更多分配工作,同时在吞吐量和延迟上仍然胜出。GC 每次暂停保持在 5 毫秒以下,即使做更多工作也是如此,因此它不会成为新的瓶颈。但生产上线应该预期更高的 GC 活动,其幅度与分配压力成正比。

图 3 解释了这种压力来自哪里。JDK 24 移除了早期虚拟线程上线中最为显眼的监视器相关失败模式。JDK 25 是第一个包含 JEP 491 和定稿版 Scoped Values 的长期支持(LTS)版本,对于在 2026 年于 JDK 21 LTS 和 JDK 25 LTS 之间做选择的生产团队来说,它是自然而然的目标。下面这张地图展示了当前风险真正所在:哪些问题已经解决,哪些问题无论 JDK 版本如何都需要深思熟虑的设计决策。

风险特征发生了转移,但没有消失

JDK 21 中的主要失败模式是同步块导致的载体线程耗尽。虚拟线程共享一个由 fork-join 调度器管理的载体线程池,其并行度等于可用处理器数量。在 JDK 21 中,进入同步块的虚拟线程即使随后阻塞在 I/O 上,也无法从载体线程上卸载。载体线程会一直被占据。如果足够多的虚拟线程同时涌入同步块,所有载体线程都会变得不可用,调度器就无法再为任何剩余的虚拟线程分派工作。系统因此停滞。

这既不是假设,也不是某一家组织独有的问题。Netflix 的 JVM 生态系统团队在 Java 21 上使用嵌入式 Tomcat 的多个 Spring Boot 3 服务中,准确记录了这一失败模式。在我们自己的 JDK 21 评估中,也看到了同样的形态:日志或指标依赖中的同步块在突发争用下耗尽了载体线程池。症状是实例在 JVM 仍存活的情况下停止提供服务,同时处于 CLOSE_WAIT 状态的 socket 数量持续增加。当工程师执行标准线程转储时,看到的是一个看似空闲的 JVM:没有等待中的监视器,也没有明显的阻塞。运行 jstack 命令得到的输出中不会出现虚拟线程栈。改用 jcmd 命令的 Thread.dump_to_file -format=json 选项后,可以看到数千个虚拟线程已创建但无法被调度,所有载体线程都钉扎在 Tomcat 的连接处理代码中。

他们的文章 Java 21 Virtual Threads -- Dude, Where's My Lock? 是关于钉扎如何产生一种看似“沉默”而不是“错误”的死锁的最清晰的公开发布记录。

JEP 491,Synchronize Virtual Threads without Pinning,在 JDK 24 中交付,重新设计了监视器所有权跟踪机制,使虚拟线程通过自身身份而不是载体线程身份被识别。现在,虚拟线程可以在同步块内卸载、进入 Object.wait(),并在唤醒后重新获取监视器,而不会钉扎载体线程。JEP 491 移除了导致 Netflix 这类停顿的特定监视器钉扎机制;对大多数服务来说,这意味着主要采用障碍已经消失,升级 JDK 就足够了。

仍然存在的残余钉扎场景范围更窄,也不太可能在典型应用代码中产生级联停顿,如下表所示:

场景JDK 21JDK 24+(及 25 LTS)
同步代码内的阻塞 I/O钉扎载体线程JEP 491 后不再钉扎
同步代码内的 Object.wait()钉扎载体线程JEP 491 后不再钉扎
原生方法帧(JNI / FFM)钉扎载体线程仍然钉扎
类加载 / 类初始化器钉扎载体线程仍然钉扎
文件 I/O(本地磁盘,Linux)钉扎载体线程仍然钉扎
诊断选项-Djdk.tracePinnedThreadsJFR jdk.VirtualThreadPinned(JDK 25 默认 20 ms 阈值)

其中两个残余场景在实践中值得关注。原生帧钉扎出现在调用带有 Java 回调的原生库的应用中。原生代码可能持有原始栈指针,因此无法保证卸载是安全的。对大多数 Web 服务来说,这种情况很少见,但使用某些加密提供方或专用 I/O 库的应用应在假设钉扎已消失之前,先用 JFR 验证。

文件 I/O 是更常见的意外。文件 I/O 在 Linux 上仍然会钉扎。JDK 中还没有生产就绪的 io_uring 集成。JDK 邮件列表讨论中的报告指出,内核版本兼容性和容器运行时支持是实际约束。基于 Project Panama 的第三方库(例如实验性的 JUring)通过直接绑定 io_uring 可提供约四倍的读取吞吐量,但它们不在官方代码树中。

要检测残余钉扎,请配置 JDK 飞行记录器(JFR)以发出 jdk.VirtualThreadPinned 事件,并在生产上线前运行负载测试套件。JDK 21 中存在的 -Djdk.tracePinnedThreads 系统属性已在 JDK 24 中移除。

ThreadLocal:两种静默到达的失败模式

监控钉扎是正确的第一步。第二个问题需要审计代码,而不是配置可观测性。它不会产生异常、不会产生警告,也不会出现明显的性能下降,直到你去专门查看它。

ThreadLocal 类设计于 1998 年,面向池中长生命周期平台线程。一个两百线程的线程池可以运行数小时或数天。每线程存储只分配一次,然后被复用数千次,这是一种可接受的模式。虚拟线程是短生命周期的,并且不会跨请求复用。每个新虚拟线程都会得到一个新的 ThreadLocalMap 实例。由此产生两件事,值得作为两种独立的失败模式来理解:静默停止缓存的缓存,以及在派生子任务中变成 null 的 InheritableThreadLocal 上下文。下面分别描述。

静默停止缓存的缓存

一种常见的生产模式是使用 ThreadLocal.withInitial() 为每个线程惰性分配一个创建成本高昂且非线程安全的对象,并在请求之间复用。SimpleDateFormat 类是最经典的例子。与 JDBC 相关的对象以及某些序列化实例也遵循同样的模式。JSON 序列化器、可复用字节缓冲区、ObjectMapper 类实例,以及任何假设线程池规模较小且稳定、线程生命周期较长的库级缓存,也都使用同样的模式。

在虚拟线程下,每次任务都会发生分配。任务完成时,结果就被丢弃。ThreadLocal 看起来工作正常,从不抛异常,只是每个请求都会重新计算值。

在我们的虚拟线程评估中,这两种失败模式都出现了。缓存未命中案例首先表现为 GC 压力升高,以及那些除了线程模型标志之外没有发生任何变更的服务上分配速率持续攀升。这种症状足够泛化,如果你不去深究,很可能会被归结为“虚拟线程本身开销更高”。

让原因变得明显的最快方法是对 ThreadLocal 本身进行埋点。继承它并在每次调用 initialValue() 方法时递增一个 Micrometer 计数器,缓存未命中率就变成可直接观测的指标。配套基准测试正是将这种埋点接入到一个可控场景中,因此无需访问生产遥测数据就能看到差异。

考虑这个六十秒的运行:五百个并发用户访问一个使用缓存的端点:

# 平台模式(端口 8080)
threadlocal_initializations_total{cache_name="SimpleDateFormat"} 200

# 虚拟模式(端口 8081),相同工作负载,相同 JVM 参数
threadlocal_initializations_total{cache_name="SimpleDateFormat"} 443,267

平台线程为每个 Tomcat 池化线程初始化一次 ThreadLocal,然后永远缓存该值,这正是应用代码所假设的行为。虚拟线程是短暂的,每个 HTTP 请求都运行在全新的虚拟线程上,因此“缓存”的 ThreadLocal 会在每个请求上被分配。比率为 2,216 倍。这两个计数器是在测试结束后立即从每个实例的 /actuator/prometheus Spring Boot Actuator 端点并排捕获的。

图 3 展示了在 threadlocal-stress 场景中 InstrumentedThreadLocal.initialValue() 的调用速率。

[LOADING...]

图 3:InstrumentedThreadLocal.initialValue() 在 threadlocal-stress 场景中的调用速率。(图片来源:作者制作)

Y 轴是对数刻度(以 10 为底)。左侧的蓝色平台是平台模式运行,稳定在每秒约六次操作,这正是应用代码在 ThreadLocal 中缓存 SimpleDateFormat 时所假设的行为。右侧的橙色平台是同一代码在相同负载下的虚拟模式运行,持续达到每秒约 9,800 次操作,同一缓存键的分配压力高出三个数量级。由于虚拟线程不被复用,“缓存”对象在每个请求上都被静默重建。这是本文描述的核心失败模式;指标是 threadlocal_initializations_total,基准测试仓库中包含对应的实时面板。

在同一运行中,性能后果是吞吐量提升了 107%,p99 延迟下降了 64%,两侧都没有错误。平台模式的吞吐量受到两百线程 Tomcat 池在内部排队工作负载的限制。虚拟线程移除了这一上限。延迟下降是因为尾部延迟中的排队部分消失了。

派生子任务中出现 null 的 InheritableThreadLocal 上下文

第二种失败模式出现在我们的事故时间线中。追踪上下文、安全主体和请求关联 ID 会正确传播到处理请求的虚拟线程中,但当该线程通过 StructuredTaskScope.fork() 派生子任务时,这些上下文会悄悄消失。子线程读到的是 null。

这不是回归,而是 StructuredTaskScope 创建线程方式的既定行为。但这类问题在本地测试中通常能通过,因为本地并发度低,上下文传播很少被真正触发;而在生产中,它恰好会在你最需要追踪上下文来理解一个事故时失败。

引入虚拟线程的 JEP 444 直接警告了这个问题。虚拟线程出于向后兼容性支持 ThreadLocal,但由于虚拟线程可能数量庞大,你应该经过深思熟虑后再使用 ThreadLocal,并且不应该用它来池化昂贵资源。JDK 团队也因此在 java.base 中移除了许多内部对 ThreadLocal 的使用。

新的主导失败模式:瓶颈转移到了下游

JEP 491 移除了监视器钉扎作为虚拟线程上线最可能遇到的第一个失败。在 2025 年的生产报告中,在我们评估的服务中,升级后最一致的模式是下游资源耗尽,包括连接池上限、文件描述符限制、下游 API 速率限制和数据库查询队列。吞吐量提升确实如预期到来。下一个运维告警几乎总是涉及某个下游组件被填满。

当虚拟线程移除了此前限制并发度的 Tomcat 池上限时,下游的瓶颈——无论它在哪里——就会开始承受压力。配套基准测试中的 JDBC 场景直接复现了这种形态。使用相同的五百个并发用户和相同的六十秒运行,两种模式都通过 HikariCP 写入同一个 Postgres 实例,平台模式连接池为二十,虚拟模式连接池为五十:

指标平台(池=20)虚拟(池=50)Δ
吞吐量(req/s)6241,539+147%
p99 延迟(ms)1,363613-55%
错误率(%)36.9137.08相同

吞吐量和延迟的提升是真实的,但相同约 37% 的错误率才是真正关键的观察。移除线程模型的上限并没有移除瓶颈,而是移动了瓶颈。两种模式在这个加载强度下都饱和了各自的 HikariCP 连接池。平台模式的池更早填满(二十个连接,两百个 Tomcat 线程竞争)。虚拟模式的池在五十个连接和无限虚拟线程竞争下更晚填满。但两个池最终都会填满。

图 4 展示了 JDBC 场景中的 HikariCP 行为。

[LOADING...]

图 4:JDBC 场景中的 HikariCP 行为。(图片来源:作者制作)

顶部面板显示活动连接:平台模式(蓝色)饱和在其配置上限二十,虚拟模式(橙色)饱和在其上限五十。两者都在负载窗口期间达到最大值并保持。底部面板显示等待获取连接的队列。当池被填满时,等待一个不存在连接的请求队列在两种模式下都飙升至约 450。移除线程模型的上限并没有移除瓶颈。恰恰是线程模型的上限将瓶颈从 Tomcat 线程池转移到了 HikariCP 连接池,这正是本文的核心论点所预测的。

缓解方案是虚拟线程带来的概念反转。基于池的并发此前通过同一种机制(线程池)同时表达了工作隔离和资源边界。虚拟线程将它们解耦。工作隔离变得无界(每个请求都有自己的虚拟线程)。资源边界变成每个共享资源处显式的 Semaphore 实例。

这一模式由 Oracle 的 Nicolai Parlog 在使用虚拟线程管理吞吐量中明确推荐。他主张在每个受限下游资源处池化许可(permit),而不是池化线程:

$ java
private static final Semaphore DB_PERMITS = new Semaphore(50);

String queryDatabase(String id) throws InterruptedException {
    DB_PERMITS.acquire();
    try {
        return jdbcTemplate.queryForObject(
                "SELECT name FROM items WHERE id = ?",
                String.class, id);
    } finally {
        DB_PERMITS.release();
    }
}

这是虚拟线程无处不在、每个共享资源处都有信号量的新模型。这比“用虚拟线程替代线程池”更深一层。它要求应用代码在以前由线程池隐式完成的地方,显式进行资源边界控制。

请注意,信号量限制的是并发度。它们不能替代背压语义。如果你的下游资源需要生产者在消费端落后时放慢速度,仅靠信号量是不够的;它只会排队或拒绝。还需要注意,截至 2026 年中,HikariCP 本身并没有合并任何虚拟线程专属修复。将内部同步块替换为 ReentrantLock 的社区 PR(HikariCP #2055)已被关闭。维护者明确选择等待 JEP 491,而不是维护并行的锁策略。在 JEP 491 之后,HikariCP 的瓶颈是连接上限,而不是锁。仅在 JDK 24 之前,虚拟线程下出现过 Hikari 加 Logback 初始化死锁的问题,见 Issue #2293。该问题通过升级到 JDK 24+ 解决,因为 JEP 491 完全绕开了这个死锁。

Scoped Values:JDK 25 定稿,值得现在采用

图 5 展示了上面讨论的两种 ThreadLocal 失败模式(左列)与对应 ScopedValue 替代方案(右列)的映射。

[LOADING...]

图 5:虚拟线程中 ThreadLocal 与 Scoped Values 的对比(图片来源:作者制作)

在上面一行,每个请求重新初始化缓存对象的问题,通过在请求作用域内绑定一次值来解决。在下面一行,通过 StructuredTaskScope.fork() 传播 null 上下文的问题,由 ScopedValue 在绑定作用域内派生子线程时自动继承来解决。本节其余部分讨论迁移机制;这张图是概要视图。

JEP 506,Scoped Values,在经历五轮预览后于 JDK 25 定稿。ScopedValue 在其绑定作用域内是不可变的,结构化绑定到创建它的代码块,并会自动传播到 StructuredTaskScope 的子线程,无需复制。它解决了上文描述的两种 ThreadLocal 失败模式。因为上下文传播是显式且结构化的,所以不会在每个虚拟线程上重新初始化。同样,因为绑定是按设计继承的,派生子任务中也不会出现 null。

$ java
// 之前:使用 InheritableThreadLocal 保存请求上下文
private static final InheritableThreadLocal<RequestContext> CTX =
        new InheritableThreadLocal<>();

void handleRequest(Request req) {
    CTX.set(new RequestContext(req.userId(), req.traceId()));
    try {
        processRequest();
    } finally {
        CTX.remove(); // 在错误路径上很容易遗漏
    }
}
// 通过 StructuredTaskScope 派生的子线程从 CTX 中读到 null。

// 之后:使用 ScopedValue(JDK 25,JEP 506)
private static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();

void handleRequest(Request req) {
    ScopedValue.runWhere(CTX,
            new RequestContext(req.userId(), req.traceId()),
            this::processRequest);
    // 上下文在代码块退出时自动清除,不需要 finally。
}
// 通过 StructuredTaskScope.fork() 派生的子线程自动继承 CTX。

InheritableThreadLocal 迁移到 ScopedValue 需要刻意重构,因为读取 API 不同,但在大多数应用代码中是直接的。绑定点变成请求处理程序的入口,而不是字段设置操作。调用方像以前一样通过 CTX.get() 读取。对于追踪框架和安全上下文传播,scoped values 是 JDK 25 上应用自有请求上下文最强大的内置选项。

定稿时有一个语义变更:ScopedValue.orElse(null) 不再被允许。应改用 orElse(某个非 null 默认值) 或先调用 isBound()。针对预览 API 编写的代码可能需要做这个小的调整。

对于那些真正需要每线程复用的缓存模式,修复方式不同。使用有界固定大小 ExecutorService 和平台线程来执行需要缓存对象的工作。虚拟线程保留给请求中 I/O 密集的部分。

WebFlux 迁移决策

在 Spring Boot 3.2+ 中设置 spring.threads.virtual.enabled=true 并不会改变 Spring WebFlux 应用的请求处理模型。WebFlux 运行在 Netty 的事件循环上,它独立于 Tomcat 的线程池管理自己的非阻塞线程模型。所以这个虚拟线程属性配置在 Tomcat 和 Jetty 上生效,Netty 上不生效。实际上,这个好处适用于 servlet 容器的请求处理(如 Tomcat 或 Jetty),而不适用于基于 Netty 的 WebFlux 请求处理。

注意,spring.threads.virtual.enabled 在 Spring Boot 3.5 和 4.0 中仍然是可选的,默认为 false。在 Spring 中启用虚拟线程仍然是一个刻意的决定,而不是静默的默认行为。这一点值得明确说明,因为不少团队设置了这个标志,看到请求仍然运行在 Netty 的 reactor 线程上,就得出结论说虚拟线程没有生效。他们“虚拟线程没有参与”的判断是对的,他们“该配置没有效果”的判断也是对的。这正是预期行为,而不是 bug。

因此迁移问题在前面:这个服务是否应该从 WebFlux 迁移到 Spring MVC?答案取决于该服务当初采用响应式编程的原因。

那些主要为了在规模化场景下避免阻塞 I/O 而选择 WebFlux 的服务,有最充分的迁移理由。在 JDK 24 及以后的虚拟线程下,阻塞式 JDBC 调用、同步 HTTP 客户端和标准 Jakarta Persistence(JPA)查询都可以在没有响应式编程模型的情况下获得可接受的扩展性。代码变得更简单,堆栈可读,调试行为也正常。控制器层从 Mono<T> 改为 TFlux<T> 改为 List<T>,Reactor 的 Mono/Flux.flatMap() 操作符链变成顺序方法调用。以前用响应式 zip 表达的扇出工作,现在可以用 StructuredTaskScope 或有界执行器表达为并发阻塞调用。

数据库层需要更多思考。R2DBC 必须被移除,换成 JDBC 或 JPA。对于那些因为相信 R2DBC 在规模化下会优于 JDBC 而选择它的服务,诚实的评估是:JDBC on virtual threads 与 R2DBC 的性能特征会随工作负载、连接池配置和驱动实现而变化。我还没有找到一个公开的、对一般请求-响应工作负载能一锤定音的 apples-to-apples 基准测试。在承诺迁移之前,先在你自己环境中测量是明智的。

关于框架定位的说明

这不是“WebFlux 已过时”的论证。这是一个决策过滤器。如果 WebFlux 只是为了在阻塞 I/O 并发下存活而采用,那么 MVC 加虚拟线程可能会显著简化服务;如果 WebFlux 是为了流式、背压或长生命周期事件流而采用,请保留它。Reactor 团队尚未发布官方建议迁移出 WebFlux 的建议。将阻塞服务迁移到 MVC 是新兴的社区实践,而不是官方认可的迁移路径。

哪些场景应保留 WebFlux

Server-Sent Events、WebSocket 端点,以及作为流式网关且有真实背压要求的服务,都不是这次迁移的好候选。Reactor 的背压模型之所以存在,是因为快速生产者可以压垮慢消费者,而阻塞 I/O 无法表达这种约束。虚拟线程不会改变这个约束。如果你的服务是为了把数据从 Kafka 流式传输到 HTTP 客户端,并且需要在 HTTP 客户端落后时放慢 Kafka 消费者的速度,WebFlux 仍然是正确的工具。迁移案例专门针对那些将响应式编程当作实现 I/O 并发的唯一途径而采用的服务,而不是那些需要响应式语义的服务。

结构化并发的现状

结构化并发值得学习,但还不适合暴露在稳定的公共 API 中。JDK 25 通过 JEP 505 将其作为第五次预览提供。后续预览继续完善 Joiner API。该模型在请求扇出、取消和任务生命周期管理方面是合理的,但团队应预期在最终定稿前,随着 JDK 升级还会发生源码层面的变化。JDK 25 最重要的变化是将 StructuredTaskScope 的公共构造函数替换为通过 StructuredTaskScope.open() 的静态工厂方法,这会破坏针对 JDK 24 预览 API 编写的代码。

Scoped Values 则不同,因为它们在 JDK 25 中已经定稿,可以在生产环境中稳定采用。

实用的采用顺序

这是我们在 Spring Boot 3.2+ 的阻塞请求-响应服务上使用的顺序,目的是在问题进入生产之前让失败模式浮出水面:

从 JDK 25 LTS 开始

JEP 491 在 JDK 24 中提供,在 JDK 25 中默认生效;Scoped Values 在 JDK 25 定稿。对于在 2026 年于 JDK 21 LTS 和 JDK 25 LTS 之间做选择的生产保守型团队,JDK 25 是自然目标。如果因为兼容性原因必须留在 JDK 21,本文的整个风险画像仍然适用,而且钉扎是真实的生产问题;此时应重点研究本文前半部分。

在负载测试前验证线程模型

在一个健康端点上快速检查即可确认虚拟线程是否生效:

$ java
@GetMapping("/health")
public Map<String, Object> health() {
    return Map.of(
            "thread", Thread.currentThread().getName(),
            "virtual", Thread.currentThread().isVirtual()
    );
}

如果出现 virtual=false,说明服务运行在 WebFlux 上,或者属性没有生效。先修复这个问题,再继续。

审计代码和第一梯队依赖中的 ThreadLocal 使用

找出任何用于缓存的 ThreadLocal.withInitial(),以及任何用于上下文传播的 InheritableThreadLocal。用于缓存的,要么转换为有界线程池模式,要么接受缓存是按任务而不是按线程的。用于上下文传播的,应在 JDK 25 上迁移到 ScopedValues

在每个有界资源处添加信号量

这就是“瓶颈转移”一节中描述的概念反转。连接池、有速率限制的下游 API、文件描述符预算,以及任何配置了最大并发上限的资源,在虚拟线程下都需要显式边界。线程池曾经提供的隐式边界已经不存在了。

在生产上线前运行 JFR

配置 jdk.VirtualThreadPinned 事件(JDK 25 默认二十毫秒阈值),并执行标准负载测试。依赖栈中来自原生调用的钉扎会在这里暴露。对大多数纯 Java Web 服务来说,这不会产生任何事件。对 JNI 依赖较重的服务来说,它正好能指出残余载体线程压力来自哪里。

虚拟线程生产可观测性指南

有三件事我们希望在上线第一天就启用;它们分别对应上面的一种失败模式:

  • JFR jdk.VirtualThreadPinned 在 JDK 25 中默认启用,阈值为二十毫秒。配合 jdk.VirtualThreadSubmitFailed 一起使用,对二者都设置告警。这能在残余钉扎类别变成事故之前捕获它们。
  • jcmd Thread.dump_to_file -format=json 会产生按载体线程分组的、感知虚拟线程的结构化转储。这正是 Netflix 遇到停顿时缺失的诊断手段(jstack 的输出不包含虚拟线程)。请把这个命令保留在 runbook 中。对于本地调试,IntelliJ 在 2025 年底增加了感知虚拟线程的线程转储渲染,但要注意:在 IDE 内捕获虚拟线程转储需要在调试器下运行应用。旧版 IDE 只会显示载体线程。
  • 为任何你关心的 ThreadLocal 添加 initialValue() 计数器。继承 ThreadLocal,并在 initialValue() 中递增指标计数器。该速率的飙升就是本文所述缓存未命中失败模式的特征。基准测试仓库中包含参考实现 InstrumentedThreadLocal。上面的图 3 就是该失败模式活跃时,这个计数器在 Grafana 中的样子。

虚拟线程不会改变什么

虚拟线程提高了 I/O 密集并发的吞吐量。它们不会改善 CPU 密集工作的延迟。它们不会增加背压语义。最后,它们不会让慢速外部服务变快。一个大部分墙钟时间花在计算、数据转换或推理上的服务,不会从切换线程模型中获益。为 CPU 密集任务创建数千个虚拟线程的调度开销,是没有对应收益的成本。

采用决策的正确范围是:如果你的服务主要在等待 I/O(数据库、下游 API、文件读取),那么在虚拟线程上进行阻塞 I/O 现在是一种可在规模化下使用的模型。如果你的服务主要是在计算,或者需要对数据流施加背压,那么线程模型不是你的瓶颈,这一改变也不会带来帮助。

结论:这对我们意味着什么

JDK 25 是第一个包含 JEP 491 钉扎修复和定稿版 Scoped Values 的 LTS 版本。对于在 2026 年于 Java 21 LTS 和 Java 25 LTS 之间做选择的团队来说,Java 并发故事在这条缝隙的两侧有着实质性的不同。在 JDK 25 上,JVM 已经消化了监视器钉扎问题,并通过 Scoped Values 提供了稳定的上下文传播原语。剩余的采用工作在你的代码中:审计 ThreadLocal 使用、显式约束每个下游资源、保持 JFR 钉扎事件开启并配置告警,以及跟随 JDK 发布列车前进。

更深刻的转变是概念性的。基于池的并发此前通过同一种机制(线程池)同时表达了工作隔离和资源边界。虚拟线程将线程池解耦。工作隔离是无界的(每个请求都有自己的虚拟线程),资源边界变成每个共享资源处显式的信号量。这种解耦是正确的模型。之前的耦合是操作系统线程成本的产物,而不是设计选择,但它要求应用代码在以前由线程池隐式完成的地方显式进行资源边界控制。这就是 2026 年实际要做的提升,它比 JDK 21 时期要求的提升更小。

Java 并发故事现在比 2021 年清晰得多。虚拟线程处理 I/O 扩展,Scoped Values 处理上下文传播,而结构化并发生态一旦定稿,将处理带有显式生命周期管理的协调并发工作。JDK 24 解决了阻碍广泛采用的载体线程钉扎问题。JDK 25 定稿了上下文 API。对大多数阻塞式 Web 服务而言,剩余的采用决策主要是依赖审计和连接池规模调整,而不是根本性的 JVM 风险。

方法论:复现这些数字

本文中的所有数值数据来自一个公开基准测试。该测试旨在保持环境稳定,同时让配置差异显式化,以便任何读者都可以在自己的硬件上重现上述失败模式。拓扑如图 1 所示。

这些配置在两个方面有意不同:线程模型,以及 JDBC 场景中配置的连接池上限。对于 ThreadLocal 端点,连接池差异无关紧要,因为该端点不访问 JDBC。对于 JDBC 场景,连接池差异是刻意的,模拟了移除 Tomcat 线程上限后随之而来的资源边界转移。

  • 仓库
  • 主机:AWS EC2 c7i.2xlarge(8 vCPU Intel Sapphire Rapids,15 GiB RAM)
  • 操作系统:Ubuntu Server 24.04 LTS,内核 6.17
  • JDK:Eclipse Temurin 25.0.3+9(LTS)
  • 应用:Spring Boot 3.4,嵌入式 Tomcat,HikariCP,Spring RestClient,阻塞式 JDBC
  • 支撑服务:PostgreSQL 16(max_connections=300shared_buffers=128MB);WireMock 3.9.1,带 300 ms 固定响应延迟
  • 对比配置:平台模式(Tomcat pool=200,HikariCP pool=20)与虚拟模式(spring.threads.virtual.enabled=true,HikariCP pool=50)
  • JVM 参数(两种配置相同):-Xms512m -Xmx512m -XX:+UseG1GC -Dhttp.maxConnections=5000,JFR 持续开启,jdk.VirtualThreadPinned 阈值为 threshold=0
  • 负载:JMeter 5.6.3,network_mode: host;预热三十秒斜坡,测量六十秒,运行顺序为平台模式 → 三十秒冷却 → 虚拟模式。ConstantThroughputTimer 保持跨配置的负载稳定;报告的吞吐量数字是服务器在该负载下完成的请求数,而不是由客户端固定下来的速率。
  • 可观测性:Prometheus + Grafana,外加 node-exporter(操作系统)、postgres-exporter(数据库内部)和 cAdvisor(容器),用于呈现 Datadog/Dynatrace 安装会暴露的指标形态。图 2、3、4 是这些仪表盘的直接截图。
  • 复现:make up && make up-app && make benchmark-full(或 make benchmark-quick 进行五分钟冒烟测试);原始 JTL 文件被 .gitignore 忽略,results/summaries/ 目录下的摘要表已提交。

该基准测试有意受控:单实例、合成工作负载、显式配置差异。正是这种受控性让失败模式能够独立浮出水面,而不是被埋在生产噪音中。真实环境会因依赖版本、下游延迟和连接池配置而改变量级。不会改变的是模式本身:假设线程复用的 ThreadLocal 缓存、线程池上限消失后成为新上限的连接池,以及在 JDK 24 上消失的钉扎事件。该配套仓库的存在,是为了让任何读者都能在自己的硬件上验证这些模式,调整参数以匹配自己的技术栈,并在受控环境中观察到这些失败模式,而不必等到它们以生产事故的形式出现。

关于作者

[LOADING...]

Sandeep Bharadwaj

Sandeep Bharadwaj Mannapur 是 Lead Data and AI/ML Engineer,拥有超过十五年的数据工程、AI/ML Ops 和平台工程经验。他的工作重点是构建可扩展、高性能的系统,将数据、模型和 JVM 基础设施融合为可靠的企业平台,涵盖生产机器学习管道,以及本文所探讨的并发和可观测性技术栈。他撰写并演讲关于生产 Java、机器学习平台以及二者背后的运维实践。