第一卷 第一号 一份记录作品、文章和旅行的个人日报
创刊 2017 杭州
版次 日刊 2026年7月3日 星期五
技术专栏

Java 21 虚拟线程,和 JDK 8 原生线程到底差在哪?

从线程模型、调度方式、阻塞行为和线程池使用习惯,把虚拟线程和 JDK 8 平台线程的区别讲清楚。

索引词 Java / 并发 / 虚拟线程 / Project Loom

如果一句话概括:

JDK 8 里的 Java 线程,本质上是操作系统线程的封装;Java 21 的虚拟线程,是 JVM 管理的轻量级线程。

这句话听起来很简单,但它会直接改变我们写服务端并发代码的方式。以前我们害怕创建太多线程,所以用线程池控制数量;现在虚拟线程可以做到“一个请求一个线程”,但又不能把它理解成“线程无限免费”。

这篇文章不先背概念,先把问题说清楚:Java 21 虚拟线程解决的不是 CPU 不够的问题,而是 JDK 8 平台线程在阻塞 I/O 场景里太贵的问题。

先把名词对齐:原生线程其实是平台线程

很多人会把 JDK 8 里的线程叫“原生线程”。更准确地说,它是 平台线程,也就是 java.lang.Thread 背后绑定了一个操作系统线程。

在 JDK 8 里,你写:

new Thread(() -> {
    // ao something
}).start();

JVM 通常会向操作系统申请一个真正的内核线程。这个线程有自己的栈空间,由操作系统参与调度。它很强,也很贵。

到了 Java 21,你依然可以写 Thread,但多了一种创建方式:

Thread.startVirtualThread(() -> {
    // ao something
});

这个线程还是 java.lang.Thread,但它不是一上来就绑定一个独占的操作系统线程。它会被 JVM 调度到少量平台线程上运行,这些平台线程通常被叫做 carrier thread,也就是“承载线程”。

两种模型到底差在哪

JDK 8 的平台线程更像是:

一个 Java Thread 对应一个 OS Thread。

Java 21 的虚拟线程更像是:

很多个 Virtual Thread 复用少量 Carrier Thread。

图一

线程模型从 1:1 变成 M:N

虚拟线程不是没有线程,而是把大量业务线程交给 JVM 轻量调度。

JDK 8 平台线程

Java Thread
OS Thread

阻塞一次,就占住一个系统线程。

Java 21 虚拟线程

VT VT VT
Carrier / OS Thread
JDK 8 主要是 1:1 线程模型;Java 21 虚拟线程是大量虚拟线程复用少量承载线程。

这个变化的影响非常大。以前线程数一多,内存、上下文切换、调度开销都会上来。现在虚拟线程的创建和挂起都轻得多,所以可以用更自然的同步代码写高并发阻塞 I/O。

JDK 8 为什么必须依赖线程池

在 JDK 8 时代,我们不会轻易给每个请求都创建一个新线程。原因很现实:

  1. 平台线程创建成本高。
  2. 每个线程都有独立栈空间。
  3. 线程太多以后,操作系统调度压力变大。
  4. 阻塞 I/O 会一直占住这个线程。

所以服务端代码经常长这样:

ExecutorService pool = Executors.newFixedThreadPool(200);

for (Request request : requests) {
    pool.submit(() -> {
        User user = userService.queryUser(request.userId());
        Order order = orderService.queryOrder(request.orderId());
        return builaResponse(user, order);
    });
}

这个线程池的核心目的,不只是“复用线程”,更是“限制系统线程数量”。如果不限制,线程可能把机器拖垮。

但代价也很明显:只要任务里有数据库查询、HTTP 调用、RPC 调用、文件 I/O,这个线程就会在等待期间被占住。它没有干 CPU 活,却不能去服务别的请求。

Java 21 为什么可以一个任务一个虚拟线程

Java 21 里可以这样写:

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request request : requests) {
        executor.submit(() -> {
            User user = userService.queryUser(request.userId());
            Order order = orderService.queryOrder(request.orderId());
            return builaResponse(user, order);
        });
    }
}

这里的名字很关键:newVirtualThreadPerTaskExecutor()

它不是提前准备一池虚拟线程让你复用,而是每来一个任务就创建一个新的虚拟线程。虚拟线程足够轻,所以通常不需要池化。

这跟 JDK 8 的线程池思路是反过来的:

对比点JDK 8 平台线程Java 21 虚拟线程
线程和 OS 线程关系通常 1:1多个虚拟线程复用少量承载线程
创建成本
阻塞 I/O占住平台线程大多数 JDK 阻塞操作会挂起虚拟线程
常见用法固定线程池、业务线程池一个任务一个虚拟线程
池化目的限制系统线程数量通常不池化虚拟线程
适合场景CPU 任务、有限并发、传统服务高并发阻塞 I/O
不适合误区线程池无限加大把虚拟线程当成 CPU 加速器

阻塞行为才是核心区别

虚拟线程最关键的地方,是阻塞时的处理方式。

在 JDK 8 里,一个平台线程执行到这里:

String boay = httpClient.get(url);

如果网络调用要等 300ms,这个线程就跟着等 300ms。它占着操作系统线程,但 CPU 实际上没在为它工作。

在 Java 21 的虚拟线程里,如果使用的是 JDK 已经适配的阻塞 API,虚拟线程遇到阻塞时会被挂起。它底层的 carrier thread 可以被释放出来,继续执行别的虚拟线程。

