Skip to content

虚拟线程 ​

[!IMPORTANT] 本系列以 JDK 20 为基准。虚拟线程在 JDK 20 是第二次预览特性,使用 Thread.ofVirtual 或 Executors.newVirtualThreadPerTaskExecutor 的案例需要 javac --release 20 --enable-preview Example.java 和 java --enable-preview Example。预览特性不是稳定 API,升级 JDK 时要重新核对。

text
javac --release 20 --enable-preview VirtualThreadDemo.java
java --enable-preview VirtualThreadDemo

学习目标 ​

  • 区分平台线程与虚拟线程的创建成本、调度方式和工作负载边界。
  • 使用每任务虚拟线程执行器处理大量等待型任务,并用 Semaphore 限制下游资源。
  • 识别 pinning、ThreadLocal、线程泄漏和结构化并发的 JDK 20 状态。

核心知识点 ​

专业术语 ​

  • 虚拟线程(virtual thread):由 JVM 调度、挂载到少量平台线程上的轻量线程。
  • 载体线程(carrier thread):实际承载虚拟线程运行字节码的平台线程。
  • pinning:虚拟线程阻塞时无法卸载载体线程的情况,常见于 synchronized 监视器或 native 调用。
  • ThreadLocal:按线程保存值的局部变量机制;在线程数量很大时要关注每个任务的值和生命周期。
  • 结构化并发(structured concurrency):把子任务的生命周期、取消和异常收束到词法作用域中的并发模型;JDK 20 为孵化 API。

白话解释与边界 ​

虚拟线程便宜的是“等待时的线程状态”,不是 CPU,也不是数据库连接、文件句柄和远端 QPS。它特别适合每个请求都会阻塞等待 I/O 的代码;CPU 密集循环仍受核心数限制,应该使用有界平台线程池或其他并行策略。

虚拟线程不应被当作无限制并发开关。对数据库、HTTP 服务、文件系统等下游资源仍要使用连接池、Semaphore、超时和取消。通过 JFR 或线程转储验证 pinning,不要靠猜测换锁。

常用用法 ​

Thread.ofVirtual:创建并等待虚拟线程 ​

Thread.ofVirtual 用于创建等待型任务线程,在 JDK 20 中属于预览 API。

java
// 说明:Thread.ofVirtual:创建并等待虚拟线程。
public class VirtualThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = Thread.ofVirtual()
                .name("virtual-worker")
                .start(() -> {
// 初始状态:thread = Thread.ofVirtual()。
// 作用:Thread thread = Thread.ofVirtual();创建并等待虚拟线程。
                    System.out.println(Thread.currentThread().isVirtual());
// 输出:true
                });
        thread.join();
// 作用:thread.join();;创建并等待虚拟线程。
    }
}

start 会立即安排任务,unstarted 只创建不启动;业务代码要像管理平台线程一样处理异常、取消和生命周期。

Executors.newVirtualThreadPerTaskExecutor:每任务一个虚拟线程 ​

该执行器不提供传统固定线程池那样的并发上限;close() 会等待已提交任务完成。

java
// 说明:Executors.newVirtualThreadPerTaskExecutor:每任务一个虚拟线程。
import java.util.concurrent.Executors;

public class VirtualExecutorDemo {
    public static void main(String[] args) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 初始状态:executor = Executors.newVirtualThreadPerTaskExecutor())。
// 作用:try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {;每任务一个虚拟线程。
            var first = executor.submit(() -> "a");
// 作用:var first = executor.submit(() -> "a");;每任务一个虚拟线程。
// 初始状态:first = executor.submit(() -> "a")。
            var second = executor.submit(() -> "b");
// 作用:var second = executor.submit(() -> "b");;每任务一个虚拟线程。
// 初始状态:second = executor.submit(() -> "b")。
            System.out.println(first.get() + second.get());
// 输出:ab
        }
    }
}

对外部资源需要另加 Semaphore 或连接池,而不是因为线程便宜就无限提交。

Semaphore:限制下游并发 ​

许可证数量代表下游允许的并发量,而不是虚拟线程数量。

java
// 说明:Semaphore:限制下游并发。
import java.util.concurrent.Semaphore;

public class VirtualThreadLimitDemo {
    public static void main(String[] args) throws InterruptedException {
        Semaphore permits = new Semaphore(2);
// 初始状态:permits = new Semaphore(2)。
// 作用:Semaphore permits = new Semaphore(2);;限制下游并发。
        permits.acquire();
// 作用:permits.acquire();;限制下游并发。
        try {
            System.out.println("one downstream slot");
// 输出:one downstream slot
        } finally {
            permits.release();
// 作用:permits.release();;限制下游并发。
        }
    }
}

