虚拟线程(Virtual Thread)原理

Java 21 引入了虚拟线程(Virtual Thread),这是 Java 并发模型的重大变革。我第一次看到这个特性的时候,感觉像是打开了新世界的大门。

以前我们说 Java 能轻松创建线程,但实际上每个线程都要占用约 1MB 的栈内存,10000 个线程就要 10GB 内存。虚拟线程的出现,让"百万并发"变成了可能。

今天我们就来把虚拟线程彻底讲透。

一、为什么需要虚拟线程

1.1 传统线程的问题

// 传统线程模型的限制
new Thread(() -> {
    try {
        // 假设这个操作要等待 1 秒(网络请求)
        Thread.sleep(1000);  // 线程在这里阻塞
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}).start();

问题:

  • 每个线程约 1MB 栈内存
  • 10000 个线程 = 10GB 内存
  • 大部分时间在线程等待,资源浪费

1.2 传统并发模型的困境

// 场景:处理 10000 个并发请求
// 方案1:10000 个线程
ExecutorService executor = Executors.newFixedThreadPool(10000);
// 内存爆炸:10000 * 1MB = 10GB

// 方案2:线程池复用
ExecutorService executor = Executors.newFixedThreadPool(100);
// 问题:只有 100 个线程处理 10000 个请求,要排队

1.3 虚拟线程的解决方案

// 虚拟线程:创建成本极低
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10000).forEach(i -> {
        executor.submit(() -> {
            // 每个任务一个虚拟线程,但只占用少量内存
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}

二、虚拟线程 vs 平台线程

2.1 概念对比

维度平台线程虚拟线程
实现OS 线程JVM 管理
栈大小约 1MB按需增长,通常几 KB
创建成本极低
调度OS 调度ForkJoinPool 调度
阻塞时OS 线程阻塞挂起到堆

2.2 【直观类比】

【直观类比】

把线程比作"人":

传统模型:每个人都要一套房子(1MB 内存)
10000 个人 = 10000 套房子 = 内存爆炸

虚拟线程:每个人只需要一张床(几 KB)
10000 个人 = 10000 张床 = 可以住在一起
Carrier 线程 = 宿舍管理员(实际执行的人)

2.3 内部实现

虚拟线程体系结构:

┌────────────────────────────────────────────┐
│                JVM                         │
│  ┌──────────────────────────────────────┐│
│  │         Virtual Thread 1               ││
│  │  [Stack: 2KB]                         ││
│  └──────────────────────────────────────┘│
│  ┌──────────────────────────────────────┐│
│  │         Virtual Thread 2               ││
│  │  [Stack: 3KB]                         ││
│  └──────────────────────────────────────┘│
│                    ...                     │
│           (10000 个虚拟线程)               │
└────────────────────────────────────────────┘

                    │ 挂起/恢复

┌────────────────────────────────────────────┐
│          Carrier Thread Pool               │
│  ┌─────────────┐ ┌─────────────┐          │
│  │ OS Thread 1 │ │ OS Thread 2 │  ...    │
│  │ (Carrier)   │ │ (Carrier)   │          │
│  └─────────────┘ └─────────────┘          │
└────────────────────────────────────────────┘


              OS 调度

三、Continuation:挂起的核心

3.1 Continuation 是什么

虚拟线程的挂起机制基于 Continuation(续体)。当虚拟线程阻塞时,它会"挂起",把栈帧保存到堆中,而不是阻塞 OS 线程。

// Continuation 的工作原理
ContinuationScope scope = new ContinuationScope("myScope");

Continuation continuation = new Continuation(scope, () -> {
    // 模拟阻塞操作
    System.out.println("Step 1");
    Continuation.yield(scope);  // 挂起
    System.out.println("Step 2");  // 恢复后执行
});

// 执行
while (!continuation.isDone()) {
    continuation.run();  // 运行,直到 yield
}

3.2 挂起过程

虚拟线程执行流程:

虚拟线程 V1 运行中...

// 遇到阻塞操作(sleep/IO/锁)

// V1 挂起:栈保存到堆,切换到 Carrier

Carrier 执行 V2、V3...

// V1 的阻塞完成后

// V1 恢复:栈从堆恢复,继续执行

3.3 挂起 vs 阻塞

// 平台线程阻塞
Thread t = new Thread(() -> {
    Thread.sleep(1000);  // OS 线程阻塞 1 秒
});
t.start();
// 这 1 秒内,OS 线程什么也干不了

// 虚拟线程挂起
Thread vt = Thread.ofVirtual().start(() -> {
    Thread.sleep(1000);  // 虚拟线程挂起 1 秒
});
// 这 1 秒内,Carrier 线程可以执行其他虚拟线程

四、创建和使用虚拟线程

4.1 创建虚拟线程

// 方式1:Thread.ofVirtual()
Thread virtualThread = Thread.ofVirtual().start(() -> {
    System.out.println("Running in virtual thread");
});

// 方式2:Thread.Builder
Thread.Builder builder = Thread.ofVirtual().name("my-vt-");
Thread vt1 = builder.start(() -> { /* ... */ });
Thread vt2 = builder.name("custom-name").start(() -> { /* ... */ });

// 方式3:Executors
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
    System.out.println("Running in virtual thread");
});

4.2 线程池对比

// 传统线程池
ExecutorService platformPool = Executors.newFixedThreadPool(100);
platformPool.submit(() -> {/*...*/});

// 虚拟线程池(每个任务一个线程)
ExecutorService virtualPool = Executors.newVirtualThreadPerTaskExecutor();
virtualPool.submit(() -> {/*...*/});

4.3 虚拟线程适合的场景

// ✅ 适合:IO 密集型、大量并发任务
void handleRequests() {
    ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
    
    for (int i = 0; i < 10000; i++) {
        int requestId = i;
        executor.submit(() -> {
            // 每个请求一个虚拟线程
            // 大部分时间在等待 IO
            // 虚拟线程挂起,Carrier 线程去处理其他请求
            String result = callApi(requestId);
            saveResult(result);
        });
    }
}

// ❌ 不适合:CPU 密集型
// CPU 密集型用线程池,让多个线程真正并行执行

五、Pinning 问题

5.1 什么是 Pinning

虚拟线程挂起依赖于 synchronized 块的释放。但如果 synchronized 块持有内部锁,JVM 无法挂起虚拟线程,导致 Carrier 线程被阻塞。

// 问题:synchronized 块会 Pinning(钉住)Carrier 线程
synchronized (lock) {
    // 虚拟线程在这里无法挂起
    // 如果持有时间很长,Carrier 线程被阻塞
}

5.2 Pinning 的影响

public class PinnedServer {
    private static final Object lock = new Object();
    
    public static void main(String[] args) throws InterruptedException {
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 1000; i++) {
                executor.submit(() -> {
                    synchronized (lock) {  // ❌ Pinning
                        try {
                            Thread.sleep(1);  // 模拟阻塞
                        } catch (InterruptedException e) {}
                    }
                });
            }
        }
    }
}
// 1000 个虚拟线程都卡在同一个锁上
// 无法真正并发