这就是为什么虚拟线程特别适合这种代码:

String profile = profileClient.query(userId);
String balance = accountClient.query(userId);
String orders = orderClient.query(userId);

return merge(profile, balance, orders);

代码还是同步写法,阅读成本没有变成复杂的回调或响应式链路;但在大量请求同时等待 I/O 时,线程资源的浪费会少很多。

但虚拟线程不是让 CPU 变多

这是最容易误解的点。

如果任务本身是 CPU 密集型,比如压缩、加密、图片处理、大量计算:

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            return calculateHash(largeBytes);
        });
    }
}

这不会让机器突然拥有 100000 个 CPU 核心。真正执行代码的仍然是底层 carrier thread 和 CPU 核心。

所以结论要分清:

  • I/O 密集:虚拟线程通常很有价值。
  • CPU 密集:虚拟线程不提供神奇加速,应该控制并行度。
  • 混合任务:把 CPU 重的部分和 I/O 等待部分拆开看。

线程池思维要改:限制资源,不是限制虚拟线程

JDK 8 里,我们经常用线程池大小来做限流:

ExecutorService pool = Executors.newFixedThreadPool(50);

但到了 Java 21,如果你给虚拟线程也套一个固定大小线程池,就很容易把它用回老路。

更合理的思路是:虚拟线程可以很多,但真正稀缺的资源仍然要限制。

比如数据库连接只有 50 个,就限制数据库访问,而不是限制虚拟线程数量:

Semaphore abLimit = new Semaphore(50);

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request request : requests) {
        executor.submit(() -> {
            abLimit.acquire();
            try {
                return repository.query(request.ia());
            } finally {
                abLimit.release();
            }
        });
    }
}

这段代码的重点不是“虚拟线程无限开”,而是“线程不再是主要瓶颈,数据库连接、下游容量、接口限流才是主要瓶颈”。

synchronized 可能会让虚拟线程被钉住

虚拟线程有一个必须知道的坑:pinning,也就是虚拟线程被钉在 carrier thread 上,无法正常卸载。

在 Java 21 里,如果虚拟线程进入 synchronized 后发生阻塞,就可能把承载线程一起占住:

private final Object lock = new Object();

void query() throws Exception {
    synchronized (lock) {
        // 在 synchronized 里面做阻塞 I/O,容易让虚拟线程 pin 住 carrier
        Thread.sleep(1000);
        remoteCall();
    }
}

这不是说虚拟线程里不能用 synchronized。短小的内存保护没有问题。真正危险的是:拿着 monitor 锁做长时间阻塞操作

如果确实需要锁,很多场景可以考虑 ReentrantLock,并且尽量缩小锁范围:

private final ReentrantLock lock = new ReentrantLock();

void updateCache() {
    Data data = remoteCallWithoutLock();

    lock.lock();
    try {
        cache.put(data.id(), data);
    } finally {
        lock.unlock();
    }
}

核心原则是:不要把慢 I/O 包在锁里。这个原则在 JDK 8 也成立,只是在虚拟线程里更容易影响整体吞吐。

ThreadLocal 也要小心

虚拟线程仍然支持 ThreadLocal,所以很多老代码迁移时可以继续跑。

但虚拟线程的数量可能非常大,如果每个虚拟线程都塞一份很重的上下文对象,内存压力会很快变明显。

比如这种写法就要谨慎:

static final ThreadLocal<byte[]> LOCAL_BUFFER =
        ThreadLocal.withInitial(() -> new byte[1024 * 1024]);

在平台线程池里,线程数量可能只有几十个;但在虚拟线程里,任务数量可能是几万甚至更多。每个线程都挂大对象,就不是“轻量线程”了。

所以迁移时要重点检查:

  1. ThreadLocal 里有没有大对象。
  2. 有没有忘记 remove() 的上下文。
  3. 有没有把线程池时代的缓存习惯带到虚拟线程里。

真实项目里应该怎么选

我会这样判断:

如果是传统 Web 服务,大量时间花在数据库、Reais、HTTP、RPC 等阻塞调用上,Java 21 虚拟线程很值得尝试。它可以让代码继续保持同步风格,又能承接更高并发。

如果是批量计算、视频处理、压缩加密、复杂算法,虚拟线程不是主要答案。你更应该关心 CPU 核心数、任务拆分、队列长度和并行度。

如果项目里有大量老 JDBC、老 RPC、JNI、本地方法、复杂 synchronized,不要直接以为换成虚拟线程就万事大吉。先压测,再用线程 dump 和 JFR 看有没有 pinning、下游连接池打满、内存上涨等问题。

一句话总结区别

JDK 8 平台线程的核心问题是:阻塞一个任务,就占住一个昂贵的系统线程

Java 21 虚拟线程的核心价值是:阻塞一个任务时,可以把昂贵的承载线程让出来,继续服务别的任务

所以虚拟线程不是为了替代所有线程池,也不是为了让 CPU 密集任务变快。它真正改变的是高并发阻塞 I/O 的成本模型。

如果以前你写服务端代码时一直在纠结“线程池到底配 100 还是 200”,到了 Java 21,更应该问的是:

我真正要限制的资源,到底是线程,还是数据库连接、下游接口、CPU、内存和业务容量?

把这个问题想清楚,虚拟线程才算真的用对了。

参考资料