Project Loom 在 IntelliJ IDEA 中的实践:虚拟线程、作用域值与结构化并发

Java 并发是一项强大的功能,但要做到这一点并不容易。编写正确的多线程代码需要对线程池、同步、取消和错误传播有深入理解。即使是有经验的开发者,也会经常引入细微的漏洞,如线程泄漏、吞噬异常和只有在特定情况下才会出现的竞态条件。

传统上,Java 并发存在若干局限性:

  • 阻断线程带来的可扩展性和资源成本。
  • 线程间安全分享上下文数据困难。
  • 管理线程时遇到的问题,比如泄露和取消延迟。
  • 难以理解、调试和维护的并发代码。

织机计划旨在消除并发 Java 代码中简单与效率之间的权衡,使编写、调试、分析和维护正确、可读且可扩展的代码变得更容易。它通过三个协同工作的特性实现这一目标:

  • 虚拟线程(JEP 444自 Java 21 起稳定)——平台线程成本高且数量有限,使得高度并发的应用程序资源消耗巨大且难以扩展。虚拟线程轻量化,由 JVM 管理,允许更多线程同时运行,而无需承担相同的开销。
  • 范围值(JEP 506自 Java 25 起稳定)——ThreadLocal 变量可变,难以推理,且易发生内存泄漏。带作用域的值提供不可变、自动清理的数据共享,并能通过虚拟线程高效扩展。
  • 结构化并行(JEP 533Java 27 第七次预览版)——非结构化并发导致线程泄漏、取消延迟以及难以调试和维护的代码。结构化并发将一组相关线程视为单一工作单元,使取消和错误处理变得可预测且一致。它还提供了清晰的父子线程层级结构,提升了可观察性,使并发代码更易于追踪和检查。

在这篇文章中,我们将为你概述这些功能,解释它们解决的一些问题,展示它们如何协同工作,并演示 IntelliJ IDEA 如何在整个过程中支持你。

Project Loom 之前的 Java 并发问题

为了说明并发中的一些问题以及 Project Loom 如何解决这些问题,我们来看一个例子,说明如何在不使用 Project Loom 的任何特性的情况下编写并发代码。然后我们将重写这些代码以利用这些特性,看看它们的对比。

举例来说,我们将使用一个应用程序加载客户配置文件。它并行获取客户的订单历史和产品推荐。你可以找到该项目的源代码给你.

方法getProfile()CustomerProfileService(你可以找到给你)加载客户配置文件,使用CompletableFuture同时运行呼叫:

