我手头有一个做了三年的数据采集服务,负责从十几个上游接口抓数据,每个请求耗时很不均匀——有的 50 毫秒返回,有的要等三四秒。服务跑在一台 8 核 16G 的机器上。原来的实现非常朴素:固定 200 个线程的池子,队列大小 500。
平时流量不大,200 个线程绰绰有余。怕的是上游抽风。上一次某个接口响应从 80 毫秒涨到 5 秒,200 个线程几分钟就被占满,队列眨眼堆到 500,之后所有请求直接拒绝。整个服务差不多半小时处于半瘫状态。
Java 21 把虚拟线程正式做出来之后,”一个任务一个线程”这种老派的写法终于不用再心疼。理论上这种 IO 密集、单任务又长的场景就是给虚拟线程量身定做的。上个月我把这个服务改了一遍,过程中撞到两个坑,这篇文章把整条路径记录下来。
改之前的代码长什么样
去掉业务细节,核心就是一个 ExecutorService:
public final class FetcherService implements AutoCloseable {
private final ExecutorService pool = new ThreadPoolExecutor(
200, 200,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
new ThreadPoolExecutor.AbortPolicy()
);
public String fetch(String url) throws Exception {
Future<String> future = pool.submit(() -> httpGet(url));
try {
return future.get(10, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw e;
}
}
private String httpGet(String url) throws IOException {
// 使用 java.net.http.HttpClient
...
}
@Override
public void close() {
pool.shutdown();
}
}
这段代码有两个特点:池子大小是硬边界,请求排队靠 ArrayBlockingQueue。当上游慢下来的时候,队列是唯一缓冲,一旦满了就 AbortPolicy 拒绝——和前面描述的现场一致。
改造成虚拟线程有两种方向。一种是把线程池换成 Executors.newVirtualThreadPerTaskExecutor(),另一种是把 Thread.Builder 拿出来直接 startVirtualThread。前者改动最小,语义上依然是一个 Executor,submit/get 的用法完全不变。
第一版:一行替换
private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
就这一行。跑起来,功能正常。之后压了一次,发现了一个奇怪的数字:
固定线程池,200 并发: 吞吐量约 1800 req/s
虚拟线程池,无限并发: 吞吐量约 1450 req/s
不仅没变快,反而慢了一点。虚拟线程理论上在 IO 密集场景要比平台线程高效得多,这里却没有体现出来,说明有个瓶颈挡在路上了。
先用 jcmd 看一眼线程状态:
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
输出里能看到大量虚拟线程的状态是 RUNNABLE,但 CPU 使用率并不高。RUNNABLE 还占着载体(carrier)线程不放——这是典型的”线程固定“(pinning)症状。虚拟线程被固定在了它的载体线程上,不能在被阻塞时把载体让出来干别的活。
虚拟线程被”固定”的常见原因只有两个:
- 在
synchronized块或方法里执行了阻塞操作 - 调用了 native 方法或 Foreign Function 接口,且该调用阻塞了载体线程
第二个不常见,第一个几乎是一抓一个准。翻代码,果然:
private final Object lock = new Object();
private String httpGet(String url) throws IOException {
synchronized (lock) {
// 有一段缓存读取逻辑
String cached = cache.get(url);
if (cached != null) return cached;
// 真正发请求
String body = doRequest(url);
cache.put(url, body);
return body;
}
}
这是一段三年前写的缓存逻辑,本来用了 synchronized 是想避免同一 URL 并发穿透。问题就在于 doRequest 这个阻塞操作——HTTP 请求——被放在了 synchronized 块里。
为什么 synchronized 会固定虚拟线程
要理解这个问题,得先知道 synchronized 在 JVM 里的实现方式。
synchronized 在字节码层面用的是 monitorenter / monitorexit 指令,对应对象头里的 Monitor。Monitor 的所有权是绑定在操作系统线程上的——它知道是哪个 OS 线程持有了这个锁,也是为了应对操作系统层面的线程抢占和优先级处理。
虚拟线程是 JVM 自己调度的,多个虚拟线程会跑在同一个平台线程之上。当虚拟线程要阻塞(比如等 HTTP 返回)的时候,正常情况下 JVM 会把这个虚拟线程从载体上摘下来,把载体还给别的虚拟线程用。但如果此刻它正持有一个 Monitor,JVM 不能把虚拟线程摘走——因为 Monitor 的持有者信息还挂在载体线程上,摘走了就没人来释放了。于是它只能让载体线程一直等着,这就是”固定”。
固定本身不是 bug,只是性能损失。但如果一个老服务里到处都是 synchronized,性能损失会累积到完全体现不出虚拟线程的优势,甚至会因为大量载体线程被白白占用而变得更慢。
官方的建议很明确:需要保护临界区的地方,应该用 ReentrantLock 替代 synchronized。前者是 java.util.concurrent.locks 里的类,不绑定在 OS 线程上,虚拟线程可以被正确挂起。
改造临界区
private final ReentrantLock cacheLock = new ReentrantLock();
private String httpGet(String url) throws IOException {
cacheLock.lock();
try {
String cached = cache.get(url);
if (cached != null) return cached;
String body = doRequest(url);
cache.put(url, body);
return body;
} finally {
cacheLock.unlock();
}
}
再压一次:
固定线程池,200 并发: 吞吐量约 1800 req/s
虚拟线程池,第一次改造: 吞吐量约 1450 req/s
虚拟线程池,替换锁之后: 吞吐量约 9200 req/s
差了两个数量级。最直观的一个变化是并发上限从”池子大小”变成了”下游允许的连接数”,在测压环境没有开连接限流的前提下,虚拟线程把上游的响应时间完全重叠掉了。
值得注意的是,这一切都没有修改 HTTP 客户端的任何参数,也没有换异步编程模型。代码还是同步的、一个线程一个请求,只是换了一个执行器,改了一个锁。
第二个坑:ThreadLocal
调优到这里,功能已经稳定了,但还有一件事值得处理一下。项目里有一段老的日志 MDC 逻辑:
public class TraceIdHolder {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void set(String id) {
TRACE_ID.set(id);
}
public static String get() {
return TRACE_ID.get();
}
public static void clear() {
TRACE_ID.remove();
}
}
在虚拟线程模型里,这个类的语义会悄悄发生变化。
表面上看它是线程私有的,TRACE_ID.set() 只影响当前线程。但虚拟线程的创建成本极低,允许一个进程里同时存在几十万乃至上百万个虚拟线程。每一个虚拟线程都有自己独立的 ThreadLocal 存储,一份 ThreadLocalMap。如果忘记 remove,而线程生命周期又很长,内存就会被慢慢撑起来。
虚拟线程被切换到不同载体上的时候,它的 ThreadLocalMap 也跟着它一起走。所以”线程销毁就自动清理”这个隐式保障,在虚拟线程上其实依然成立——只要虚拟线程真的结束了。它不成立的地方在于:有人创建了虚拟线程池(虽然一般不建议)或者创建了长生命周期的虚拟线程并复用它们。这时候 ThreadLocal 就跟着一起变成了”长期驻留”。
更麻烦的是有些库在 ThreadLocal 里塞了不小的对象——Session、UserContext、缓存对象。一个进程里几十万个虚拟线程,每个塞一个 100KB 的对象,就是几十 GB 的占用,会直接把服务干翻。
用 Scoped Value 替代部分 ThreadLocal
Java 21 里引入的 ScopedValue(JEP 446,在 Java 25 中正式定型)就是来解决这个问题的。它不把值存在线程上,而是存在”作用域”上。作用域结束值就消失,没有清理负担。
public final class TraceContext {
public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
public static <T> T runWith(String traceId, Callable<T> task) throws Exception {
return ScopedValue.where(TRACE_ID, traceId).call(task);
}
public static String current() {
return TRACE_ID.isBound() ? TRACE_ID.get() : "anonymous";
}
}
调用的地方:
String result = TraceContext.runWith(
UUID.randomUUID().toString(),
() -> fetcher.fetch(url)
);
跟 ThreadLocal 相比,好处很直接:
- 不需要显式
remove,作用域退出自动清空 - 值是不可变的,作用域内无法改
- 虚拟线程继承值的时候,是共享引用,不复制一份(也就是说 100 万虚拟线程共享同一个 traceId,占用只有一份)
它的限制也是明确的:只能在 runWith/call 的闭包里读,闭包外就看不见。所以它替代的是”请求上下文”这类场景,替代不了”每个线程需要判断自己状态的私有缓存”。
我这边把所有 TraceId、UserContext、TenantContext 相关的 ThreadLocal 都换成了 ScopedValue,剩下少数几个真正是”线程本地”的缓存还保留着 ThreadLocal,但都加了一次性的显式清理。
两个辅助诊断的小工具
过程中用得比较多的两个命令,记一下。
第一个是查看线程固定:
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
输出的 JSON 里每个虚拟线程都有 carrierThread 字段。如果某个载体线程上同时挂着几十个 RUNNABLE 状态的虚拟线程,同一个时刻都卡在那里不动,基本可以确认是固定了。
第二个是启动参数打开固定事件告警:
-Djdk.tracePinnedThreads=full
加上之后 JVM 会在检测到线程固定时往标准输出打一段栈。开发阶段建议打开,改完之后再关掉——生产环境打开会加一点日志开销。
还有一个细节:jdk.tracePinnedThreads 早期是 short 和 full 两个选项,后来官方统一改了行为,新版 JDK 上直接用 full 就行。老版本上如果只打了 full 没生效,可以试试 short。
什么时候不该用虚拟线程
改造完成后我梳理了一下,发现虚拟线程明显不擅长的场景有几个。
CPU 密集型任务。虚拟线程的优势来自”阻塞时让出载体”,如果任务根本不阻塞,让它跑满 CPU,那载体数量就等于 CPU 核心数,跟平台线程表现一样。这种场合还不如老实用 ForkJoinPool。
下游资源有限、需要背压的场景。虚拟线程没有”池子大小”这个概念,你用 newVirtualThreadPerTaskExecutor 就意味着并发没有上限。如果下游数据库连接池只有 30 个连接,同时开 5000 个虚拟线程去抢,只会有一大堆等连接、超时、重试。这种场合要么给任务加信号量,要么老老实实保留有界线程池。
精确控制执行时机的地方。虚拟线程由 JVM 调度,它什么时候真正开始跑不由你说了算。需要按优先级或顺序调度的时候,ThreadPoolExecutor 的语义更好把握。
用到了 native 方法或 FFI 且会阻塞的库。这些调用会固定虚拟线程,跟 synchronized 是同一个问题。JDBC 驱动如果用了本地实现,也会碰到这个坑。好在主流驱动基本都改过了。
改造之后的最终样子
public final class FetcherService implements AutoCloseable {
private final ExecutorService pool =
Executors.newVirtualThreadPerTaskExecutor();
private final ReentrantLock cacheLock = new ReentrantLock();
private final Map<String, String> cache = new ConcurrentHashMap<>();
public String fetch(String url) throws Exception {
Future<String> future = pool.submit(() -> httpGet(url));
try {
return future.get(10, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw e;
}
}
private String httpGet(String url) throws IOException {
cacheLock.lock();
try {
String cached = cache.get(url);
if (cached != null) return cached;
String body = doRequest(url);
cache.put(url, body);
return body;
} finally {
cacheLock.unlock();
}
}
private String doRequest(String url) throws IOException {
// java.net.http.HttpClient 实现
...
}
@Override
public void close() {
pool.shutdown();
}
}
改动幅度不大:一行执行器,一处锁,外加把 trace 相关的上下文换成了 ScopedValue。但吞吐量上的差异是数量级的,而且整个服务在极端上游延迟下的表现比原来稳得多——不会再有”线程被占满,队列爆掉”这种场景,因为池子这个概念已经不存在了。
一点个人感受
虚拟线程的引入门槛其实比想象中低。代码不需要重写成 CompletableFuture 链,不需要学响应式编程,同步阻塞的代码直接搬过去就行。真正需要费心思的地方在于——
把持有资源的方式和保护资源的锁,从”和线程绑定”改成”和作用域绑定”。
ThreadLocal 是绑在线程上的,虚拟线程让线程变成了廉价品,捆绑就失去了意义。synchronized 的锁也绑在 OS 线程上,虚拟线程要挂起的时候挂不动,必须换成 ReentrantLock 这类 java.util.concurrent 里的实现。
想清楚这一点,重构的方向就清楚了。剩下的就是逐个扫描那些用了 synchronized 和 ThreadLocal 的老代码,一处一处处理。这次我在一个模块里改了差不多十处,每一处都不是新技术,就是把老工具换成对的工具。