必须在 finally 中释放,异常或取消路径漏放会造成永久饥饿。

ThreadLocal:按任务保存小范围上下文 ​

虚拟线程通常不是长寿命池线程,ThreadLocal 泄漏风险较小但仍应在任务边界清理;不要把大对象、连接或事务状态长期绑定在线程本地。

java
// 说明:ThreadLocal:按任务保存小范围上下文。
public class VirtualThreadLocalDemo {
    private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
// 初始状态:REQUEST_ID = new ThreadLocal<>()。
// 作用:private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();;按任务保存小范围上下文。

    public static void main(String[] args) throws InterruptedException {
        Thread thread = Thread.ofVirtual().start(() -> {
// 作用:Thread thread = Thread.ofVirtual().start(() -> {;按任务保存小范围上下文。
// 初始状态:thread = Thread.ofVirtual().start(() ->。
            REQUEST_ID.set("req-1");
// 作用:REQUEST_ID.set("req-1");;按任务保存小范围上下文。
            try {
                System.out.println(REQUEST_ID.get());
// 输出:req-1
            } finally {
                REQUEST_ID.remove();
// 作用:REQUEST_ID.remove();;按任务保存小范围上下文。
            }
        });
        thread.join();
// 作用:thread.join();;按任务保存小范围上下文。
    }
}

Thread.sleep:等待型工作不会占满载体 ​

示例用 sleep 模拟等待,真实程序应使用带超时的阻塞 I/O。

java
// 说明:Thread.sleep:等待型工作不会占满载体。
public class VirtualSleepDemo {
    public static void main(String[] args) throws Exception {
        try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
// 初始状态:executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor())。
            var future = executor.submit(() -> {
// 作用:var future = executor.submit(() -> {;等待型工作不会占满载体。
// 初始状态:future = executor.submit(() ->。
                Thread.sleep(1);
// 初始状态:Thread.sleep(1)。
// 作用:Thread.sleep(1);;等待型工作不会占满载体。
                return "io-like wait";
            });
            System.out.println(future.get());
// 输出:io-like wait
        }
    }
}

虚拟线程不是让 sleep 变快,而是让等待期间更少占用平台线程。

不常用但需要知道 ​

pinning:监视器或 native 调用可能占住载体 ​

JDK 20 需要通过 JFR 等证据确认 pinning;不要把所有 synchronized 都机械替换为 Lock。真正的改进通常是缩短锁内代码,把阻塞 I/O 移到临界区外。

java
// 说明:work() 持有 monitor 时只执行短输出;若把长时间 native 或阻塞调用放进该区域,虚拟线程可能占住载体线程。
public class PinningBoundaryDemo {
    private final Object monitor = new Object();
// 初始状态:monitor = new Object()。
// 作用:private final Object monitor = new Object();;监视器或 native 调用可能占住载体。

    void work() {
        synchronized (monitor) {
            // 这里若执行长时间阻塞调用,可能 pin 住载体线程
            System.out.println("short critical section");
// 输出:short critical section
        }
    }

    public static void main(String[] args) {
        new PinningBoundaryDemo().work();
// 作用:new PinningBoundaryDemo().work();;监视器或 native 调用可能占住载体。
// 初始状态:new PinningBoundaryDemo().work()。
    }
}

结构化并发:JDK 20 孵化 API ​

JDK 20 的 jdk.incubator.concurrent 是孵化模块,不是稳定 API;只使用该 API 时编译和运行需分别加 --add-modules jdk.incubator.concurrent。它把子任务的等待、取消和异常收束到 scope,仍应核对目标 JDK 的具体签名。

只使用该孵化 API 时可以这样编译和运行:

java
import jdk.incubator.concurrent.StructuredTaskScope;

public class StructuredConcurrencyDemo {
    public static void main(String[] args) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 初始状态:scope = new StructuredTaskScope.ShutdownOnFailure())。
// 作用:try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {;JDK 20 孵化 API。
            var left = scope.fork(() -> "left");
// 初始状态:left = scope.fork(() -> "left")。
// 作用:var left = scope.fork(() -> "left");;JDK 20 孵化 API。
            var right = scope.fork(() -> "right");
// 初始状态:right = scope.fork(() -> "right")。
// 作用:var right = scope.fork(() -> "right");;JDK 20 孵化 API。
            scope.join();
// 初始状态:scope.join()。
// 作用:scope.join();;JDK 20 孵化 API。
            scope.throwIfFailed();
