前两天接手一个旧模块,是处理订单导入的。用户上传一个Excel,里面一万多条订单,需要逐条调用外部接口查重,再写进本地库。原来的实现用的是线程池,核心线程数配了8,结果每次导入都要跑将近二十分钟。不仅慢,还时不时的内存溢出,因为每次Excel里的数据量一旦超过两万,那8条线程忙不过来,队列里的任务越堆越多,最后直接堆死。
我本来想调大线程池,但发现外部接口的响应时间平均在80毫秒左右,就算开到20个线程,也就能快一倍,治标不治本。后来想起Java 21之后正式有了虚拟线程,就顺手试了一下,结果出乎意料,同样的数据,执行时间从二十分钟降到了三分钟。整个过程可以说无痛升级,代码改动不到十行。
为什么以前这么慢?
问题就出在“一对一”的线程模型上。每条线程处理一条订单的时候,大部分时间都在等外部接口的响应。这80毫秒完全被浪费了,因为线程卡在HttpClient的IO阻塞上,CPU其实没有事情干。但线程自己不能去处理别的任务,只能干等。
线程池大小8,每条任务80毫秒IO,1万条任务就是 10000/8 * 80ms ≈ 100秒,再加上数据库操作,差不多二十多分钟。这不是计算密集,纯粹是阻塞造成的低效。
如果用虚拟线程,每一条任务可以分配一个虚拟线程,数量上限不受平台线程限制。虚拟线程在遇到IO阻塞时,会自动释放底层平台线程,然后切换到其他虚拟线程。这种方式让程序在等待外部接口的时候,CPU还能继续处理别的任务,等于把等待时间给“填上”了。
改造前的代码长啥样
先看一下老代码的大概结构:
ExecutorService executor = Executors.newFixedThreadPool(8);
List<Order> orders = readFromExcel(file);
for (Order order : orders) {
executor.submit(() -> {
boolean exists = externalQuery(order.getId());
if (!exists) {
orderDao.insert(order);
}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
外部的externalQuery方法内部就是个HTTP调用,可以想象成HttpClient的send(),会阻塞当前线程。这种代码在Java 8时代没什么问题,但是放到高并发下就会很难受。
改成虚拟线程,代码更简单
Java 21开始,Executors工具类多了个newVirtualThreadPerTaskExecutor(),直接用它替换原来的固定线程池就行了:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
List<Order> orders = readFromExcel(file);
for (Order order : orders) {
executor.submit(() -> {
boolean exists = externalQuery(order.getId());
if (!exists) {
orderDao.insert(order);
}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
就这么简单?是的。但等等,生产环境我肯定不能这么直接上,因为一万个订单同时发起外部请求,很可能把下游接口给打死。我们得限制并发数,虚拟线程不是让你没用限制的乱用的,下游服务扛不住你还是要把它限制住。
加一个并发限制,防止把下游打爆
虚拟线程的特点是平台线程不受限,但下游服务一般有自己的承受上限。我这里用了一个最简单的Semaphore,控制在同一个时刻最多有40个虚拟线程在调外部接口:
Semaphore limiter = new Semaphore(40);
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (Order order : orders) {
executor.submit(() -> {
try {
limiter.acquire();
boolean exists = externalQuery(order.getId());
if (!exists) {
orderDao.insert(order);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
limiter.release();
}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
这样既利用了虚拟线程的轻量性,又控制住了对下游的并发压力。40个并发不算高,下游接口稳稳当当。在这个并发数下,一万条任务的总耗时是多少呢?差不多三分钟。如果我把Semaphore的数值调大到80,耗时还会缩短到两分钟以内,但是需要下游接口能抗住。
踩了一个坑:在synchronized里做阻塞IO
你可能在网上看到过一种说法:虚拟线程使用synchronized的时候,可能会被平台线程“钉住”(pinning),导致虚拟线程无法释放平台线程,特别是碰到synchronized代码块或者方法内部发生了阻塞IO,会让虚拟线程退化成平台线程。
我一开始不信,后来在一个定时任务里遇到了类似情况。当时有一个缓存锁是用synchronized写的,虚拟线程执行到那里,再进行HTTP调用,结果整体性能反而跟普通线程池差不多。排查了半天才想起“钉住”这个概念,后来把synchronized换成了ReentrantLock,性能才恢复正常。
所以后来我在项目里约定:虚拟线程里尽量不要用synchronized,如果是用了,那把锁范围尽量调小,并且在锁里面不要做网络IO或数据库操作。如果确实需要,就用ReentrantLock。
实际效果
改造完之后我统计了一下线上的运行数据:
- 一万条订单导入,以前平均耗时约21分钟,现在平均耗时约3分20秒。
- 内存占用反而降了,因为不再需要庞大的线程池队列来堆积任务。
- 部署时间也快了,因为虚拟线程是JVM级别的轻量对象,创建几乎零开销。
最让我意外的是,整个改动就是这么小。没有重构业务代码,把newFixedThreadPool(8)换成newVirtualThreadPerTaskExecutor(),再补上Semaphore控制流量,完事。
最后说点实在的
虚拟线程不是银弹,它解决的是“高并发IO密集型”场景下的问题。如果你是想提高CPU密集型计算的速度,虚拟线程帮不上忙,用虚拟线程反而因为线程切换变得更慢。但像我们这种主要时间花在等外部接口返回的,虚拟线程简直是救星。
还有一点,老项目用的是Java 11的话,就没法直接用虚拟线程了。不过目前Java 21已经是很普及的LTS版本,如果公司还没有升级,建议找机会推动一下。就为了虚拟线程这个特性,也值得升级一把。
另外,虽然虚拟线程的API使用非常便利,但真正决定性能上限的还是下游服务。我建议每个团队都做一个“并发阈值”的预算,比如下游允许40并发,那Semaphore就设置为40,不要贪多。否则虚拟线程再强,下游挂了也是白搭。
好了,这次的实战经验就分享到这。如果你也有类似的批处理任务或者IO密集的服务,不妨把线程池换成虚拟线程试试。反正我改完以后,运营那边再也没有找我抱怨过导入太慢,这个周末终于能好好休息了。

