Java 21 虚拟线程 vs. 传统线程:Spring Boot 实战指南

2025/06/15 Spring Boot

Java 21 中引入的虚拟线程 (Virtual threads),代表了 Java 并发模型的一次重大进步。本指南将通过一个 Spring Boot 应用,演示传统线程与虚拟线程之间的实际差异,展示虚拟线程如何在不受物理线程限制的情况下,轻松处理成千上万的并发请求。

先决条件

  • Java 21 或更高版本
  • Maven 或 Gradle
  • 对 Spring Boot 有基本了解
  • 对并发编程有基本了解

核心概念

传统线程 (Traditional Threads / 平台线程)

  • 每个线程都直接映射到一个操作系统 (OS) 线程
  • 数量受限于可用的 CPU 核心数和操作系统限制。
  • 内存开销高 (通常每个线程约 1MB)。
  • 阻塞操作会占用并“卡住”整个线程,使其无法执行其他任务。

虚拟线程 (Virtual Threads)

  • JVM 管理的轻量级线程
  • 数量不受限于 CPU 核心数,可以创建数百万个。
  • 内存开销极小 (通常每个线程仅几 KB)。
  • 高效处理阻塞操作,线程在阻塞时不会占用 OS 线程,可以去执行其他任务。
  • 非常适合 I/O 密集型应用。

项目设置

  1. 创建一个使用 Java 21 的新 Spring Boot 项目。
  2. 将以下依赖项添加到你的 pom.xml 文件中 (或者你也可以使用 Gradle):
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
    

实现步骤

1. 线程配置 (ThreadConfig.java)

创建一个配置类来分别配置传统线程池和虚拟线程的执行器 (Executor)。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

@Configuration
@EnableAsync // 启用异步方法执行
public class ThreadConfig {

