跳至主要內容

JDK 虚拟线程完全指南:原理、使用与常见问题

郑天祺大约 10 分钟java基础Java虚拟线程并发编程

前言

JDK 21 正式将虚拟线程(Virtual Threads)作为一项特性发布,彻底改变了 Java 并发编程的格局。传统平台线程在面对高并发 I/O 密集型场景时,线程数受限于操作系统资源,开发者不得不借助异步编程或响应式框架来突破瓶颈。虚拟线程的出现让"每个请求一个线程"的简洁模型重新焕发生机——但用不对也会踩坑。

本文将系统梳理虚拟线程的原理、与平台线程的区别、正确使用方式以及常见问题。


1. 什么是虚拟线程

虚拟线程是由 JDK 而非操作系统管理的轻量级线程。它的工作原理可以概括为:

  1. 虚拟线程是用户态线程:由 JVM 调度,不直接映射到操作系统线程
  2. 载体线程(Carrier Thread):虚拟线程需要挂载到一个平台线程上才能执行
  3. 自动挂起/恢复:当虚拟线程遇到阻塞 I/O 操作时,JVM 自动将其从载体线程上卸载,载体线程可以去执行其他虚拟线程;I/O 完成后再恢复执行

这种机制类似于 Go 语言的 goroutine 和 Erlang 的 process,核心思想都是 M:N 调度——大量虚拟线程复用少量载体线程。

简单示例

// JDK 21+ 直接使用
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}

上面这段代码可以轻松创建一万个并发任务,而不会耗尽系统资源——这在传统平台线程模型下几乎不可能。


2. 虚拟线程 vs 平台线程

核心差异对比

特性平台线程虚拟线程
创建成本高(~1-2 MB 栈内存)极低(~几 KB)
调度者操作系统JVM
上限数千个(受内存限制)数百万个
阻塞行为阻塞 OS 线程自动卸载,不阻塞载体
适用场景CPU 密集型I/O 密集型
调试难度成熟较新,工具支持逐步完善
线程本地变量有效但昂贵不推荐使用
ThreadLocal 支持正常支持但有严重性能隐患

内存占用对比

假设创建 10,000 个线程,每个线程栈大小为 1MB:

  • 平台线程:约 10 GB 内存(实际可能触发 OOM)
  • 虚拟线程:约 几十 MB(虚拟线程的栈在堆上按需增长)

3. 创建虚拟线程的方式

方式一:直接创建

// 启动一个虚拟线程并运行
Thread vThread = Thread.startVirtualThread(() -> {
    System.out.println("Hello from virtual thread: " + Thread.currentThread());
});

vThread.join();

方式二:ThreadFactory

ThreadFactory factory = Thread.ofVirtual().name("my-vt-", 0).factory();

Thread vt = factory.newThread(() -> {
    System.out.println("Virtual thread name: " + Thread.currentThread().getName());
});
vt.start();
vt.join();

方式三:newVirtualThreadPerTaskExecutor(推荐)

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = new ArrayList<>();
    for (int i = 0; i < 1000; i++) {
        futures.add(executor.submit(() -> {
            // 模拟 I/O 操作
            String result = httpClient.send(request, BodyHandlers.ofString()).body();
            return result;
        }));
    }
    // 等待所有任务完成
    for (Future<String> f : futures) {
        System.out.println(f.get());
    }
}

⚠️ 注意newVirtualThreadPerTaskExecutor() 为每个任务创建一个新的虚拟线程,本身不需要设置线程池大小。不要像传统线程池那样去限制它的并发数。

方式四:自定义 ExecutorService(结构化并发)

JDK 21 引入的结构化并发(Structured Concurrency)API 与虚拟线程配合使用:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> user = scope.fork(() -> fetchUser(id));
    Future<Order> order = scope.fork(() -> fetchOrder(orderId));

    scope.join();
    scope.throwIfFailed();

    return combine(user.resultNow(), order.resultNow());
}

4. 适用场景

✅ 虚拟线程的最佳场景

1. I/O 密集型任务

这是虚拟线程的"主场"。任何涉及网络请求、数据库查询、文件读写等阻塞操作的场景都能从中获益:

  • HTTP 服务器处理请求
  • 数据库连接池调用
  • 微服务间的 RPC 调用
  • 文件上传/下载处理
// 典型的 I/O 密集型场景:并行调用多个下游服务
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<User> userFuture = executor.submit(() -> userService.getById(userId));
    Future<List<Order>> ordersFuture = executor.submit(() -> orderService.getByUser(userId));
    Future<Account> accountFuture = executor.submit(() -> accountService.getByUser(userId));

    // 三个调用并行执行,总耗时 ≈ 最慢的那个
    User user = userFuture.get();
    List<Order> orders = ordersFuture.get();
    Account account = accountFuture.get();
}

2. 高并发但短生命周期的任务

每个请求独立处理,任务之间无共享状态:

// Web 服务器伪代码
while (true) {
    Socket socket = serverSocket.accept();
    Thread.startVirtualThread(() -> handleRequest(socket));
}