5.3 解决方案

// 方案1:改用 ReentrantLock(可以挂起)
ReentrantLock lock = new ReentrantLock();

executor.submit(() -> {
    lock.lock();  // ✅ 可以挂起
    try {
        // ...
    } finally {
        lock.unlock();
    }
});

// 方案2:JVM 参数(Java 21+)
// -Djdk.virtualThreadScheduler.parallelism=4

六、生产最佳实践

6.1 ✅ 推荐:使用虚拟线程的场景

// Web 服务器
public class VirtualThreadServer {
    public static void main(String[] args) throws IOException {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var server = new ServerSocket(8080);
            while (true) {
                Socket socket = server.accept();
                executor.submit(() -> handle(socket));
            }
        }
    }
}

6.2 ✅ 推荐:重构现有代码

// 以前:线程池 + 回调
CompletableFuture.supplyAsync(() -> callApi(id), pool)
    .thenAccept(result -> process(result));

// 现在:虚拟线程 + 同步代码
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> future = executor.submit(() -> callApi(id));
    String result = future.get();  // 同步写法,但不会阻塞 Carrier
}

6.3 ❌ 避免:在线程池中使用 ThreadLocal

// ❌ 问题
ThreadLocal<String> context = new ThreadLocal<>();
context.set("value");

// 虚拟线程是复用的?不,每个任务一个线程
// 但如果虚拟线程被 park/unpark 很多次,可能有问题

// ✅ 方案:改用 scoped values(Java 21+)
ScopedValue<String> context = ScopedValue.newInstance();
ScopedValue.runWhere(context, "value", () -> {
    // ...
});

七、性能对比

7.1 内存占用对比

10000 个并发连接:

平台线程:
  10000 线程 × 1MB = 10GB 内存
  需要复杂的线程池调优

虚拟线程:
  10000 虚拟线程 × ~2KB = 20MB 内存
  Carrier 线程 = CPU 核心数

7.2 吞吐量对比

// 测试脚本
public class ThroughputTest {
    public static void main(String[] args) {
        int TASKS = 100_000;
        int DURATION_MS = 5000;
        
        // 平台线程池
        test("Platform", Executors.newFixedThreadPool(200), TASKS, DURATION_MS);
        
        // 虚拟线程
        test("Virtual", Executors.newVirtualThreadPerTaskExecutor(), TASKS, DURATION_MS);
    }
}

// 典型结果:
// Platform: ~8000 tasks/sec
// Virtual: ~50000 tasks/sec (IO 密集型场景)

八、面试追问链

第一层:基础概念

面试官问:"虚拟线程是什么?"

Java 21 引入的轻量级线程,由 JVM 管理而不是 OS 线程。虚拟线程的栈在堆中按需增长,阻塞时挂起到堆中,让 Carrier 线程去执行其他虚拟线程。

第二层:原理

面试官追问:"虚拟线程是怎么实现挂起的?"

基于 Continuation 机制。当虚拟线程阻塞时,JVM 把它的栈帧保存到堆中,切换到 Carrier 线程执行其他虚拟线程。阻塞结束后,虚拟线程从堆中恢复栈帧继续执行。

第三层:与 OS 线程的区别

面试官追问:"虚拟线程和平台线程有什么区别?"

平台线程是 OS 线程的 1:1 映射,每个平台线程占用约 1MB 内存。虚拟线程是 JVM 管理的,多个虚拟线程可以复用一个 Carrier 线程(OS 线程)。虚拟线程创建成本极低,适合大量并发。

第四层:Pinning 问题

面试官追问:"什么是 Pinning?怎么解决?"

synchronized 块持有内部锁时,JVM 无法挂起虚拟线程,导致 Carrier 线程被阻塞。解决方案是改用 ReentrantLock,或者升级到 Java 21+。

【学习小结】

  • 虚拟线程是 JVM 管理的轻量级线程
  • 栈在堆中按需增长,占用内存少
  • 阻塞时挂起,Carrier 线程执行其他虚拟线程
  • 适合 IO 密集型、大量并发场景
  • 不适合 CPU 密集型
  • synchronized 会导致 Pinning,Java 21+ 可用 ReentrantLock