    // 定义传统线程池执行器
    @Bean(name = "traditionalExecutor")
    public Executor traditionalExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);  // 核心线程数:10
        executor.setMaxPoolSize(10);   // 最大线程数:10 (线程数量有限)
        executor.setQueueCapacity(100); // 等待队列容量:100
        executor.setThreadNamePrefix("traditional-"); // 线程名前缀
        executor.initialize();
        return executor;
    }

    // 定义虚拟线程执行器
    @Bean(name = "virtualExecutor")
    public Executor virtualExecutor() {
        // 每次任务都创建一个新的虚拟线程
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

2. 测试控制器 (TestController.java)

创建一个控制器,包含两个端点,分别用于演示两种线程类型的行为。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/api/test")
public class TestController {

    private static final Logger logger = LoggerFactory.getLogger(TestController.class);

    // 使用传统线程池异步执行
    @Async("traditionalExecutor")
    @GetMapping("/traditional")
    public CompletableFuture<String> traditionalThread() {
        logger.info("开始传统线程请求。线程: {}", Thread.currentThread().getName());
        try {
            TimeUnit.SECONDS.sleep(1); // 模拟耗时的I/O操作
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return CompletableFuture.completedFuture("任务被中断");
        }
        return CompletableFuture.completedFuture(
            "传统线程任务完成。线程: " + Thread.currentThread().getName()
        );
    }

    // 使用虚拟线程执行器异步执行
    @Async("virtualExecutor")
    @GetMapping("/virtual")
    public CompletableFuture<String> virtualThread() {
        // 注意:日志中打印 Thread.currentThread() 会显示虚拟线程的详细信息
        logger.info("开始虚拟线程请求。线程: {}", Thread.currentThread());
        try {
            TimeUnit.SECONDS.sleep(1); // 模拟耗时的I/O操作
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return CompletableFuture.completedFuture("任务被中断");
        }
        return CompletableFuture.completedFuture(
            "虚拟线程任务完成。线程: " + Thread.currentThread()
        );
    }
}

测试实现

1. 启动应用程序

./mvnw spring-boot:run

2. 测试传统线程

使用下面的命令模拟 200 个并发请求。由于线程池限制,你应该会看到一些请求失败。

# 测试200个并发请求(应该会看到一些失败或拒绝)
seq 1 200 | xargs -n1 -P200 curl -s "http://localhost:8080/api/test/traditional"
  • 对上述命令的解释:
    • seq 1 200: 生成从 1 到 200 的数字序列。
    • |: 管道符,将前一个命令的输出作为后一个命令的输入。
    • xargs: 将输入转换为命令行参数。
    • -n1: 一次处理一个输入项。
    • -P200: 最多并行运行 200 个进程。
    • curl: 发起 HTTP 请求的命令。
    • -s: 静默模式(不显示进度条或错误信息)。
    • "http://.../traditional": 要测试的目标 URL。

3. 测试虚拟线程

使用下面的命令模拟 5000 个并发请求。所有请求应该都会成功。

# 测试5000个并发请求(应该都能成功)
seq 1 5000 | xargs -n1 -P5000 curl -s "http://localhost:8080/api/test/virtual"

预期结果

  • 传统线程
    • 最多同时处理 10 个并发请求,外加 100 个在队列中等待的请求。
    • 超出 110 个的并发请求将会失败或超时。
    • 日志中看到的线程名将是 “traditional-1” 到 “traditional-10”。
    • 在大量并发请求下,内存使用率会显著增高。
  • 虚拟线程
    • 可以轻松处理数千个并发请求。
    • 没有失败或超时
    • 日志中看到的线程信息将类似于 VirtualThread[#ID]/runnable@ForkJoinPool-1-worker-1
    • 内存开销极小。

理解结果

  • 为什么传统线程会失败?
    • 每个传统线程都消耗大量内存。
    • 受到操作系统线程数量的限制。
    • 阻塞操作会“霸占”整个线程,使其无法处理其他任务。
    • 等待队列的容量也限制了能处理的并发请求总数。
  • 为什么虚拟线程能成功?
    • 它是 JVM 管理的轻量级实现。
    • 高效地处理阻塞操作:当虚拟线程遇到阻塞(如 sleep 或网络 I/O)时,JVM 会自动挂起它,并让底层的 OS 线程去执行其他任务,而不是空等。
    • 几乎不受物理资源的限制,可以大量创建。
    • 非常适合 I/O 密集型应用。

最佳实践

  • 建议使用虚拟线程的场景:
    • I/O 密集型应用 (例如,调用外部 API、数据库查询、文件读写)。
    • 需要处理高并发请求的场景。
    • 微服务架构中,需要同时处理大量并发请求的服务。
    • 应用中包含大量阻塞操作的。
  • 建议使用传统线程的场景:
    • CPU 密集型任务 (例如,复杂的数学计算、数据加密/解密)。因为虚拟线程并不会提高 CPU 密集型任务的性能,这类任务需要与 CPU 核心数匹配的线程数才能最高效。
    • 需要使用线程本地存储 (ThreadLocal) 并且依赖其与平台线程绑定的特性的场景 (虚拟线程对 ThreadLocal 的支持有限,且可能在不同 OS 线程上恢复执行)。
    • 与不支持虚拟线程的遗留代码或原生库 (JNI) 交互时。

常见陷阱 (Common Pitfalls)

  • 未使用 Java 21 或更高版本:虚拟线程是 Java 21 的正式功能。
  • 在不理解其影响的情况下混合使用虚拟线程和传统线程:例如,在虚拟线程中调用一个 synchronized 同步块可能会“钉住 (pin)”虚拟线程,使其无法被挂起,从而降低性能优势。
  • 未正确处理线程中断:无论是哪种线程,都应妥善处理 InterruptedException
  • 未监控线程使用情况和性能:应使用适当的工具来监控应用的线程行为,以确保其按预期工作。

结论

虚拟线程是 Java 并发模型的一次重大进步,尤其对于 I/O 密集型应用而言。通过理解传统线程与虚拟线程之间的差异,开发者可以在构建应用程序时,做出更明智的技术选型决策。

Show Disqus Comments

Search

    Post Directory