前段时间要写一个批量下载图片的服务,数据源是几十万个URL,需求是尽快把所有图片拉到本地。一开始按老规矩用固定大小的线程池,把任务丢进去慢慢跑。很快发现线程数设小了吞吐量上不去,设大了内存又撑不住——每个平台线程都要分一块独立的栈空间,几百个线程轻轻松松吃掉几个G内存。后来把项目切到Java 21,用虚拟线程重写了这部分,同样的硬件只改了十几行代码,吞吐量翻了好几倍,而且JVM内存稳得像条直线。
虚拟线程并不是什么魔法,它只是把Java原先那种一对一的线程模型改成了多对多——大量虚拟线程共享少量操作系统线程,调度工作交给了JVM。这样做的好处是你不需要再小心翼翼地管理线程池大小,遇到高并发任务直接为每个任务开一个虚拟线程,代码写起来跟单线程一样简单,但跑起来完全是异步非阻塞的效果。
平台线程的坑到底在哪里
传统的Java线程是直接和操作系统线程一一对应的,每个new Thread()都要向操作系统请求创建一个内核线程。这个成本不低:创建和销毁线程要涉及系统调用,每个线程默认还要预留1MB左右的栈空间。所以当并发量上来以后,要么疯狂创建线程导致内存耗尽,要么用线程池把并发数限制在几百个以内。线程池虽然避免了无限制创建,但带来了新的问题——池的大小是个玄学参数,设错了要么并发能力受限,要么上下文切换开销陡增。更麻烦的是,如果你的任务里有阻塞操作(比如等待网络响应),线程就会被卡住,白白占着一个平台线程不干活。
虚拟线程就是为了解决这个两难局面设计的。它保留了Thread类的完整API,但底层不再和操作系统线程绑定。一个虚拟线程在执行时挂载到一个操作系统线程上,遇到阻塞操作(sleep、网络IO、锁等待)时自动从载体线程上卸载下来,让出执行权给其他虚拟线程,等阻塞解除后再重新挂载继续执行。整个过程对开发者透明,不需要像写异步回调那样把代码拆得七零八落。
创建虚拟线程的方式
Java 21提供了多种创建虚拟线程的方式,最简单的是通过Thread.startVirtualThread:
Thread vThread = Thread.startVirtualThread(() -> {
System.out.println("这是一个虚拟线程");
});
如果需要更精细的控制,比如设置线程名称、捕获未处理异常,可以用Thread.ofVirtual()构建器:
Thread vThread = Thread.ofVirtual()
.name("worker-", 1)
.uncaughtExceptionHandler((t, e) -> e.printStackTrace())
.start(() -> downloadImage(url));
你还可以用Executors.newVirtualThreadPerTaskExecutor()获得一个每任务一个虚拟线程的执行器,用法和传统的线程池完全一样,但不需要指定线程数:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (String url : urls) {
executor.submit(() -> downloadImage(url));
}
}
这个执行器不会复用线程,每个提交的任务都会创建一个全新的虚拟线程。听起来很浪费,但虚拟线程的创建销毁成本极低(几乎就是new一个普通对象),这种模式反而是推荐的用法。你不需要再纠结池的大小应该设为多少,直接有多少任务就开多少虚拟线程,JVM会自动管理底层载体线程的调度。
实战案例:下载十万张图片
下面用一个完整的例子对比传统线程池和虚拟线程的性能差异。场景是下载给定URL列表中的所有图片并保存到本地目录。网络请求用Java自带的HttpClient,模拟真实的高并发IO场景。
先写一个通用的下载方法:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
public class ImageDownloader {
private static final HttpClient client = HttpClient.newHttpClient();
public static void download(String url, String outputDir) {
try {
// 从URL中提取文件名
String fileName = url.substring(url.lastIndexOf('/') + 1);
Path target = Path.of(outputDir, fileName);
// 发送HTTP请求
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofByteArray());
// 写入磁盘
Files.write(target, response.body());
} catch (Exception e) {
// 简单记录错误但不中断整个流程
System.err.println("下载失败: " + url + " - " + e.getMessage());
}
}
}
然后是传统的基于固定线程池的实现:
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class TraditionalDownloader {
public static void downloadAll(List urls, String outputDir)
throws InterruptedException {
// 用200个线程试试看——这是很多项目的经验值
ExecutorService pool = Executors.newFixedThreadPool(200);
for (String url : urls) {
pool.submit(() -> ImageDownloader.download(url, outputDir));
}
pool.shutdown();
pool.awaitTermination(10, TimeUnit.MINUTES);
}
}
再来看看虚拟线程版本,几乎就是删掉了线程池大小参数:
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class VirtualThreadDownloader {
public static void downloadAll(List urls, String outputDir)
throws InterruptedException {
// 每任务一个虚拟线程,不需要纠结池的大小
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (String url : urls) {
executor.submit(() -> ImageDownloader.download(url, outputDir));
}
}
// 所有任务提交完后,executor会自动等待它们完成
}
}
两个版本的代码在调用方式上几乎一致,区别只是执行器的创建方式不同。用一万个真实URL(指向不同的小图片文件)做了一次基准测试,在同一台16核机器上表现差异明显:
传统线程池 (200线程): 耗时 332秒,峰值内存 1.8GB
虚拟线程 (每任务一个): 耗时 41秒, 峰值内存 480MB
耗时从五分钟多降到了不到一分钟,内存也没有因为创建几万个虚拟线程而爆炸。这个结果和很多人对虚拟线程的预期一致——它是面向IO密集型任务设计的,当大量线程同时阻塞在网络等待上时,虚拟线程能让底层载体线程始终处于忙碌状态,而不会被阻塞操作闲置。
虚拟线程为什么能这么快
关键就在于“阻塞时卸载”这个机制。在平台线程模式下,200个线程中有190个都在client.send上等待HTTP响应,它们虽然什么也不做但在操作系统眼里就是活跃的线程,占着栈空间不放手。虚拟线程不同:当一个虚拟线程调用client.send等待响应时,JVM检测到这个操作会阻塞,主动把当前虚拟线程从载体线程上拆卸下来,载体线程立刻去执行其他就绪的虚拟线程。等HTTP响应到了,JVM再把对应的虚拟线程挂载到某个空闲的载体线程上继续执行。
所以不管有多少任务,载体线程的数量始终等于CPU核心数(默认值),极少发生无意义的上下文切换。开发者可以用同步阻塞风格写代码,性能却能接近异步非阻塞模型的效果。这一下子就把编程模型拉回到了简单直接的轨道上。
适用场景与不适用场景
虚拟线程最擅长的是高并发、低CPU消耗、大量阻塞等待的任务。网络请求、数据库查询、文件IO、消息队列消费这些场景都非常契合。只要你的任务里有Thread.sleep、lock.wait、或者网络和磁盘的阻塞操作,虚拟线程就能帮你把载体线程有效利用起来。
但在以下场景里,虚拟线程并不能带来明显增益,甚至会适得其反:
- 纯计算任务。 如果每个任务都是大量的数学运算,没有任何阻塞操作,虚拟线程的卸载机制根本用不上,反而因为JVM调度带来额外开销。这种情况下用传统线程池配合
ForkJoinPool是更优选择。 - 持有锁的临界区过长。 虚拟线程在
synchronized块内部发生阻塞时,当前版本还不能卸载载体线程。这是因为synchronized关联的对象监视器在实现上与平台线程绑定,强制卸载会有兼容性问题。如果代码里大量使用synchronized包裹网络调用,虚拟线程的优势会打折扣。 - 需要线程本地变量做状态传递。 虚拟线程的数量可能非常大,如果用
ThreadLocal存储大对象,内存压力会骤增。这种情况下建议改用ScopedValue等更轻量的机制。
从线程池迁移到虚拟线程的注意事项
如果你的项目正打算从传统线程池迁移到虚拟线程,以下几条经验可能对你有帮助:
不要再限制并发数量。 虚拟线程的设计让你不需要人为限制并发数,直接为每个任务创建一个虚拟线程是最优解。如果确实需要限制——比如对目标服务器施加压力控制——可以考虑用Semaphore来限制同一时刻的执行数,而不是通过线程池的大小来间接控制。
注意连接池的配置。 线程数量暴增后,如果你的任务里使用了数据库连接池或HTTP连接池,这些底层资源可能会先一步成为瓶颈。虚拟线程本身不消耗连接池资源,但每个任务都会尝试从池里获取连接,如果池的大小还是按原来几百个线程配置的,就会出现大量虚拟线程排队等连接的现象。迁移时需要重新评估连接池的大小,或者采用每虚拟线程一个独立连接的方式(如果数据库能承受)。
观察载体线程的数量。 虚拟线程最终跑在载体线程上,载体线程的数量会影响整体吞吐量。默认情况下载体线程数等于CPU核数,在纯IO场景下一般够用。如果你发现CPU利用率仍然不高、吞吐量上不去,可以通过-Djdk.virtualThreadScheduler.parallelism=N来手动调大载体线程数。
异常处理不能忽略。 虚拟线程中未捕获的异常会直接导致该虚拟线程终止,但不会影响其他虚拟线程。建议给每个虚拟线程设置uncaughtExceptionHandler,或者在任务代码里做好try-catch,避免某个任务失败后无声无息地消失。
关于可观测性
虚拟线程在调试和监控方面和平台线程略有不同。线程转储(thread dump)中虚拟线程会单独列出来,并以挂载状态标记当前是否绑定到载体线程上。Java 21的jcmd工具已经支持虚拟线程的完整信息输出。如果在生产环境中使用,确保监控工具也更新到了支持虚拟线程的版本,否则可能会出现线程数量“异常暴涨”的误报——实际上那是正常的,几十万个虚拟线程的存在完全不值得惊慌。
总结
虚拟线程并不能让单次网络请求变得更快,也不能让CPU算得更快,但它把开发高并发程序的成本拉回到了同步编程的舒适区。你不再需要为了性能去拆回调、写响应式流、小心翼翼地调线程池参数——直接写一个循环,每次迭代里用阻塞式IO处理一个任务,然后把这些迭代交给虚拟线程执行,JVM帮你搞定并行和调度。
下载十万张图片的例子已经证明了这种模式的有效性。如果你的项目运行在Java 21上且遇到IO密集型高并发需求,虚拟线程值得第一时间投入实战。

