在 Spring 6 中使用虚拟线程(Virtual Threads)

1、简介

在这个简短的教程中,我们将了解如何在 Spring Boot 中使用虚拟线程(Virtual Threads)。

虚拟线程是 Java 19 的 预览特性,这意味着它们将在未来 12 个月内被纳入 JDK 的正式版本中。虚拟线程最初是由 Loom 项目引入的,在 Spring 6 中,开发人员可以一睹为快了。

首先,我们将了解 “平台线程” 与 “虚拟线程” 的主要区别。接下来,我们将使用虚拟线程从头开始构建一个 Spring-Boot 应用。最后,我们会进行一个测试,测试 web 应用的吞吐量提升了多少。

2、虚拟线程和平台线程

主要区别在于,虚拟线程在运行周期内不依赖操作系统线程:它们与硬件脱钩,因此被称为 “虚拟”。这种解耦是由 JVM 提供的抽象层赋予的。

虚拟线程的运行成本远低于平台线程。它们消耗的内存要少得多。这就是为什么我们可以创建数百万个虚拟线程而不会出现内存不足的问题,而标准平台(或内核)线程只能创建数百个。

从理论上讲,这赋予了开发人员一种超级能力:无需依赖异步代码即可管理高度可扩展的应用程序。

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

从 Spring Framework 6(和 Spring Boot 3)开始,虚拟线程功能正式全面可用,但虚拟线程是 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,为每个任务启动一个新的虚拟线程。第二个 Bean 命名为 ProtocolHandlerVirtualThreadExecutorCustomizer,它将以同样的方式定制标准的 TomcatProtocolHandler。我们还添加了注解 @ConditionalOnProperty,以便通过修改 application.yaml 文件中配置属性的值来按需启用虚拟线程:

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

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

@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() 调用返回了一个虚拟线程类的实例。现在让我们通过一个简单但有效的负载测试来看看虚拟线程的性能。

4、性能对比

在负载测试中,我们将使用 JMeter。这并不是虚拟线程和标准线程之间完整的性能比较,而是一个起点,我们可以从这个起点出发,用不同的参数建立更多的测试。

简单起见,我们将调用 Rest Controller 中的一个端点,该端点将简单地让当前线程进入 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 注解,我们只需更改 application.yaml 中变量的值,就可以在虚拟线程和标准线程之间切换。

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

JMeter 线程组

在这种情况下,采用虚拟线程这项新功能所带来的性能提升是显而易见的。

让我们比较一下不同实现的 “响应时间图”(Response Time Graph)。

这是标准线程的响应时间图。我们可以看到,完成一次调用所需的时间很快就达到了 5000 毫秒:

标准线程的性能表现

出现这种情况是因为平台线程是有限的资源,当所有调度线程和任务线程都忙得不可开交时,Spring 应用程序只能将请求搁置,直到有线程空闲出来。

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

虚拟线程的性能表现

我们可以看到,响应时间稳定在 1000 毫秒。虚拟线程会在请求发出后立即创建和使用,因为从资源角度来看,虚拟线程非常廉价。

在本例中,我们比较了 Spring 默认的固定标准线程池(默认为 200)和 Spring 默认的无限制虚拟线程池的使用情况。

这种级别的性能提升只有在场景简单且不考虑 Spring Boot 应用的全部功能范围时才可能实现。采用这种从底层操作系统基础设施中抽象出来的方式可能会带来好处,但并非在每种情况下都适用。

5、总结

在本文中,我们了解了如何在 Spring 6 中使用虚拟线程,以及使用虚拟线程和标准线程之间的性能差异。


参考:https://www.baeldung.com/spring-6-virtual-threads