IntelliJ IDEA 中的 Project Loom:虚拟线程、作用域值和结构化并发
Java 并发是一个强大的特性,但很难做到正确。编写正确的多线程代码需要深入了解线程池、同步、取消和错误传播。即使是经验丰富的开发人员,也经常引入一些微妙的 bug,例如线程泄漏、被吞掉的异常以及仅在特定情况下才会出现的竞态条件。
传统上,Java 并发存在几个局限:
- 阻塞线程带来的可扩展性和资源成本问题。
- 难以在线程之间安全地共享上下文数据。
- 线程管理问题,例如泄漏和取消延迟。
- 并发代码难以理解、调试和维护。
Project Loom 旨在消除 Java 并发代码中简单性与效率之间的权衡,让编写、调试、性能分析和维护正确、可读且可扩展的代码变得更加容易。它通过三个协同工作的特性来实现这一目标:
- 虚拟线程(JEP 444,自 Java 21 起稳定)——平台线程昂贵且数量有限,导致高并发应用资源密集且难以扩展。虚拟线程是轻量级的,由 JVM 管理,允许以相同的开销运行更多并发线程。
- 作用域值(JEP 506,自 Java 25 起稳定)——
ThreadLocal变量是可变的、难以推理,并且容易出现内存泄漏。作用域值提供不可变的、自动清理的数据共享,能够与虚拟线程一起高效扩展。 - 结构化并发(JEP 533,Java 27 中的第七个预览版)——非结构化并发会导致线程泄漏、取消延迟以及难以调试和维护的代码。结构化并发将一组相关线程视为单个工作单元,使取消和错误处理可预测且一致。它还提供了清晰的父子线程层次结构,改善可观测性,使并发代码更容易追踪和检查。
在本文中,我们将概述这些特性,解释它们解决的一些问题,展示它们如何协同工作,并演示 IntelliJ IDEA 如何为你提供支持。
Project Loom 之前的 Java 并发问题
为了说明并发的一些问题以及 Project Loom 如何解决这些问题,让我们看一个示例:在不使用 Project Loom 任何特性的情况下如何编写并发代码。然后,我们将重写这段代码以利用这些特性,并比较两者的差异。
作为一个示例,我们将使用一个加载客户资料的应用程序。它会并行获取客户的订单历史记录和产品推荐。你可以在此处找到该项目的源代码。
CustomerProfileService 中的 getProfile() 方法(可在此处找到)使用 CompletableFuture 并行执行这些调用,以加载客户资料:
catch 块处理任何异常。这段代码完成了它应该做的事情:并行运行独立的调用,设置超时,并在失败时尝试取消剩余的任务。但仍然存在几个潜在问题:
- 线程泄漏。
cancel(true)将 Future 标记为已取消,但对于CompletableFuture,中断标志会被忽略,除非显式连接到取消信号,否则池中已经在运行的工作仍会继续执行。 - 笨拙的错误处理。
ExecutionException包装了真正的根因,必须手动解包(如下面的代码片段所示,也可在此处找到)。每当引入新的异常类型时,都需要更新解包链。
- 重复的取消逻辑。
cancel()调用在TimeoutException和ExecutionException两个 catch 块中重复出现。如果以后添加任何额外的并行调用,它需要在两个 catch 块中都有cancel()调用,这很容易被忘记。随着代码的演变,这些块可能会失去同步。 - 脆弱的上下文传播。 使用
ThreadLocal在线程间传递上下文数据(例如已登录用户的会话或追踪 ID)是脆弱的。ThreadLocal变量是可变的,除非显式移除,否则其值在线程的整个生命周期内持续存在,并且子线程不会自动继承它们,除非使用InheritableThreadLocal,而后者也有自身的缺陷。 - 可观测性差。 线程转储显示的是池线程的扁平列表,没有指示哪些线程属于哪个请求,或者哪些线程仍在等待已经失败的操作。要查看正在运行的线程,你可以在程序挂起时(停在断点处或已暂停)在 IntelliJ IDEA 中获取线程转储。在服务处理请求时,在 Debug 工具窗口中,点击 More 并选择 Get Thread Dump。
暂停输出并获取线程转储
附注:要将 Get Thread Dump 按钮添加到 Debugger 工具窗口,请右键点击 Debugger 工具窗口并选择 Customize Toolbar。在弹出的对话框中,点击 Add,搜索并选择 Get Thread Dump,然后点击 OK。
使用 Get Thread Dump 按钮自定义工具栏
让我们看看 Project Loom 如何解决这些问题。
虚拟线程(JEP 444,自 Java 21 起稳定)
Project Loom 的第一个特性是虚拟线程,它显著提高了具有阻塞代码的 Java 应用程序的吞吐量。
传统上,Java 应用程序中可用线程的数量是有限的,因为平台线程封装了操作系统(OS)线程,而操作系统线程的数量是有限的。平台线程也很昂贵;创建它们可能耗时数毫秒,每个线程都会消耗大量内存,并且它们之间的上下文切换有相当大的开销。为了管理这些成本,应用程序使用线程池——由 ExecutorService 管理的一组固定的可重用线程。
相比之下,虚拟线程是轻量级线程。它们创建成本低(只需微秒而不是毫秒),而且由于不依赖于操作系统线程,因此数量不限;你可以运行数百万个虚拟线程。当虚拟线程阻塞时,底层平台线程会被释放用于其他工作,并在虚拟线程准备好继续时重新分配。这意味着虚拟线程可以显著提高阻塞工作负载的吞吐量,例如 I/O(任何等待数据库、网络调用或文件访问的操作)、暂停或同步。
在 Java 24 中,还进行了一项改进,以提高 Java 代码的可扩展性。通过 JEP 491:Synchronize Virtual Threads without Pinning,在同步方法和语句中阻塞的虚拟线程会释放其底层平台线程。你可以在 What's New in IntelliJ IDEA 2025.2 livestream 中看到实际效果。
我们之前在 Java 25 LTS 和 IntelliJ IDEA 中简要讨论过虚拟线程。有关使用虚拟线程转储的更多信息,请参阅 Thread Dumps and Project Loom (Virtual Threads)。
要调试并发线程的问题,请查看 Println Debugging Done Right 中描述的全新改进版 logpoint 功能。
作用域值(JEP 506,自 Java 25 起稳定)
Project Loom 的第二个特性是作用域值——一种比 ThreadLocal 变量更安全、更具可扩展性的替代方案,专为虚拟线程而设计。它解决了在线程之间清晰、安全地共享上下文数据的问题。
要在应用程序的组件之间共享数据,我们可以使用线程局部变量,但这些变量有几个缺点。ThreadLocal 变量是可变的,因此很难推理。除非手动移除,否则数据会在线程的整个生命周期内持续存在(存在内存泄漏和安全问题的风险),而且子线程会继承副本,从而增加内存占用。
ScopedValue 提供了一种更好的模型:值在定义的作用域内绑定一次,自动可用于该作用域内运行的所有代码,并在作用域结束时自动清理。绑定不能从作用域内部更改,从而消除了意外变更的风险,并保证任何读取该值的代码都会看到相同的值。当与结构化并发一起使用时,作用域值无需显式传播到子线程,从而使上下文共享更安全、更简单。
请注意,即使你的代码没有显式使用 ThreadLocal,像 Spring 这样的框架在底层也会使用它。
更多详情,请参阅 Java 25 LTS 和 IntelliJ IDEA 中关于作用域值的部分。
结构化并发(JEP 533,Java 27 中的第七个预览版)
Project Loom 的第三个特性是结构化并发,目前仍处于预览阶段。Java 27 再次对这个预览特性带来了一些更改。由于我们已经在 IntelliJ IDEA 中添加了对该特性的一些支持,现在是尝试它的最佳时机。
结构化并发旨在推广一种并发编程风格,以减少常见问题,如线程泄漏和取消延迟、重复的取消逻辑以及笨拙的错误处理。其核心思想是,将一组相关的并发任务视为一个具有明确所有者、明确生命周期和清晰规则的工作单元。子任务不能超出其作用域而存在,失败会干净地传播,取消会自动从父任务流向子任务。
StructuredTaskScope 允许你将一个任务分解为并发子任务,这些子任务作为一个整体进行协调。子任务被 fork 出来在自己的线程上运行,并在工作完成时作为一个整体被 join。
StructuredTaskScope 有一个工厂方法 StructuredTaskScope.open()。这个方法有几个重载,允许你提供 Joiner 和/或配置回调。这让你在打开作用域时可以在一个地方定义失败策略、用于可观测性的名称以及超时。
在我们的示例中,我们希望两个方法(fetchOrders() 和 fetchRecommendations())都成功,以便正确组装客户资料。我们可以为作用域提供一个名称,并设置一个超时时间,表示我们愿意等待结果多久。如果其中任何一个失败,另一个会被取消。当子任务失败或超时到期时,join() 会抛出带有底层原因的 ExecutionException。我们根据该原因进行 switch,以显式处理每种情况——包括 CancelledByTimeoutException,这是 joiner 用来发出超时信号的异常。
如果不需要所有并行调用的结果呢?例如,假设推荐信息可以从两个不同的缓存中获取,而你只需要其中一次调用成功即可。如果一个任务成功,另一个任务就可以被关闭。要实现这一点,我们可以使用另一个 Joiner:anySuccessfulOrThrow()。一旦某个缓存返回结果,作用域就会关闭,另一个任务会自动取消。如果两者都失败,join() 方法会抛出 ExecutionException,以其中一个失败子任务的异常作为原因。
要在 IntelliJ IDEA 中快速搭建 StructuredTaskScope,请使用内置的 live template sts。
使用 live template sts 创建并打开一个 StructuredTaskScope。
结构化并发在 Java 27 中仍是预览特性,因此还不建议用于生产环境。话虽如此,该特性在几个预览轮次中整体形态已经相对稳定,API 有一些变化,现在正是尝试它的好时机。要确定代码中哪些地方可以使用结构化并发,可以查找并行执行多个任务并等待结果的位置。这些代码都可以考虑使用结构化并发重写。
使用 Project Loom 特性重写 CustomerProfileService
我们的演示项目的 “modern”分支 包含使用 Project Loom 特性重写后的相同应用程序。其结构遵循与之前相同的模式:在作用域内并行获取订单和推荐信息。
CustomerProfileService 中更新后的 getProfile() 方法(可在此处找到)现在使用 StructuredTaskScope:
请注意,我们不再需要重复的 cancel() 调用。如果任一任务失败或超过超时时间,所有剩余子任务都会自动取消。不再需要手动调用 cancel()。
这段代码现在清晰地表达了其意图:并行获取订单和推荐信息,最多等待两秒,如果出现任何问题则干净地失败。由于代码所做的事情的模式已经清晰地体现在代码中,因此这段代码更容易阅读、理解和推理。
要了解结构化并发带来的差异,请在 IntelliJ IDEA 中运行更新后的服务,并在处理请求时获取线程转储。你可以按照前面介绍的方法创建线程转储。从 IntelliJ IDEA 2026.1 开始,在 StructuredTaskScope 中 fork 的虚拟线程会被分组到代表其作用域的容器中。IntelliJ IDEA 调试器现在会向你展示结构化并发中的结构。
使用 StructuredTaskScope 获取线程转储## 在 IntelliJ IDEA 中使用 Java 27(EA)
要试用本文介绍的功能,你需要 Java 27。你可以在 IntelliJ IDEA 中通过 Project Structure | Project Settings | Project 下载,然后打开 SDK 下拉菜单并选择 Download JDK。将 Version 设置为 27,并选择 Early-Access 版本。
从 IntelliJ IDEA 下载 JDK
如果你使用其他方式下载 JDK,可以将 IntelliJ IDEA 指向你的安装位置。前往 Project Structure | Project Settings | Project,打开 SDK 下拉菜单,选择 Add JDK from disk,然后将 IntelliJ IDEA 指向你安装的 Java 27。
如果你使用 SDKMAN! 或 asdf 等命令行工具,可以利用内联提示(inlay hints)更轻松地管理版本。如果你的 .sdkmanrc 或 .tool-versions 文件指定了尚未安装的 JDK 版本,界面上会出现一个内联提示,让你直接下载该版本。
通过 .sdkmanrc 下载 JDK
如果 JDK 已安装但尚未为项目配置,你可以使用内联提示将其设置为项目 JDK。
通过 .sdkmanrc 设置 JDK
更多信息,请参阅文档。
为了在使用 JDK 早期访问版本时获得对新语言特性(如结构化并发)的支持,请将 Language level 设置为 X -- Experimental features。
如果你在阅读本文时 Java 27 已经发布,可从 IntelliJ IDEA 中下载你想使用的 Java 27 发行版;如果已安装 Java 27,则可将 IDE 指向你的安装位置。要使用结构化并发,你还需要启用预览功能。在 Project Structure 中将 Language level 设置为 27 (Preview) -- Primitive types in patterns, instanceof, and switch (5th preview)。IntelliJ IDEA 会在编辑器中标记预览功能的使用情况,让你随时了解哪些功能尚不稳定。
结论
虚拟线程、作用域值和结构化并发被设计为一个协同工作的系统,各自解决问题的不同维度:
- 虚拟线程提升了可扩展性。它们消除了管理线程池大小的需要,使得即使在高并发下,每个任务一个线程也变得切实可行。
- 作用域值改善了上下文传播。该 JEP 解决了
ThreadLocal(以及框架的变通方案)的一些缺点,让作用域内的所有任务都能自动、安全地访问共享的不可变上下文。 - 结构化并发通过为并发任务提供清晰的生命周期、明确的所有者以及干净的故障模型,解决了并发中的结构性问题,从而消除了线程泄漏、重复的取消逻辑以及
ExecutionException的解包问题。
它们共同让你编写出的并发代码比传统并发代码更易读,同时兼具安全性和可扩展性。当前可能需要多个步骤才能正确完成的样板代码,被更简洁、读起来正如其所解决问题的代码所取代。
你可以在 IntelliJ IDEA 中使用这些功能。如果你有任何问题或反馈,请在下方评论区告诉我们。