1. 引言

Java虚拟线程由 Project Loom 引入,最初作为 Java 19 的预览特性推出,现已成为 JDK 21 正式版的一部分。此外,Spring 6 版本集成了这一出色的特性,让开发者可以进行实验。

首先,我们将了解"系统线程"和"虚拟线程"之间的主要区别。接着,我们将从头开始构建一个使用虚拟线程的 Spring Boot 应用程序。最后,我们将创建一个小型测试套件,以观察简单 Web 应用的吞吐量是否有所提升。

2. 虚拟线程 vs 系统线程

主要区别在于虚拟线程在其运行周期中不依赖于操作系统线程。虚拟线程与硬件解耦,这也是为什么称之为"虚拟"。而且,JVM 提供的抽象层实现了这种解耦。

在本教程中,我们想验证虚拟线程的运行成本远低于系统线程。我们希望确认可以创建数百万个虚拟线程而不会出现内存不足的错误,这是系统线程常见的问题。

3. 在 Spring 6 中使用虚拟线程

首先,我们需要根据环境配置应用程序。

3.1. Spring Boot 3.2 和 Java 21 中的虚拟线程

从 Spring Boot 3.2 开始,如果我们使用 Java 21,启用虚拟线程变得非常简单。我们只需将 spring.threads.virtual.enabled 属性设置为 true

spring.threads.virtual.enabled=true

理论上,我们不需要做其他任何修改。然而,从普通线程切换到虚拟线程可能会对遗留应用程序产生不可预见的后果。因此,我们必须对应用程序进行全面的测试。

3.2. Spring 6 和 Java 19 中的虚拟线程

然而,如果我们无法使用最新的 Java 版本,但正在使用 Spring Framework 6,虚拟线程功能仍然可用。我们需要一些额外的配置来启用 Java 19 的预览特性。这意味着我们需要告诉 JVM 我们想在应用程序中启用它们。由于我们使用 Maven 构建应用程序,让我们确保在 pom.xml 中包含以下代码:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <source>19</source>
                <target>19</target>
                <compilerArgs>
                    --enable-preview
                </compilerArgs>
            </configuration>
        </plugin>
    </plugins>
</build>

从 Java 的角度来看,要让 Apache Tomcat 支持虚拟线程,我们需要创建一个简单的配置类,其中包含几个关键的 bean:

@EnableAsync
@Configuration
@ConditionalOnProperty(
  value = "spring.thread-executor",
  havingValue = "virtual"
)
public class ThreadConfig {
    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }

    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }
}

第一个 Spring Bean ApplicationTaskExecutor 替换了标准的 *ApplicationTaskExecutor*。简而言之,我们想要覆盖默认的 Executor,使其为每个任务启动一个新的虚拟线程。第二个名为 ProtocolHandlerVirtualThreadExecutorCustomizer 的 bean 以相同的方式自定义标准的 *TomcatProtocolHandler*。

此外,我们添加了 @ConditionalOnProperty 注解,这样我们就可以使用 application.yaml 文件中的属性来启用或禁用虚拟线程:

spring:
    thread-executor: virtual
    //...

现在,我们可以验证是否正在运行虚拟线程。

3.3. 验证虚拟线程是否正在运行

让我们测试一下 Spring Boot 应用程序是否使用虚拟线程来处理 Web 请求调用。为此,我们需要构建一个简单的控制器,返回所需的信息:

@RestController
@RequestMapping("/thread")
public class ThreadController {
    @GetMapping("/name")
    public String getThreadName() {
        return Thread.currentThread().toString();
    }
}

Thread 对象的 toString() 方法会返回我们需要的所有信息:线程 ID、线程名称、线程组和优先级。让我们用 curl 请求来调用这个端点:

$ curl -s http://localhost:8080/thread/name
$ VirtualThread[#171]/runnable@ForkJoinPool-1-worker-4

从响应中我们可以清楚地看到,这个Web请求是由一个虚拟线程来处理的。换句话说,调用 Thread.currentThread() 返回的是 VirtualThread 类的一个实例。接下来,让我们通过一个简单但有效的负载测试来看看虚拟线程的实际效果如何。

4. 性能对比

我们将使用 JMeter 进行压力测试。注意,这并不是一个全面的性能对比,我们可以在此基础上构建更多不同参数的测试。

我们将调用 RestController 中的一个接口,该接口只是简单地执行sleep一秒模拟耗时任务:

@RestController
@RequestMapping("/load")
public class LoadTestController {

    private static final Logger LOG = LoggerFactory.getLogger(LoadTestController.class);

    @GetMapping
    public void doSomething() throws InterruptedException {
        LOG.info("hey, I'm doing something");
        Thread.sleep(1000);
    }
}

通过使用 @ConditionalOnProperty 注解,我们可以在虚拟线程和标准线程之间进行切换。

JMeter 测试只包含一个线程组,模拟 1000 个并发用户在 100 秒内持续访问 /load 接口:

JMeter 线程组

在这种情况下,采用这项新特性带来的性能提升是显而易见的。让我们比较一下不同实现的"响应时间图"。这是标准线程的响应图。我们可以看到,完成一次调用所需的时间很快就达到了 5000 毫秒:

标准线程性能

这种情况发生的原因是平台线程是有限的资源。当所有已调度和池化的线程都在忙碌时,Spring 应用只能保持请求等待,直到有一个线程空闲下来。

让我们看看使用虚拟线程会发生什么:

虚拟线程图

结果图显示响应时间稳定在 1000 毫秒左右。这是因为虚拟线程在资源消耗方面非常轻量,可以在请求到达后立即创建和使用。在这个案例中,我们比较的是 Spring 默认的固定标准线程池(默认大小为 200)和 Spring 默认的无界虚拟线程池。

这种性能提升只有在像我们这样的简单场景中才能实现。事实上,对于 CPU 密集型操作,虚拟线程并不是一个好选择,因为这类任务需要的阻塞操作很少。

5. 总结

在本文中,我们学习了如何在基于 Spring 6 的应用程序中使用虚拟线程。首先,我们了解了如何根据应用程序使用的 JDK 版本启用虚拟线程。其次,我们创建了一个 REST 控制器来返回线程名称。最后,我们使用 JMeter 进行测试,证实了相比标准线程,虚拟线程确实使用更少的资源。我们还看到了这如何简化了更多请求的处理。

一如既往,代码可以在 GitHub 上找到。