去年做了一次大促压测,一个订单聚合接口在 500 并发下 P99 跑到 1.4 秒,监控面板上线程池的队列深度一直贴着上限。当时第一反应是”线程池给小了”,把 32 调到 64,指标好看了两天,第三天流量再上一个台阶,又有一样的味道。
问题其实不在池子大小。这个接口要并发去调四个下游:订单主表、商品明细、物流、优惠券。四个都发出去之后等结果,最慢的那个决定整体耗时。下游的 P99 在 300ms 左右,但均值只有 40ms。固定线程池一旦被这些长尾请求占住,后面的请求连”发出去”的机会都没有,只能排队——排队时间被算进了响应时间,于是 P99 越堆越高。
这就是典型的 I/O 等待型瓶颈。虚拟线程正好治这个病。但真正动手改的时候,我发现麻烦不在”换执行器”那一步,而在于项目里散落各处的 ThreadLocal。这篇把整个过程和踩的坑记下来。
一、先确认三件事,别急着改代码
我承认一开始想直接动手,被同事拦了一下,让先把下面三点确认清楚。事后看这个建议非常对。
1. JDK 版本够不够
这是最关键的一条。在 JDK 24 之前,synchronized 块会让虚拟线程”钉住”(pin)它所在的载体线程,导致这个载体线程没法去跑别的虚拟线程。 如果代码里有大量 synchronized,虚拟线程的好处会被吃掉一大半。
JDK 24 通过 JEP 491 解决了这个问题,synchronized 不再导致 pin。我们当时的生产环境刚升到 JDK 25 LTS,所以这一关直接过了。
如果你们还停在 JDK 21,那就得先把热点路径上的 synchronized 换成 ReentrantLock,再考虑虚拟线程。这一步不做,后面的收益会很有限。
顺便说一句,pin 并没有完全消失。JNI 原生调用、类初始化期间仍然会 pin。只是这两类在我们项目里极少出现在高并发路径上。
2. 线程池是不是被当成限流器在用
这个问题更隐蔽。很多项目里,Executors.newFixedThreadPool(32) 不只是线程资源,它同时承担了”最多同时打 32 个请求给下游”这个限流职责。一旦换成虚拟线程,这个隐式限流就没了,下游可能瞬间被打爆。
我们那次就是。改完当天下午,下游物流服务的人找过来说你们量怎么涨了三倍。后来加了个 Semaphore 才压住。
3. ThreadLocal 的影响面有多大
用 IDE 全局搜了一遍 ThreadLocal,项目里一共三处:
- 链路追踪的 traceId
- MyBatis 的租户 ID 传递
- 一个自研的限流组件里存令牌桶
前两个本质上都是”请求上下文”,第三个是”随线程复用的资源”。这两种情况在虚拟线程下表现完全不同,得分开处理。
二、先看看改造前的样子
核心的聚合逻辑大概长这样:
@Service
public class OrderAggregateService {
private static final ExecutorService POOL = Executors.newFixedThreadPool(32);
public OrderDetail aggregate(String orderId) throws Exception {
Future<Order> orderF = POOL.submit(() -> orderClient.get(orderId));
Future<List<Item>> itemsF = POOL.submit(() -> itemClient.listByOrder(orderId));
Future<Logistics> logisticsF = POOL.submit(() -> logisticsClient.byOrder(orderId));
Future<Coupon> couponF = POOL.submit(() -> couponClient.byOrder(orderId));
return new OrderDetail(
orderF.get(800, TimeUnit.MILLISECONDS),
itemsF.get(800, TimeUnit.MILLISECONDS),
logisticsF.get(800, TimeUnit.MILLISECONDS),
couponF.get(800, TimeUnit.MILLISECONDS)
);
}
}
traceId 是通过一个 ThreadLocal 在 Filter 里绑定的:
public final class TraceContext {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void bind(String id) { TRACE_ID.set(id); }
public static String get() {
String id = TRACE_ID.get();
return id == null ? "-" : id;
}
public static void unbind() { TRACE_ID.remove(); }
}
@Component
public class TraceFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
TraceContext.bind(traceId);
try {
chain.doFilter(req, res);
} finally {
TraceContext.unbind();
}
}
}
Tomcat 的工作线程处理完请求后会回池复用,所以 finally 里的 unbind 是必需的,不写就会串号。
三、第一步改造:换执行器
最直接的一步就是换掉执行器:
private static final ExecutorService POOL = Executors.newVirtualThreadPerTaskExecutor();
就这一行,接口的 P99 立刻从 1.4s 掉到 600ms 左右。原因很简单:每个 submit 都会新建一个虚拟线程,四个下游调用各自独立,不再互相排队等池子里的位置。
但要特别注意:这不是线程池。 newVirtualThreadPerTaskExecutor() 名字里虽然有 Executor,但它每个任务创建一个新虚拟线程,不做池化。你没法”预热”它,也不该去限制它的核心线程数——那等于把虚拟线程的意义抹掉了。
另外,虚拟线程对象本身很轻,但它背后还是有 JVM 和操作系统的开销。真的每秒创建几百万个,仍然会有问题。所以生产环境该限流还是要限流。
四、traceId 全丢了
EAP 环境跑了一轮功能测试,日志看起来不太对:聚合之后那几行下游调用的日志,traceId 全变成了 -。
原因很清楚。ThreadLocal 的值是绑在”线程”这个身份上的。Tomcat 的工作线程在 Filter 里 bind 了 traceId,然后请求进入了 Controller,进 Service,再 submit 到执行器。虚拟线程是全新的线程,它自己的 ThreadLocal 表是空的,读不到任何东西。
最原始的修法是在每个提交的任务里手动 set / clear:
POOL.submit(() -> {
TraceContext.bind(traceId);
try {
return orderClient.get(orderId);
} finally {
TraceContext.unbind();
}
});
能跑通,但我不想这么写。四个任务就是四份一模一样的样板,将来加第五个下游就得再抄一遍。而且只要有一个人忘了 finally,虚拟线程结束之后 ThreadLocal 表不会被自动清理——虽然线程会销毁,但那段时间里的日志和内存都是脏的。
更麻烦的是租户 ID。租户 ID 是在 Service 层的某个拦截切面里 set 进去的,位置比 traceId 更深。要让它在虚拟线程里可见,得从切面一路把值透传到每个任务,代码会变得很难看。
五、换成 ScopedValue
ScopedValue 在 JDK 25 里转正了(JEP 506),可以放心用在生产环境。
它和 ThreadLocal 最大的区别有三个:
一是不可变。 值只能在进入作用域的时候绑一次,作用域内没法改,嵌套绑定会新建一个作用域而不是覆盖。
二是作用域自动结束。 从 run / call 里出来,绑定就没了,不需要手写 remove。这一点直接消除了大部分 ThreadLocal 内存泄漏的来源。
<strong三是它不是"每个线程一份副本"。 同一个绑定值可以被作用域内的所有线程共享读取,读取的开销比 ThreadLocal.get() 更低。
改造后的 TraceContext 变成一个几乎没有逻辑的类:
public final class TraceContext {
public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
public static final ScopedValue<String> TENANT_ID = ScopedValue.newInstance();
private TraceContext() {}
public static String get() {
return TRACE_ID.isBound() ? TRACE_ID.get() : "-";
}
public static String tenant() {
return TENANT_ID.isBound() ? TENANT_ID.get() : "default";
}
}
注意 isBound() 这个判断。直接对未绑定的 ScopedValue 调 get() 会抛 NoSuchElementException,不是返回 null。 这一点和 ThreadLocal 的直觉不一样,第一次用很容易在异步线程或者定时任务里踩到。
绑定和解绑的写法:
String traceId = newTraceId();
ScopedValue.where(TraceContext.TRACE_ID, traceId)
.run(() -> {
// 这里以及下面所有同步调用链里都能读到 traceId
chain.doFilter(req, res);
});
整个过程没有 remove,没有 finally,从 lambda 出来绑定自动失效。Filter 里的 unbind 那段可以整块删掉。
关于跨线程的可见性
这里有个容易被忽略的细节。ScopedValue 的绑定会被”在作用域内创建的线程”继承,而虚拟线程恰恰是 submit 的那一刻才创建的。所以理论上,在作用域里提交的任务能直接读到 traceId。
但我们在实际项目里没有依赖这个行为。原因是团队里有人还在用 JDK 21 做本地开发,而 ScopedValue 几次预览版本之间在这个行为上有过调整。为了不给自己挖坑,我们统一走显式重新绑定:
private <T> Callable<T> bound(Callable<T> task) {
String traceId = TraceContext.get();
String tenant = TraceContext.tenant();
return () -> ScopedValue.where(TraceContext.TRACE_ID, traceId)
.where(TraceContext.TENANT_ID, tenant)
.call(task);
}
这里利用了 Carrier.where(...) 可以链式叠加多个绑定。调用侧就很干净了:
Future<Order> orderF = POOL.submit(bound(() -> orderClient.get(orderId)));
租户 ID 的切面也不用再往下透传了——切面里绑一次,下面所有同步代码和虚拟线程都能通过 TraceContext.tenant() 读到。
顺便提一下:提前创建好的固定线程池里的线程读不到。 因为那些线程是在应用启动时就建好的,当时不在任何作用域里。这一点和虚拟线程正好相反,如果你的代码里两种执行器混用,得想清楚。
六、把限流补回来
前面说过,固定线程池承担了隐式限流。换掉之后必须显式补一个:
private static final Semaphore DOWNSTREAM_PERMITS = new Semaphore(96);
private <T> Callable<T> bound(Callable<T> task) {
String traceId = TraceContext.get();
return () -> ScopedValue.where(TraceContext.TRACE_ID, traceId)
.call(() -> {
DOWNSTREAM_PERMITS.acquire();
try {
return task.call();
} finally {
DOWNSTREAM_PERMITS.release();
}
});
}
96 这个数字是压测摸出来的:四个下游加起来,能扛住不报警的上限大概在这里。如果换成信号量之后发现请求开始排队,说明下游确实是瓶颈,这时候应该去优化下游,而不是把信号量调大——虚拟线程解决了”平台线程不够”的问题,但解决不了”下游就是慢”的问题。
这里还有一个坑要提醒:数据库连接池的容量不会因为虚拟线程而变大。 我们用的是 HikariCP,池子 20 个连接。换成虚拟线程之后,如果某个查询走的是同步 JDBC,那 20 个连接就是硬上限,多出来的虚拟线程只能挂在连接池的等待队列上。所以在虚拟线程场景下,数据库这一层的限流同样要用 Semaphore 控住,不能指望连接池自己兜住。
七、日志的 MDC 怎么办
日志框架的 MDC 内部也是 ThreadLocal,所以虚拟线程里一样读不到。
最省事的做法是不用 MDC 了,直接让 logback 从一个自定义的转换器里取值。写一个 ClassicConverter:
public class TraceIdConverter extends ClassicConverter {
@Override
public String convert(ILoggingEvent event) {
return TraceContext.get();
}
}
logback 配置里注册一下,然后 pattern 里用 %traceId 代替 %X{traceId}:
<conversionRule conversionWord="traceId"
converterClass="com.example.log.TraceIdConverter"/>
<pattern>%d{HH:mm:ss.SSS} [%thread] [%traceId] %-5level %logger{36} - %msg%n</pattern>
这样无论日志是在 Tomcat 线程、虚拟线程还是定时任务线程里打的,读到的都是同一套逻辑,不需要在每个异步任务里手动 put 一遍。
有一个地方要注意:converter 里读 ScopedValue 是有成本的,如果日志量特别大,建议在里面做一个简单的缓存或者只在必要级别启用。我们压测下来这部分开销可以忽略,但流量再翻一倍可能就要重新评估。
八、怎么确认改造真的生效了
改完之后靠什么判断?我们用了几种手段:
线程数量。 传统线程池模式下,jvm.threads.live 基本稳定在一个区间。换成虚拟线程之后,这个指标会随着并发量明显波动,因为虚拟线程也计入其中。如果它依然是一条平线,那虚拟线程很可能根本没跑起来。
线程转储。 jcmd <pid> Thread.dump_to_file -format=json /tmp/dump.json 能把虚拟线程也导出,可以看到每个虚拟线程当前卡在哪个调用栈上。排查”请求卡住不动”这类问题很有用。
JFR。 关注 jdk.VirtualThreadStart 和 jdk.VirtualThreadEnd 两个事件。如果 Start 的数量远大于 End,说明大量虚拟线程挂在某个地方没结束,要去看是不是卡在连接池或者信号量上。
自定义指标。 给那个 Semaphore 加一个 Gauge,暴露当前等待的虚拟线程数。这个指标比线程池时代的 queue size 更直白,一看就知道下游是不是成了瓶颈。
九、压测结果和一个补充说明
同样的场景重新压了一遍(500 并发,下游均值 40ms、P99 300ms):
| 指标 | 改造前(固定线程池 32) | 改造后(虚拟线程 + 信号量 96) |
|---|---|---|
| P99 | 约 1400ms | 约 380ms |
| 吞吐 | 约 1200 qps | 约 2600 qps |
| 线程峰值 | 32 | 约 3100(大部分在等待) |
| 下游错误率 | 0.02% | 0.03% |
下游错误率略微上升,是信号量放开到 96 之后下游压力变大导致的,后来把信号量收到 80,就回到了改造前的水平。这个细节也说明一件事:虚拟线程不创造容量,它只是让你用更少的资源去排队等 I/O。真正的容量还得看下游和数据库。
十、什么场景不该上虚拟线程
最后说个反方向的建议。
虚拟线程的收益来自”等待”。如果一个接口的主要耗时是 CPU 计算——比如大量的 JSON 解析、加解密、图像处理——那虚拟线程几乎没用,甚至因为调度开销让情况变糟。这类任务应该继续用平台线程池,而且线程数最好贴着 CPU 核数走。
另外,如果项目里有大量基于线程复用的设计,比如靠 ThreadLocal 缓存一些昂贵对象、依赖线程池做背压、用 InheritableThreadLocal 传上下文,那迁移成本会明显高于收益。我们这次能顺利改完,很大程度上是因为项目里的 ThreadLocal 用法本来就比较规矩,只有三处,而且都是”请求上下文”这个语义。
如果你们也在准备升级 JDK 25,我的建议是:先把 synchronized 和 ThreadLocal 这两件事盘清楚,再动虚拟线程。盘清楚之后,改动量会比你想象的小很多。

