承接的问题
虚拟线程上线一周,libraryd-conc 出了三个新问题,都不是线程本身的错。
症状一:数据库连接被打满。 借阅查询改成虚拟线程后,一次促销活动进来 3 万个并发,数据库侧的 max_connections 是 100,DBA 打电话过来说「你们的连接数打到 100 之后全在等」。应用侧看起来一切正常——没有异常,没有拒绝,只有 P99 从 80 ms 涨到 40 秒。
症状二:堆涨了 1.3 GB。 同一份代码,平台线程池 200 个线程时堆稳定在 1.2 GB;换成虚拟线程后,峰值堆 2.5 GB,GC 频率翻倍。堆转储里没有泄漏对象,全是 byte[] 和 ThreadLocalMap$Entry。
症状三:日志里的 traceId 串了。 用户 A 的请求日志里出现了用户 B 的 traceId。查下来,写日志的代码没错,错的是 MDC 的取数方式——它从一个「创建时就固定下来」的变量里读。
三个症状对应三个不同的机制:连接池的硬上限、ThreadLocal 的每线程拷贝、InheritableThreadLocal 的创建时快照。本节逐个实测。
连接池仍然是硬上限
5.2 的结论在这里兑现:虚拟线程不改变 W/C 的物理含义,它只改变了「这个比值决定什么」。平台线程时代,N×(1+W/C) 算出的是线程数,线程数就是上限;虚拟线程时代,线程数放开了,上限转移到了真正稀缺的那个资源上——连接池。
实测:5000 个任务,三种许可数
任务体模拟一次 20 ms 的下游查询:
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.LockSupport;
/** 虚拟线程 + 连接池上限:permits=0 表示不限制。 */
public final class ConnPoolCap {
static final AtomicInteger IN_DB = new AtomicInteger();
static final AtomicInteger PEAK_IN_DB = new AtomicInteger();
static final AtomicInteger WAITERS = new AtomicInteger();
static final AtomicInteger PEAK_WAITERS = new AtomicInteger();
static void query() {
PEAK_IN_DB.accumulateAndGet(IN_DB.incrementAndGet(), Math::max);
LockSupport.parkNanos(20_000_000L); // 模拟 20 ms 下游往返
IN_DB.decrementAndGet();
}
public static void main(String[] args) throws Exception {
int permits = Integer.parseInt(args[0]);
int tasks = Integer.parseInt(args[1]);
Semaphore sem = permits > 0 ? new Semaphore(permits) : null;
long[] waits = new long[tasks];
CountDownLatch done = new CountDownLatch(tasks);
CountDownLatch go = new CountDownLatch(1);
long t0 = System.nanoTime();
for (int i = 0; i < tasks; i++) {
final int k = i;
Thread.ofVirtual().name("task-" + k).start(() -> {
try {
go.await();
long s = System.nanoTime();
if (sem != null) {
PEAK_WAITERS.accumulateAndGet(WAITERS.incrementAndGet(), Math::max);
sem.acquire();
WAITERS.decrementAndGet();
}
waits[k] = (System.nanoTime() - s) / 1_000_000L;
query();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (sem != null) {
sem.release();
}
done.countDown();
}
});
}
go.countDown();
done.await();
long wall = (System.nanoTime() - t0) / 1_000_000L;
long[] s = waits.clone();
Arrays.sort(s);
System.out.printf("permits=%-6s wall=%dms 吞吐=%.1f/s 库内峰值=%d 排队峰值=%d p50=%dms p99=%dms%n",
permits > 0 ? String.valueOf(permits) : "无限", wall,
tasks * 1000.0 / Math.max(1, wall), PEAK_IN_DB.get(), PEAK_WAITERS.get(),
s[tasks / 2], s[(int) (tasks * 0.99)]);
}
}
5000 个任务,结果:
| 许可数 | 墙钟 | 吞吐 | 库内峰值 | 排队峰值 | 等待 p50 | 等待 p99 |
|---|---|---|---|---|---|---|
| 不限制 | 83 ms | — | 3506 | 0 | 0 ms | 0 ms |
| 8 | 16 918 ms | 295.5/s | 8 | 4984 | 8491 ms | 16 651 ms |
| 64 | 2 116 ms | 2362.9/s | 64 | 4936 | 1017 ms | 2026 ms |
三行数据各自说明一件事。
不限制的那一行是事故现场。 5000 个虚拟线程在同一瞬间全部打到下游,库内峰值 3506。真实的数据库在这个并发下会先变慢、再超时、最后拒绝——而应用侧的「吞吐」看起来漂亮得毫无意义,因为 parkNanos 不会失败。虚拟线程让「不设上限」变得极其容易,这是它最危险的一面。
8 个许可的那一行是数学。 8 个连接、每个查询 20 ms,理论上限 400/s,实测 295.5/s(74%,差额是调度与上下文切换)。排队的 4984 个线程什么都没干,只是在 Semaphore 上等。P99 16.7 秒不是虚拟线程慢,是排队长度除以服务率:5000 / 8 × 20 ms = 12.5 秒 的理论值,加上前后段抖动,量级完全对得上。
64 个许可的那一行是另一个极端。 吞吐 2363/s,但只有 64 个并发在算,10 个核里有 6 个在空转。这说明「连接数」和「核数」是两个独立的上限,谁小谁说话(5.2 的 min(公式值, 下游上限))。
把上限写进代码,而不是写进配置
Semaphore 是最直接的落地方式,但要注意它必须包在获取连接这一层,而不是包在业务方法外面:
package com.plume.conc.ops;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/**
* 给任意 DataSource 套一个「并发上限 + 等待超时」。
* 虚拟线程下这才是真正的池:线程不是池,连接才是。
*/
public final class ConnGate implements AutoCloseable {
private final DataSource delegate;
private final Semaphore permits;
private final long waitMillis;
public ConnGate(DataSource delegate, int maxConcurrent, long waitMillis) {
this.delegate = delegate;
this.permits = new Semaphore(maxConcurrent, true); // 公平,避免长任务饿死短任务
this.waitMillis = waitMillis;
}
public <T> T withConnection(Function<Connection, T> work) throws SQLException {
boolean acquired = false;
try {
acquired = permits.tryAcquire(waitMillis, TimeUnit.MILLISECONDS);
if (!acquired) {
throw new SQLException("连接许可等待超时 " + waitMillis + "ms");
}
try (Connection c = delegate.getConnection()) {
return work.apply(c);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new SQLException("等待连接许可时被中断", e);
} finally {
if (acquired) {
permits.release();
}
}
}
public int waiting() {
return permits.getQueueLength();
}
@Override
public void close() {
// DataSource 由容器管理,这里只释放自己的许可状态
}
}
三个细节值得说明。tryAcquire(waitMillis, ...) 而不是 acquire():虚拟线程的等待几乎不花成本,「无限等」在虚拟线程下比以前更常见也更危险——它把超时控制从连接池(HikariCP 的 connectionTimeout)搬到了你自己手里,不设超时,故障就从「快速失败」变成「全部挂死」。new Semaphore(n, true) 用公平模式:虚拟线程数量大、任务长短不一时,非公平模式会让长任务反复插队,公平模式的吞吐损失在 5% 以内。waiting() 是给 12.1 的指标用的——Semaphore.getQueueLength() 就是虚拟线程时代的 pool.getQueue().size(),它比任何池的活跃线程数都更能反映真实压力。
上限该定多少
回到 5.1 的 Little’s law,只是把变量换掉:
需要的连接数 = 目标吞吐(λ) × 单次持有时间(W)
= 300/s × 0.020s = 6
要 300/s 的吞吐、每次查询 20 ms,6 个连接就够;配 8 个是留 33% 余量。配 100 个不会让吞吐变成 5000/s——数据库那边的 max_connections 和 innodb 的并发能力才是天花板。连接数应该由下游能承受的并发决定,而不是由应用层有多少线程决定。
ThreadLocal:百万份拷贝
平台线程时代,ThreadLocal 的代价是「每线程一份」。200 个线程,一份 1 KB 的上下文总共 200 KB,可以忽略。
虚拟线程时代,这句话要改成「每虚拟线程一份」。100 万虚拟线程,一份 1 KB,就是 1 GB。
实测:每线程多出来的那部分
同一段代码,只改 ThreadLocal 里放的对象大小:
| 规模 | 每份大小 | 堆增量 | 每线程 |
|---|---|---|---|
| 10 万 | 16 B | 133 MB | 1394 B |
| 10 万 | 1024 B | 232 MB | 2432 B |
| 100 万 | 256 B | 1313 MB | 1376 B |
| 100 万(不设 ThreadLocal,6.1 基线) | — | 890 MB | 933 B |
10 万档的两行直接相减:每线程多了 1038 字节,而两份只差 1008 字节。也就是说,ThreadLocal 里的对象是实打实按线程数复制的,没有任何共享。100 万档那一行是 1376 B/线程,比基线 933 B 多 443 B——其中 256 B 是载荷,剩下的约 190 B 是 ThreadLocalMap 的 Entry 与数组本身。
这就是症状二的来源:把平台线程池的上下文对象原样搬进虚拟线程,堆会按「线程数 × 上下文大小」线性膨胀,而线程数从 200 变成了 100 万。
先找到是谁在写
JDK 提供了一开关,专门用来找「哪段代码在虚拟线程上设 ThreadLocal」:
java -Djdk.traceVirtualThreadLocals=true -cp out YourMain
每次 ThreadLocal.set 在虚拟线程上执行,都会打印线程和栈:
VirtualThread[#29,tl-1]/runnable@ForkJoinPool-1-worker-2
java.base/java.lang.ThreadLocal.set(ThreadLocal.java:231)
VtThreadLocal.lambda$main$0(VtThreadLocal.java:35)
java.base/java.lang.VirtualThread.run(VirtualThread.java:460)
把这段输出按第二行的调用点聚合,就能列出「谁在虚拟线程里写上下文」的完整清单。多数项目会在这个清单里看到三样东西:日志框架的 MDC、安全框架的 SecurityContext、以及自己写的 RequestContext。
替代:把上下文变成参数或 ScopedValue
ThreadLocal 在虚拟线程下的问题不是性能(一次 get 还是几纳秒),而是空间。所以替代方案的判断标准是「能不能只存一份」:
- 能传参就传参。一个
record Ctx(String traceId, String tenant)沿调用链传下去,零拷贝、零歧义、可测试。这是最土也最有效的办法。 - 需要隐式传递用
ScopedValue。JDK 25 已转正(不是 preview),它的值不可变、作用域由代码块界定、不需要remove():
static final ScopedValue<String> TRACE = ScopedValue.newInstance();
ScopedValue.where(TRACE, "req-42").run(() -> {
String id = TRACE.get(); // 作用域内直接读
handle(id);
}); // 出了 run() 自动失效
一个必须实测清楚的细节:ScopedValue 不会被普通的 Thread.ofVirtual().start() 继承。本机实测(--enable-preview 只用于下面的 StructuredTaskScope,ScopedValue 本身不需要):
绑定内当前线程 scoped=req-42
Thread.ofVirtual().start scoped=<unbound>
Thread.ofPlatform().start scoped=<unbound>
StructuredTaskScope.fork scoped=req-42
只有结构化并发里 fork 出来的子任务才继承绑定。如果你在 ScopedValue 作用域里用 Thread.ofVirtual().start() 起了个野线程,它读到的是 isBound() == false,会 NPE 或读到默认值——这一点和很多文章的暗示相反,必须自己验证。
MDC 的传递
日志框架的 MDC 本质是一个 InheritableThreadLocal<Map>。它的语义是:子线程在创建的那一刻,拷贝父线程当时的值。这句话在平台线程池和虚拟线程下都会出问题,只是出错的方式不同。
两个失效场景,一个共同根因
用 InheritableThreadLocal<String> 模拟 MDC,跑一遍:
== 场景 1:平台线程池 + InheritableThreadLocal ==
[A 自身] thread=pool-1 trace=req-A
[B 自身] thread=pool-1 trace=req-A <- 泄漏:B 没设置,读到了 A 的
[B 的子线程] thread=child-of-B trace=req-A <- 再往下传一层
== 场景 2:虚拟线程从「已被污染的」平台线程创建 ==
[B 的虚拟线程] thread=vt-for-B trace=req-A <- 串号
场景 1 的根因不是 InheritableThreadLocal 坏了,而是池线程活得太久:pool-1 是在请求 A 期间创建的,继承了 A 的 MDC;请求 B 复用这个线程,入口没有显式覆盖,读到的就是 A 的值。场景 2 只是把它搬到虚拟线程:vt-for-B 在 dispatcher 里创建,而 dispatcher 手上的 MDC 还是 A 的。
关键点:虚拟线程没有让这个问题变好,也没有让它变坏,它是「创建时快照」这个语义的必然结果。真正的修复是不要依赖创建时快照。
修法一:显式传递
record Ctx(String traceId) {}
Ctx ctx = new Ctx("req-B");
Thread.ofVirtual().name("vt-explicit")
.start(() -> handle(ctx))
.join();
代价是每个跨线程的调用点都要多一个参数。收益是没有隐式状态:读到什么、从哪来,看签名就知道。
修法二:ScopedValue + 结构化并发
需要隐式传递又不想复制时,用 ScopedValue 配合 StructuredTaskScope(JDK 25 preview,未来版本 API 可能变化,编译和运行都要 --enable-preview):
import java.util.concurrent.StructuredTaskScope;
static final ScopedValue<Ctx> CTX = ScopedValue.newInstance();
void handleRequest(Ctx ctx) throws Exception {
ScopedValue.where(CTX, ctx).call(() -> {
try (var scope = StructuredTaskScope.open()) {
var loan = scope.fork(() -> loadLoan(ctx.traceId()));
var book = scope.fork(() -> loadBook(ctx.traceId()));
scope.join();
return merge(loan.get(), book.get());
}
});
}
编译与运行:
javac --release 25 --enable-preview -d out src/HandleRequest.java
java --enable-preview -cp out com.plume.conc.core.HandleRequest
两个 fork 出来的子任务都能读到 CTX.get(),因为它们是在绑定作用域内、由 StructuredTaskScope 创建的。这解决了场景 2 的串号:值不是从「创建它的线程」继承的,而是从词法作用域继承的。
在 libraryd-conc 里落地
三件事合成一个改造清单,按优先级排:
| 优先级 | 改造 | 验收标准 |
|---|---|---|
| P0 | 所有下游入口套 ConnGate,设等待超时 | waiting() 有指标,超时率 < 0.1% |
| P1 | 移除虚拟线程路径上的 InheritableThreadLocal | -Djdk.traceVirtualThreadLocals=true 输出为空 |
| P1 | 请求上下文改成 record Ctx 显式传递 | 日志 traceId 串号投诉归零 |
| P2 | 结构化并发替换手写的 CompletableFuture 组合 | 取消能向下传播 |
P1 那两条的顺序不能反:先确认没有隐式上下文,再谈要不要上 ScopedValue。如果代码里还在用 InheritableThreadLocal 传租户 ID,直接换成 ScopedValue 会漏掉所有 Thread.start() 创建的线程(上一小节实测的 <unbound>),变成随机 NPE。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 虚拟线程不加连接池上限 | 5000 并发全打到库,库内峰值 3506 | Semaphore / ConnGate 兜底 |
Semaphore.acquire() 不设超时 | 故障时全部挂死,没有快速失败 | tryAcquire(timeout) |
| 用线程数当连接数 | 连接数按线程数配,下游被打穿 | 按 λ × W 算,再和下游上限取小 |
把大对象放进 ThreadLocal | 100 万线程 → 1 GB 堆 | 传参或 ScopedValue |
用 InheritableThreadLocal 传请求上下文 | 池复用导致 traceId 串号 | 显式 Ctx / ScopedValue |
以为 ScopedValue 会自动传给 Thread.start() | 野线程读到 <unbound> | 用 StructuredTaskScope.fork |
什么时候不要用 Semaphore 限流:当下游本身有健康检查与熔断时,应用层再套一层信号量会掩盖真实故障——请求全卡在信号量上,熔断器看不到错误率,永远不会打开。这种情况应该让请求快速打到下游、快速失败,由熔断器决定降级。信号量的正确位置是「保护一个没有自我保护能力的资源」,比如一个老式的、连接数一超就雪崩的数据库驱动。
小结
连接池、ThreadLocal、MDC 是虚拟线程落地时必然撞上的三堵墙,它们的共同点是:虚拟线程放开了线程数,但没有放开线程背后的资源。
实测数字:5000 个任务、每个 20 ms,8 个许可时墙钟 16.9 秒、P99 16.7 秒、峰值 4984 个线程在排队;64 个许可时 2.1 秒;不限制时 83 ms 但库内峰值 3506——最后这行才是生产事故的形态。
ThreadLocal 的成本按线程数线性复制:实测每线程多 1038 字节,与载荷的 1008 字节基本相等,10 万线程就是约 100 MB。-Djdk.traceVirtualThreadLocals=true 能列出所有写入点。
MDC 的串号来自 InheritableThreadLocal 的「创建时快照」语义,池复用会把上一个请求的值带过来。修法两条:显式传 record Ctx,或用 ScopedValue——但 ScopedValue 只被 StructuredTaskScope.fork 继承,不会被 Thread.start() 继承,这一点本机实测过,和很多文章的暗示相反。
下一章换一个方向:同样面对「大量并发连接」,Reactor 走的是完全不同的路。先回顾它的线程模型,再和虚拟线程做一张取舍矩阵。
阅读导航:上一节 6.2 pinning 的诊断与消除 | 下一节 7.1 Reactor 的线程模型回顾
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。