代码瀑布

Java编程技术分享

Java 虚拟线程深度解析:从原理到实战

代码瀑布 2026年9月29日 35 次阅读

前言

2022 年 9 月,Java 19 正式引入了虚拟线程(Virtual Threads)作为预览特性,并在 Java 21 中转为正式特性。这一被称为"Project Loom"核心成果的技术,被誉为 Java 并发编程领域近十年来最重要的变革。它让开发者可以用简单的同步代码,获得接近异步编程的吞吐量。

本文将深入剖析虚拟线程的原理、使用方式、适用场景以及常见陷阱,帮助你真正掌握这项技术。


一、为什么需要虚拟线程?

1.1 传统平台线程的困境

在 Java 21 之前,我们使用的线程都是平台线程(Platform Thread),它们与操作系统内核线程一一对应。这带来两个核心问题:

  • 创建成本高:每个平台线程需要分配约 1MB 的栈内存,创建和销毁开销大。

  • 数量受限:一台机器能创建的线程数通常只有几千个,远不能满足高并发需求。

考虑一个典型的 Web 服务场景:

// 传统方式:每个请求一个线程serverSocket.accept(); // 阻塞等待// 处理请求,可能涉及数据库查询、HTTP 调用等阻塞操作

当并发请求达到上万时,线程池会被耗尽,请求只能排队等待,吞吐量急剧下降。

1.2 异步编程的复杂性

为了突破线程数量限制,业界转向了异步编程(如 CompletableFuture、Reactive Streams):

public CompletableFuture<User> getUser(Long id) {
    return userRepository.findByIdAsync(id)
        .thenCompose(user -> orderService.getOrdersAsync(user.getId())
            .thenApply(orders -> {
                user.setOrders(orders);
                return user;
            }));}

这种"回调地狱"虽然提升了吞吐量,但代码可读性差、调试困难、异常处理复杂,严重损害了开发效率。

虚拟线程的目标就是:让同步代码拥有异步代码的吞吐量。


二、虚拟线程的原理

2.1 核心机制:M:N 调度

虚拟线程采用 M:N 调度模型:大量虚拟线程(M)被调度到少量平台线程(N)上执行。

虚拟线程1 ─┐
虚拟线程2 ─┼─→ ForkJoinPool 调度器 ─→ 平台线程1
虚拟线程3 ─┤                          ─→ 平台线程2
...       ┘                          ─→ 平台线程3

关键在于:当虚拟线程遇到阻塞操作时,JVM 会将其挂起(unmount),释放底层平台线程去执行其他虚拟线程,而不是像平台线程那样阻塞内核线程。

2.2 栈内存的存储

平台线程的栈存储在操作系统内存中,而虚拟线程的栈存储在 Java 堆中(称为 StackChunk)。这意味着:

  • 虚拟线程初始只占用几百字节,按需增长。

  • 可以轻松创建数百万个虚拟线程。

2.3 阻塞时的状态转换

Thread.startVirtualThread(() -> {
    // 1. 运行中,挂载(mount)到平台线程
    var result = blockingIoCall(); 
    // 2. 遇到阻塞,JVM 自动 unmount,平台线程被释放
    // 3. IO 完成后,重新 mount 到某个平台线程继续执行
    System.out.println(result);});

整个过程对开发者完全透明,无需修改任何业务代码。


三、快速上手

3.1 创建虚拟线程的几种方式

// 方式一:直接启动Thread.startVirtualThread(() -> {
    System.out.println("Hello from virtual thread");});// 方式二:使用 Builder(推荐,可命名便于调试)Thread thread = Thread.ofVirtual()
    .name("my-virtual-thread-", 0)
    .start(() -> {
        System.out.println("Running");
    });// 方式三:使用 ExecutorServicetry (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return "done";
        });
    }} // 自动关闭

3.2 一个完整的对比示例

public class VirtualThreadDemo {

    // 模拟一次耗时 100ms 的 IO 操作
    static String fetchData(int id) throws InterruptedException {
        Thread.sleep(100);
        return "data-" + id;
    }

    public static void main(String[] args) throws Exception {
        int taskCount = 10_000;
        long start = System.currentTimeMillis();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = new ArrayList<>();
            for (int i = 0; i < taskCount; i++) {
                int id = i;
                futures.add(executor.submit(() -> fetchData(id)));
            }
            for (Future<String> f : futures) {
                f.get();
            }
        }

