Java 21虚拟线程实战:从Core API到Spring Boot集成,性能对比与避坑指南

2026-08-21 0 508

这阵子Java圈子讨论最热闹的除了JDK 21发布了之外,就是虚拟线程了。虽然虚拟线程在Java 19和20都是预览版,但到21终于转正,算是给Java的高并发开发带来了一种全新的思路。网上讲原理的不少,但很多都停在“是什么”和“怎么用”的层面,实际项目里一跑就会碰见各种奇奇怪怪的问题。今天我结合自己的测试和踩坑经验,把虚拟线程从基础API到Spring Boot集成,再到性能对比和注意点,一条龙讲清楚。

一、为什么虚拟线程突然这么火?

以前一提到Java高并发,脑子里就是线程池、Connections、NIO、Netty这些词。为什么?因为传统的一个Java线程对应一个操作系统线程,创建和切换成本都很高。一台普通机器开几千个线程就差不多到极限了,再多CPU光顾着做线程上下文切换,业务代码跑不了几行。

于是大家想了很多办法,异步回调、Reactor模型、CompletableFuture……代码是能写了,但调试起来真是一言难尽。而虚拟线程不一样——它由JVM管理,底层用少量平台线程承载大量虚拟线程,一个虚拟线程就是一个完整的“线程”,但创建成本极低。你可以开十万个虚拟线程去处理并发任务,而不必担心系统资源耗尽。这就是它被称作“解决了Java异步编程复杂性问题”的原因。

二、虚拟线程的核心API

Java 21中,虚拟线程的使用非常简单,主要有三种创建方式。

1. Thread.ofVirtual()

Thread vThread = Thread.ofVirtual()
        .name("virtual-thread-")
        .start(() -> System.out.println("Hello from " + Thread.currentThread()));

如果你想先创建但不启动,可以调用unstarted(),之后手动启动。

Thread unStarted = Thread.ofVirtual().unstarted(() -> doWork());
unStarted.start();

2. Executors.newVirtualThreadPerTaskExecutor()

这是更常用的方式,特别是处理大量任务时。每次提交任务都会创建一个新的虚拟线程,执行完就回收,不用自己管理线程池大小。

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10000; i++) {
        executor.submit(() -> {
            System.out.println("Task running in " + Thread.currentThread());
        });
    }
} // try-with-resources 会自动调用 shutdown

三、一个真实对比实验:传统线程池 vs 虚拟线程

光说没用,跑个实验看看效果。我模拟一个典型的IO密集型场景:每个任务里“睡”100毫秒,假装在远程调用或者查数据库。用传统的固定线程池和虚拟线程分别执行1000个任务,看谁先跑完。

实验代码

import java.util.concurrent.*;

public class VThreadCompare {
    public static void main(String[] args) throws InterruptedException {
        int taskCount = 2000;
        long sleepMillis = 100;

        // 传统固定线程池,50个线程
        ExecutorService fixedPool = Executors.newFixedThreadPool(50);
        long start1 = System.currentTimeMillis();
        CountDownLatch latch1 = new CountDownLatch(taskCount);
        for (int i = 0; i < taskCount; i++) {
            fixedPool.submit(() -> {
                try {
                    Thread.sleep(sleepMillis);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    latch1.countDown();
                }
            });
        }
        latch1.await();
        long end1 = System.currentTimeMillis();
        fixedPool.shutdown();
        System.out.println("固定线程池耗时: " + (end1 - start1) + " ms");

        // 虚拟线程,每个任务一个虚拟线程
        ExecutorService vPool = Executors.newVirtualThreadPerTaskExecutor();
        long start2 = System.currentTimeMillis();
        CountDownLatch latch2 = new CountDownLatch(taskCount);
        for (int i = 0; i < taskCount; i++) {
            vPool.submit(() -> {
                try {
                    Thread.sleep(sleepMillis);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    latch2.countDown();
                }
            });
        }
        latch2.await();
        long end2 = System.currentTimeMillis();
        vPool.shutdown();
        System.out.println("虚拟线程耗时: " + (end2 - start2) + " ms");
    }
}

结果分析

在我的测试机上,2000个任务,每个睡100ms:

  • 固定线程池(50线程):大约4000ms左右
  • 虚拟线程:大约105ms,几乎就是第一个任务结束后的时间

原因很简单:虚拟线程在sleep时释放了底层平台线程,让其他虚拟线程顶上。而固定线程池里的50个线程只能反复调度,排队等待。看到这个差距,你是不是也手痒了?

四、Spring Boot 3 中使用虚拟线程

如果你用的是Spring Boot 3.2及以上版本,开启虚拟线程简直不要太简单。只用改一行配置文件:

spring:
  threads:
    virtual:
      enabled: true

这一个配置会同时影响Tomcat的Web容器线程和@Async异步方法的执行器。也就是说,你的Controller默认就会跑在虚拟线程上,不用改任何业务代码。前提是你的Spring Boot版本是3.2+,并且Java版本是21+。

如果是Spring Boot 3.0或3.1,就需要自己创建一个Tomcat定制器来设置虚拟线程执行器,稍微麻烦一点,不过也可以这么干:

@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadProtocolHandlerCustomizer() {
    return protocolHandler -> {
        protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    };
}

五、实战中的4个大坑,千万别踩

虚拟线程看着很美,但用错了照样翻车。下面这几个坑是我自己踩过的,也是社区里常见的问题。

坑1:synchronized会钉住平台线程

如果在虚拟线程里用了synchronized同步块,并且同步块里的操作涉及IO(比如sleep、网络请求),那么JVM的调度器可能会阻塞底层平台线程,导致虚拟线程调度无法复用该平台线程。这就是所谓的“Pinned Thread”(线程钉住)。

解决办法:尽量用ReentrantLock代替synchronized,因为ReentrantLock的lock/unlock在JDK 21中已经做了特殊处理,不会被钉住。

坑2:ThreadLocal滥用导致内存爆炸

虚拟线程非常便宜,你可能会一次性创建几十万个。如果每个虚拟线程里都放了ThreadLocal,并且没有清理,那内存压力会相当大。因为ThreadLocal是绑定在线程实例上的,虚拟线程同样继承了这一点。

建议:要么别用ThreadLocal,要么在finally里调用remove()。尤其是使用虚拟线程池时,线程不是复用的,用完就丢,ThreadLocal未清理的对象就变成垃圾了,但实际上并不会立刻回收,因为JVM线程栈还在整理。所以能不用就不用。

坑3:CPU密集型任务请绕道

虚拟线程的强项是IO密集,如果任务里全是CPU计算,比如大循环、加密解密,虚拟线程不仅没有优势,还会因为线程切换增加性能开销。因为CPU核数是固定的,虚拟线程在CPU密集场景下无法并行更多,反而多了一层调度。

坑4:Tomcat的连接器限制

如果你在Spring Boot中启用了虚拟线程,Tomcat的默认接受连接数可能成为瓶颈。默认的acceptCount是100,maxThreads是200,但虚拟线程模式下maxThreads几乎可以设置得非常大。建议把maxThreads调到10000,并适当调整acceptCount,否则流量一大,连接照样拒绝。

server:
  tomcat:
    accept-count: 1000
    max-threads: 10000

六、虚拟线程与JDBC、数据库连接池

很多人担心虚拟线程跟JDBCTemplate、MyBatis的兼容性。其实完全没问题,因为JDBC调用本身就是阻塞的,虚拟线程最擅长的就是把阻塞时间让给别的线程。但要注意:数据库连接池的大小需要重新考虑。以前一个Tomcat容器用200个线程,我们配20个连接就够。现在虚拟线程可以有5000个,如果每个请求都分一个数据库连接,那连接池就崩了。所以虚拟线程模式下,连接池要么调大,要么用像HikariCP这类支持动态增长的池(但也要设置最大上限),或者直接在应用层做信号量限流。

七、一段完整的“虚拟线程应用”示例

写一个小程序,模拟一个Web服务器用虚拟线程处理请求:每个请求先查缓存(sleep 20ms),再查数据库(sleep 200ms),最后返回。用固定线程池和虚拟线程分别跑,看吞吐量。

import java.util.concurrent.*;
import java.util.*;

public class SimulatedWebServer {
    public static void main(String[] args) throws InterruptedException {
        int requests = 500;
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        // 如果改成 newFixedThreadPool(100) 再跑一下试试
        CountDownLatch done = new CountDownLatch(requests);
        long start = System.currentTimeMillis();

        for (int i = 0; i < requests; i++) {
            final int id = i;
            executor.submit(() -> {
                try {
                    // 模拟缓存查询
                    Thread.sleep(20);
                    // 模拟数据库查询
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    done.countDown();
                }
            });
        }
        done.await();
        long cost = System.currentTimeMillis() - start;
        System.out.println("总耗时: " + cost + " ms, 平均响应时间约: " + (cost / (double) requests) + " ms");
        executor.shutdown();
    }
}

跑一次你会发现:500个请求,固定100线程池大概需要1秒多,而虚拟线程只需要220ms左右,因为大量sleep被让出来了。

八、总结

虚拟线程这个特性,从API设计到实际表现,都算是Java这几年里最有价值的功能之一。它让“用同步代码写高并发”成为可能,也让我们少学一大堆Reactive框架。但凡事都有两面,虚拟线程不是银弹,用之前先判断业务是IO密集还是CPU密集,再想想线程局部变量和同步锁的用法,才能真正发挥它的威力。强烈建议你在自己的项目里跑一跑实验,感受一下差距。

如果你正在用Spring Boot,直接把虚拟线程开关打开,大多数接口不用改就能提升并发能力。但注意监控Tomcat的线程数和连接池状态,遇到问题再回到上面提到的那几个坑里找原因。

希望这篇实战能帮到你。有更好玩的想法,欢迎在评论区里交流。

Java 21虚拟线程实战:从Core API到Spring Boot集成,性能对比与避坑指南
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java Java 21虚拟线程实战:从Core API到Spring Boot集成,性能对比与避坑指南 https://www.taomawang.com/server/java/2583.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务