3. 替代异步编程模型

虚拟线程让你可以用同步的写法获得接近异步的性能,代码可读性大幅提升:

// 虚拟线程:同步写法,简洁直观
public User getUserWithOrders(Long userId) {
    User user = userClient.get(userId);          // 阻塞但不浪费 OS 线程
    List<Order> orders = orderClient.getByUser(userId);
    user.setOrders(orders);
    return user;
}

// 对比 CompletableFuture:异步写法,可读性差
public CompletableFuture<User> getUserWithOrdersAsync(Long userId) {
    return userClient.getAsync(userId)
        .thenCombineAsync(orderClient.getByUserAsync(userId), (user, orders) -> {
            user.setOrders(orders);
            return user;
        });
}

❌ 虚拟线程不适合的场景

1. CPU 密集型任务

虚拟线程不会让你获得更多的 CPU 资源。如果任务是纯计算(加密、图像处理、数据压缩),虚拟线程没有优势,反而增加了调度开销:

// ❌ 没有意义:CPU 密集型任务
executor.submit(() -> {
    return complexMathematicalComputation(); // 纯 CPU 计算
});

// ✅ 正确做法:使用与 CPU 核心数匹配的平台线程池
int cores = Runtime.getRuntime().availableProcessors();
var computeExecutor = Executors.newFixedThreadPool(cores);

2. 在虚拟线程上执行 synchronized 代码块

这是虚拟线程最容易踩的坑(详见下一节),synchronized 会导致 载体线程被固定(pinning),虚拟线程无法在阻塞时卸载。

3. 使用大量 ThreadLocal 的场景

虚拟线程的数量可能是平台线程的千百倍,每个虚拟线程都持有 ThreadLocal 副本会导致严重的内存问题。


5. 常见问题与陷阱

问题一:载体线程固定(Pinning)⭐ 最常见

现象:虚拟线程在执行 synchronized 方法或代码块时遇到阻塞操作,JVM 无法将其从载体线程上卸载,导致载体线程被"钉住",退化为平台线程的行为。

问题代码

// ❌ synchronized 会导致 pinning
public synchronized void processData(Socket socket) {
    // 阻塞 I/O 操作 + synchronized = 载体线程被固定
    InputStream is = socket.getInputStream();
    byte[] data = is.readAllBytes(); // 阻塞
}

解决方案:将 synchronized 替换为 ReentrantLock

private final ReentrantLock lock = new ReentrantLock();

// ✅ ReentrantLock 不会导致 pinning
public void processData(Socket socket) {
    lock.lock();
    try {
        InputStream is = socket.getInputStream();
        byte[] data = is.readAllBytes();
    } finally {
        lock.unlock();
    }
}

诊断方法

JDK 21+ 提供了 JVM 参数来检测 pinning:

java -Djdk.virtualThreadScheduler.parallelism=16 \
     -Djdk.tracePinnedThreads=short \
     -jar your-app.jar

输出示例:

Thread[#42,ForkJoinPool-1-worker-1] pinned at:
    com.example.MyService.processData(MyService.java:15)
    com.example.MyService$$Lambda$1.run(Unknown Source)

问题二:ThreadLocal 滥用

现象:在虚拟线程中使用 ThreadLocal 会导致内存占用暴涨。

原因:传统场景下平台线程数量有限(几十到几百),ThreadLocal 的内存开销可控。但虚拟线程可以轻松创建数十万甚至数百万个,每个都持有 ThreadLocal 副本。

解决方案

// ❌ 避免在虚拟线程中使用 ThreadLocal
private static final ThreadLocal<SimpleDateFormat> dateFormat = 
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// ✅ 替代方案1:使用不可变对象(最推荐)
// DateTimeFormatter 本身就是线程安全的
private static final DateTimeFormatter formatter = 
    DateTimeFormatter.ofPattern("yyyy-MM-dd");

// ✅ 替代方案2:使用 Scoped Values(JDK 21 Preview)
// 适用于需要传递上下文的场景
private static final ScopedValue<UserContext> CURRENT_USER = ScopedValue.newInstance();

public void handleRequest(Request req) {
    ScopedValue.where(CURRENT_USER, resolveUser(req))
        .run(() -> processRequest(req));
}

关键:如果你必须在虚拟线程中使用 ThreadLocal,确保存储的值是轻量级的,并考虑在任务完成后主动清理。

问题三:不支持中断 Thread.sleep()

现象Thread.interrupt() 对虚拟线程的 Thread.sleep() 不一定能立即中断。

说明:虚拟线程的 interrupt() 行为与平台线程基本一致,但在某些 I/O 操作上可能存在差异。建议使用 java.util.concurrent 中的 Future.cancel() 或结构化并发中的 TaskScope.shutdown()

问题四:虚拟线程的生命周期很短,不适合做缓存 Key

现象:用虚拟线程作为 Map 的 key 或缓存键,在任务结束后线程对象即被丢弃,缓存会膨胀。

// ❌ 虚拟线程不适合做缓存 key
ConcurrentHashMap<Thread, Context> contextCache = new ConcurrentHashMap<>();

// ✅ 使用任务 ID 或其他业务标识
ConcurrentHashMap<String, Context> contextCache = new ConcurrentHashMap<>();

问题五:与 synchronized I/O 流的交互

现象:Java 标准库中部分 I/O 操作内部使用了 synchronized,可能导致隐藏的 pinning。

常见来源

  • BufferedInputStream / BufferedOutputStream
  • PrintStream(System.out / System.err)
  • ObjectInputStream / ObjectOutputStream

解决方案

# 启动时加上 pinning 检测
java -Djdk.tracePinnedThreads=short -jar app.jar

如果发现是 JDK 内置类导致的 pinning,可以:

  1. 升级 JDK(后续版本持续修复 pinning 问题)
  2. 在关键路径上替换为 NIO 实现

问题六:数据库连接池配置

现象:使用虚拟线程后,连接池的等待队列堆积。

原因:虚拟线程可以轻松创建数万个,但数据库连接是有限的。如果每个虚拟线程都在等数据库连接,实际上并没有解决瓶颈,只是把等待从线程层面搬到了连接池层面。

解决方案

// HikariCP 配置建议
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(20);           // 连接数与数据库实际承载能力匹配
config.setMinimumIdle(5);

// ✅ 正确:虚拟线程 + 有限连接池 = 虚拟线程在等连接时自动释放载体线程
// ❌ 错误:以为用了虚拟线程就无限创建连接

核心原则:虚拟线程解决的是"等待时的线程资源浪费"问题,不解决"等待资源本身不够"的问题。


6. 虚拟线程迁移策略

从传统线程池迁移到虚拟线程

// 迁移前:固定大小线程池
@Service
public class OrderService {
    private final ExecutorService executor = Executors.newFixedThreadPool(200);
    
    public CompletableFuture<Order> processOrder(OrderRequest req) {
        return CompletableFuture.supplyAsync(() -> {
            return doProcess(req);
        }, executor);
    }
}

// 迁移后:虚拟线程执行器
@Service
public class OrderService {
    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
    
    public CompletableFuture<Order> processOrder(OrderRequest req) {
        return CompletableFuture.supplyAsync(() -> {
            return doProcess(req);
        }, executor);
    }
}

从异步编程迁移

// 迁移前:CompletableFuture 链式调用
public CompletableFuture<Response> handle(Request req) {
    return authAsync(req)
        .thenCompose(auth -> validateAsync(auth))
        .thenCompose(valid -> processAsync(valid, req))
        .thenApply(this::buildResponse)
        .exceptionally(ex -> buildErrorResponse(ex));
}

// 迁移后:虚拟线程 + 同步代码
public Response handle(Request req) {
    try {
        Auth auth = auth(req);          // 看似阻塞,实际不浪费 OS 线程
        Validation valid = validate(auth);
        Result result = process(valid, req);
        return buildResponse(result);
    } catch (Exception ex) {
        return buildErrorResponse(ex);
    }
}

迁移检查清单


7. 最佳实践总结

✅ Do

  1. I/O 密集型场景放心用:HTTP 调用、数据库查询、文件操作等阻塞操作是虚拟线程的最佳搭档
  2. 用 ReentrantLock 替代 synchronized:避免载体线程固定
  3. 利用结构化并发管理任务生命周期StructuredTaskScope 提供了优雅的任务编排方式
  4. 合理设置载体线程并行度-Djdk.virtualThreadScheduler.parallelism=N(默认为 CPU 核心数)
  5. 使用 jdk.tracePinnedThreads 诊断问题:开发阶段开启 pinning 检测

❌ Don't

  1. 不要用虚拟线程做 CPU 密集计算:计算量大的任务应该用平台线程池
  2. 不要在虚拟线程中使用 synchronized:必须用 ReentrantLock 替代
  3. 不要在虚拟线程中使用 ThreadLocal:内存消耗会随虚拟线程数量线性增长
  4. 不要限制虚拟线程的数量newVirtualThreadPerTaskExecutor() 不需要像固定线程池那样设置大小
  5. 不要把虚拟线程当长期存活对象使用:虚拟线程是一次性的,用完即弃

8. 总结

虚拟线程是 Java 并发编程的一次范式转变。它让开发者能够用最简单的同步代码风格处理高并发场景,而无需被迫学习复杂的异步编程模型。

核心要点

  • 虚拟线程是 I/O 密集型场景的最佳选择,对 CPU 密集型无帮助
  • synchronized 会导致 pinning,务必替换为 ReentrantLock
  • ThreadLocal 在虚拟线程中危险,优先用不可变对象或 Scoped Values
  • 资源瓶颈不会因虚拟线程消失,连接池大小仍需合理配置
  • 结构化并发是管理虚拟线程生命周期的推荐方式

如果你正在使用 JDK 21+ 并且项目中有大量的 I/O 等待操作,虚拟线程值得认真考虑。从传统线程池迁移的改造成本通常很低,但收益显著。


上次编辑于:
贡献者: zhengtianqi