        System.out.println("虚拟线程耗时: " + 
            (System.currentTimeMillis() - start) + "ms");
    }}

对比结果(参考值):

线程类型 10000 个任务耗时 内存占用
平台线程(池大小 200) ~5000ms 高
虚拟线程 ~150ms 极低

四、深入理解:Pinning(钉住)问题

虚拟线程并非在所有阻塞场景下都能释放平台线程。当虚拟线程被"钉住"(pinned)时,它会一直占用平台线程,失去优势。

4.1 两种会导致 Pinning 的情况

情况一:在 synchronized 块中阻塞

// 危险:在 synchronized 中执行阻塞操作synchronized (lock) {
    Thread.sleep(1000); // 此处会 pin 住平台线程!}

情况二:调用 native 方法或 foreign function

// JNI 调用会 pin 住线程nativeMethod();

4.2 解决方案

用 ReentrantLock 替代 synchronized:

private final ReentrantLock lock = new ReentrantLock();public void doWork() {
    lock.lock();
    try {
        Thread.sleep(1000); // 不会 pin
    } finally {
        lock.unlock();
    }}

4.3 检测 Pinning

JVM 提供了系统属性来检测 pinning:

-Djdk.trackAllThreads=false \-Djdk.virtualThreadScheduler.maxPoolSize=256

在 JDK 21+ 中,可以通过 JFR(Java Flight Recorder)事件 jdk.VirtualThreadPinned 监控。


五、适用场景与不适用场景

✅ 适合虚拟线程的场景

  • IO 密集型任务:HTTP 调用、数据库查询、文件读写、消息队列消费。

  • 高并发服务端:每个请求一个虚拟线程,代码简单直观。

  • 替代传统线程池:Executors.newVirtualThreadPerTaskExecutor() 几乎可以无脑替换。

❌ 不适合的场景

  • CPU 密集型任务:虚拟线程无法提升 CPU 利用率,应使用固定大小线程池。

  • 长时间持有 synchronized 锁:会导致 pinning。

  • 依赖线程局部变量的重型缓存:因为虚拟线程数量巨大,ThreadLocal 可能导致内存爆炸。

关于 ThreadLocal 的替代

由于虚拟线程数量可达百万级,滥用 ThreadLocal 会带来严重内存问题。推荐使用 Scoped Values(Java 21 预览):

private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();ScopedValue.where(CURRENT_USER, user).run(() -> {
    // 在此作用域内可访问 CURRENT_USER.get()
    handleRequest();});

六、最佳实践总结

  1. 优先使用 Executors.newVirtualThreadPerTaskExecutor(),而不是手动创建虚拟线程。

  2. 避免使用线程池复用虚拟线程:虚拟线程本身就是廉价的,无需池化(池化的目的是复用昂贵资源)。

  3. 将 synchronized 替换为 ReentrantLock,特别是在持有锁期间有阻塞操作时。

  4. 谨慎使用 ThreadLocal,考虑迁移到 ScopedValue。

  5. 不要为虚拟线程设置过大的栈,它会自动增长。

  6. CPU 密集型任务仍使用平台线程池,虚拟线程不是万能的。


七、结语

虚拟线程是 Java 并发编程的一次范式转变。它让"一个请求一个线程"的简单模型重新成为高并发场景的可行方案,让开发者摆脱回调地狱,同时保持接近异步的性能。

当然,虚拟线程并非银弹。理解其调度机制、pinning 问题和内存特性,才能真正发挥它的威力。建议在新项目中尝试虚拟线程,但也要在生产环境中做好压测和监控,特别是关注 pinning 事件。

Java 的并发之路还在继续演进,Scoped Values、Structured Concurrency(结构化并发)等特性将进一步丰富这一生态。掌握虚拟线程,就是掌握了 Java 未来十年的并发基础。


参考资料

  • JEP 444: Virtual Threads

  • JEP 446: Scoped Values (Preview)

  • JEP 453: Structured Concurrency (Preview)

  • Oracle 官方文档:Virtual Threads



Copyright © 2026.代码瀑布 All rights reserved.粤ICP备2023039942号