// 初始状态:scope.throwIfFailed()。
// 作用:scope.throwIfFailed();;JDK 20 孵化 API。
            System.out.println(left.get() + right.get());
// 输出:leftright
        }
    }
}
text
javac --release 20 --add-modules jdk.incubator.concurrent StructuredConcurrencyDemo.java
java --add-modules jdk.incubator.concurrent StructuredConcurrencyDemo

Thread.Builder:配置线程名称和启动方式 ​

Builder 适合统一命名或配置未启动线程;大量任务通常直接使用每任务执行器,并把命名策略集中放在 ThreadFactory 或任务日志上下文中。

java
// 作用:通过 Thread.Builder 配置线程名称和启动方式。
public class VirtualThreadBuilderDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = Thread.ofVirtual().name("request-1").unstarted(
                () -> {
// 初始状态:thread = Thread.ofVirtual().name("request-1").unstarted(。
// 作用:Thread thread = Thread.ofVirtual().name("request-1").unstarted(;配置线程名称和启动方式。
                    System.out.println(Thread.currentThread().getName());
// 输出:request-1
                });
        thread.start();
// 作用:thread.start();;配置线程名称和启动方式。
        thread.join();
// 作用:thread.join();;配置线程名称和启动方式。
    }
}

简单案例 ​

java
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

public class VirtualThreadServiceDemo {
    public static void main(String[] args) throws Exception {
        Semaphore limit = new Semaphore(1);
// 初始状态:limit = new Semaphore(1)。
// 作用:Semaphore limit = new Semaphore(1);;配置线程名称和启动方式。
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 初始状态:executor = Executors.newVirtualThreadPerTaskExecutor())。
            var future = executor.submit(() -> {
// 作用:var future = executor.submit(() -> {;配置线程名称和启动方式。
// 初始状态:future = executor.submit(() ->。
                limit.acquire();
// 作用:limit.acquire();;配置线程名称和启动方式。
                try {
                    return "response";
                } finally {
                    limit.release();
// 作用:limit.release();;配置线程名称和启动方式。
                }
            });
            System.out.println(future.get());
// 输出:response
        }
    }
}

案例把“每请求一个虚拟线程”和“下游只允许一个并发”分开表达;线程扩展性与资源扩展性是两件事。

易混点 ​

  • 虚拟线程是 JDK 20 第二次预览,不是 JDK 20 的稳定特性;编译和运行都要 --enable-preview。
  • 虚拟线程适合等待型工作,不会让 CPU 密集代码突破核心数,也不会增加数据库连接上限。
  • pinning 是载体被占住,不是所有 synchronized 都有问题;应先用 JFR 定位长临界区或 native 阻塞。
  • ThreadLocal 是线程局部,不是请求自动清理的上下文;任务边界仍需 remove。
  • 结构化并发在 JDK 20 是孵化 API,命令需要 --add-modules,不能写成稳定标准库保证。

课后小问 ​

  1. 为什么虚拟线程多并不代表数据库可以打开同样多的连接? 答案:虚拟线程只是执行调度资源,数据库连接是独立的有容量下游资源。 解析:使用连接池、Semaphore、超时和排队把请求并发限制在数据库可承受范围内。

  2. 看到 synchronized 就应该改成 ReentrantLock 吗? 答案:不应该,只有明确需要可中断、超时、多个 Condition 或诊断控制时才考虑 Lock。 解析:synchronized 释放更可靠;改用 Lock 反而增加了手动 unlock 和遗漏异常路径的风险。

  3. JDK 20 运行结构化并发示例需要哪些参数? 答案:使用 jdk.incubator.concurrent 时编译和运行都要加 --add-modules jdk.incubator.concurrent。 解析:它是孵化 API,不是预览语法;若另外使用预览语法,才在两侧再加 --enable-preview。

本节小结 ​

  • 虚拟线程降低大量等待型任务的线程管理成本,但不改变 CPU 和外部资源上限。
  • 每任务虚拟线程执行器适合简单请求并发,连接池、Semaphore 和超时负责背压。
  • pinning、ThreadLocal 和取消仍是普通线程同样重要的生命周期问题。
  • JDK 20 的虚拟线程是预览特性,结构化并发是孵化 API,升级时必须重新核对。

快速回顾 ​

  • 会用 Thread.ofVirtual 和 newVirtualThreadPerTaskExecutor。
  • 能解释载体线程、pinning 和 CPU/IO 工作负载边界。
  • 会用 Semaphore 限制虚拟线程访问下游资源。
  • 能写出 JDK 20 预览与孵化特性的正确编译、运行参数。