public CustomerProfile getProfile(String customerId) throws OrderServiceException, RecommendationServiceException {
        CompletableFuture> orderFuture =
                CompletableFuture.supplyAsync(() -> orderServiceClient.getOrders(customerId), executor);
        CompletableFuture> recFuture =
                CompletableFuture.supplyAsync(() -> recommendationServiceClient.getRecommendations(customerId), executor);

        CompletableFuture allFutures = CompletableFuture.allOf(orderFuture, recFuture);

        try {
            allFutures.get(2, TimeUnit.SECONDS); // single timeout covering both futures
            return new CustomerProfile(customerId, orderFuture.join(), recFuture.join());
        }

        // catch block 

catchblock 处理所有异常。该代码完成了它应有的功能:它并行运行独立调用,设置超时,并在失败时努力取消剩余任务。但仍然存在几个潜在问题:

  • 线漏了。 cancel(true)标记未来为取消,但CompletableFuture中断标志被忽略,池中已运行的工作会继续完成,除非它被明确接入了取消信号。
  • 错误处理尴尬。 ExecutionException包裹真正的原因,必须手动解封(代码片段中显示,相关内容也可查阅)给你).每次引入新的异常类型时,解封链都需要更新。
        catch (ExecutionException e) {
            Throwable cause = e.getCause();
            orderFuture.cancel(true);
            recFuture.cancel(true);

            if (cause instanceof RestClientResponseException ex && ex.getStatusCode().value() == 503) {
                if (orderFuture.isCompletedExceptionally()) {
                    throw new OrderServiceException("Order service unavailable", e.getCause());
                }
                if (recFuture.isCompletedExceptionally()) {
                    throw new RecommendationServiceException("Recommendation service unavailable", e.getCause());
                }
            }
            throw new RuntimeException("Unexpected error", e.getCause());
  • 重复的取消逻辑。cancel()呼叫会在两个TimeoutException以及ExecutionException捕获块。如果后续添加任何额外的并行调用,则需要cancel()同时调用两个接点块,这很容易被忘记。随着代码的发展,这些块可能会漂移不同步。
  • 脆弱的上下文传播。通过使用以下方式在线程间传递上下文数据,如登录用户的会话或追踪ID。ThreadLocal很脆弱。ThreadLocal 变量是可变的,除非明确移除,否则其值在整个线程生命周期内持续存在,子线程不会自动继承,除非你使用InheritableThreadLocal,这本身就有其陷阱。
  • 可观测性差。线程转储显示一个扁平的池线程列表,没有指示哪些线程属于哪个请求,或者哪些线程仍在等待已经失败的某个请求。要查看运行中的线程,可以在IntelliJ IDEA中获得线程转储,当程序在停赛(要么在断点处停顿,要么暂停)。在调试工具窗口,点击更多并选择获取线程转储服务正在处理请求时。

附注:补充一下获取线程转储按钮到你的调试器工具窗口,右键点击调试器工具窗口和选择自定义工具栏.在弹窗中点击添加搜索并选择获取线程转储,点击好的。

让我们来看看Project Loom是如何解决这些问题的。

虚拟线程(JEP 444,自Java 21起稳定)

Project Loom的第一个特色是虚拟线程,这些方法极大地提升了 Java 应用中带有阻塞代码的吞吐量。

传统上,Java 应用中可用的线程数量有限,因为平台线程会环绕操作系统线程,而操作系统线程数量有限。平台线程也成本高昂;创建它们可能需要几毫秒,每个线程占用大量内存,且在线程间切换上下文开销较大。为了管理这些成本,应用程序使用线程池——一组固定的可重用线程,由ExecutorService.

相比之下,虚拟线程是轻量级线程。它们创建成本低(只需微秒而非毫秒),而且由于不绑定操作系统线程,数量不受限制;你可以运行数百万个线程。当虚拟线程阻塞时,底层平台线程会被释放用于其他工作,并在虚拟线程准备好继续时重新分配。这意味着虚拟线程可以显著提升阻塞工作负载的吞吐量,如I/O(任何等待数据库、网络调用或文件访问的任务)、暂停或同步。

在 Java 24 中,进行了进一步改进,以提升 Java 代码的可扩展性。与JEP 491:无需钉顶即可同步虚拟线程,阻挡同步方法和语句的虚拟线程会释放其底层平台线程。你可以在IntelliJ IDEA 2025.2 直播最新动态.

我们已经简要讨论过了虚拟线程Java 25 LTS 与 IntelliJ IDEA.想了解更多关于使用虚拟线程转储的信息,可以看看线程转储与项目织机(虚拟线程).

要调试并发线程的问题,请查看以下描述的全新改进的logpoint功能正确完成的 PrintLN 调试.

作用域值(JEP 506,自Java 25以来稳定)

《织机计划》的第二个特色是范围值—— 一个更安全、更可扩展的替代方案ThreadLocal变量,专为虚拟线程设计。它们解决了线程间干净且安全地共享上下文数据的问题。

为了在应用组件之间共享数据,我们可以使用线程本地变量,但这有几个缺点。AThreadLocal变量是可变的,因此难以推理。数据会在线程生命周期内持续存在,除非手动删除(存在内存泄漏和安全问题风险),子线程继承的副本会增加内存占用。

ScopedValue提供了更好的模型:值在定义的范围内绑定一次,自动对该范围内运行的所有代码开放,且在作用域结束时自动清理。绑定不能在作用域内更改,消除了意外变异的风险,并确保任何读取该值的代码都会看到相同的值。当与结构化并发一起使用时,作用域值无需显式传播到子线程,使上下文共享更加安全、更简单。

请注意,即使你的代码没有明确使用ThreadLocal像Spring这样的框架底层就用它。

更多细节请参见范围值Java 25 LTS 与 IntelliJ IDEA.

结构化并发(JEP 533,Java 27第七次预览)

《织机计划》的第三个特色是结构化并发,目前仍在预览阶段。Java 27 再次对该预览功能带来了一些变化。由于我们已经在 IntelliJ IDEA 中增加了对该功能的支持,现在正是尝试它的好时机。

结构化并发旨在促进一种并发编程风格,减少线程泄漏和取消延迟、重复取消逻辑以及错误处理尴尬等常见问题。核心思想是将一组相关的并发任务视为一个单一的工作单元,拥有明确的所有者、明确的生命周期和明确的规则。子任务无法超越其范围,失败能干净利落地传播,取消自动从父任务流向子任务。

StructuredTaskScope允许你将任务拆分为并发子任务,作为一个单元协调。子任务分支成独立线程,工作完成时作为一个单元加入。

StructuredTaskScope具有工厂方法StructuredTaskScope.open().该方法具有多个过载功能,使你能够提供Joiner和/或配置回调。这让你在打开作用域时,可以在同一地方定义故障策略、可观测性名称和超时。

在我们的例子中,我们希望两种方法都存在(fetchOrders()以及fetchRecommendations()) 以成功,以便正确组装客户配置文件。我们可以为范围命名,并设定超时时间,决定愿意等待结果的时间。如果其中任一失败,另一项将被取消。当子任务失败或超时结束时,join()抛出ExecutionException与其根本原因有关。我们会开启该原因,明确处理每个案例——包括CancelledByTimeoutException,加入者用来表示超时。

如果我们不需要所有方法并行调用的结果呢?例如,假设推荐来自两个不同的缓存,你只需要其中一个调用的结果就能成功。如果一个任务成功,另一个任务可以被关闭。为此,我们可以使用不同的Joiner,anySuccessfulOrThrow().一旦一个缓存返回结果,作用域关闭,另一个任务自动取消。如果两者都失败,join()方法 抛出ExecutionException除了一个失败的子任务作为原因。

快速搭建脚手架StructuredTaskScope在 IntelliJ IDEA 中,使用内置的实时模板sts.

结构化并发仍是Java 27的预览功能,因此尚未推荐用于生产环境。话虽如此,该功能在几轮预览中相对稳定,只是对API做了一些调整,现在是尝试它的好时机。要确定代码中可以使用结构化并发的部分,可以寻找代码并行执行多项任务并等待结果的地方。这段代码是通过结构化并发重写的候选代码。

利用Project Loom功能重写CustomerProfileService

我们演示项目的“现代”分支包含使用Project Loom功能重写的同一应用程序。结构遵循之前的模式:订单和推荐在作用域内并行获取。

更新后的方法getProfile()CustomerProfileService(你可以找到给你) 现在使用StructuredTaskScope:

public CustomerProfile getProfile(String customerId) throws InterruptedException, TimeoutException {
        try {
            return ScopedValue.where(CUSTOMER_ID, customerId).call(() -> {
                try (var scope = StructuredTaskScope.open(
                        Joiner.awaitAllSuccessfulOrThrow(),
                        config -> config.withName("customer-profile").withTimeout(Duration.ofSeconds(2)))) {
                    var orderTask = scope.fork(() -> orderServiceClient.getOrders(CUSTOMER_ID.get()));
                    var recTask = scope.fork(() -> recommendationServiceClient.getRecommendations(CUSTOMER_ID.get()));
                    scope.join();
                    return new CustomerProfile(customerId, orderTask.get(), recTask.get());
                } catch (ExecutionException e) {
                    switch (e.getCause()) {
                        case StructuredTaskScope.CancelledByTimeoutException _ -> throw new TimeoutException("Request timed out");
                        case OrderServiceException ose -> throw ose;
                        case RuntimeException rte -> throw rte;
                        default -> throw new RuntimeException(e.getCause());
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    throw new RuntimeException("Interrupted", e);
                }
            });
        } catch (InterruptedException | TimeoutException | RuntimeException e) {
            throw e;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

注意我们不再需要复制cancel()调用两个捕获块。如果任一任务失败或超时时间超过,所有剩余子任务自动取消。不再需要手动操作cancel() 电话。

代码现在清晰表达了其意图:并行获取命令和推荐,等待最多两秒,若出现问题则干净利落地失败。由于代码的行为模式清晰地体现在代码中,这些代码更容易阅读、理解和推理。

要了解结构化并发的区别,可以在 IntelliJ IDEA 中运行更新后的服务,并在请求处理过程中进行线程转储。你可以像前面描述的那样创建线程转储。来自IntelliJ IDEA 2026.1,在StructuredTaskScope被分组到代表其作用域的容器中。IntelliJ IDEA 调试器现在会以结构化并发方式显示结构。

在 IntelliJ IDEA 中使用 Java 27(EA)

要尝试本文描述的功能,你需要Java 27.你可以从IntelliJ IDEA内部下载,通过项目结构 |项目设置 |项目,然后打开SDK下拉菜单和选择下载JDK.赛场版本27并选择抢先体验版本。

如果你用的是其他方式下载 JDK,可以指向你的安装方式 IntelliJ IDEA。前往项目结构 |项目设置 |项目,打开SDK下拉菜单,选择从磁盘添加 JDK并指向你安装的Java 27的IntelliJ IDEA。

如果你用的是命令行工具,比如SDKMAN!ASDF你可以用内嵌提示来简化版本管理。如果你.sdkmanrc.tool-versions文件指定尚未安装的JDK版本,会显示内嵌提示,允许你直接下载。

如果JDK已经安装好但还没为项目配置,你可以用inlay提示把它设为项目JDK。

更多信息请参见文献.

为了支持新的语言特性,比如结构化并发,在使用早期访问版的JDK时,设置语言水平X – 实验特征.

如果你阅读本文时 Java 27 已经发布,请从 IntelliJ IDEA 下载你想使用的 Java 27 发行版,或者如果你已经安装了 Java 27,也可以将 IDE 指向你的安装环境。要使用结构化并发,你还需要启用预览功能。设置语言水平27(预览)– 模式、实例和交换中的原始类型(第五次预览)项目结构.IntelliJ IDEA会标记编辑器中预览功能的使用情况,这样你就能随时知道哪些功能尚未稳定。

结论

虚拟线程、有作用域的值和结构化并发被设计为一个连贯系统,每个系统针对问题的不同维度:

  • 虚拟线程提升可扩展性。它们消除了管理线程池大小的需求,使每个任务运行一个线程变得切实可行,即使在高并发状态下也是如此。
  • 范围值改善上下文传播。该 JEP 解决了一些ThreadLocal(以及框架的变通方法),使所有任务在一个范围内都能自动、安全地访问共享且不可变的上下文。
  • 结构化并发通过赋予并发任务清晰的生命周期、明确的所有者和干净的故障模型,解决并发中的结构性问题,从而消除线程泄漏、重复的消去逻辑,以及ExecutionException拆开。

它们结合起来,让你写出比传统并发代码更易读的代码,同时又安全且具可扩展性。目前可能需要多步才能完善的样板代码,被更简洁、阅读时完全符合问题的代码所取代。

你可以在IntelliJ IDEA中使用这些功能。如果你有任何问题或反馈,请在下方评论告诉我们。

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论