本节目标:把「一个请求四个虚拟线程」的架构接到有限的下游容量上,并且不丢请求上下文。
适用版本:JDK 25(Temurin 25.0.5);ScopedValue已正式;StructuredTaskScope相关代码需要--enable-preview。
承接的问题
6.1 把 HTTP 请求线程换成了虚拟线程,6.2 让一个面板请求再扇出三个子任务。一个请求从 1 个线程变成 4 个虚拟线程,成本确实低,但下游没有变:
- HikariCP 还是 10 个连接。
- PostgreSQL 的最大连接数还是那么多。
- 外部征信接口的 QPS 配额还是每秒 20 次。
虚拟线程改变的是「等待时占不占平台线程」,不是「下游能同时处理多少」。如果没有显式的闸门,500 个并发请求会产生 2000 个虚拟线程一起冲向 10 个连接,过载只是从线程池转移到了连接池的等待队列。
另外两件事在切到虚拟线程后开始变得刺眼:请求上下文用 ThreadLocal 存,线程变多之后内存开销和串号风险都上来了;「线程数」这个曾经可以用来限流的指标,现在没有意义了。
这一节按「上下文 → 连接 → 限流」的顺序落地。
ThreadLocal 在池化线程上的老问题
先复现一个与虚拟线程无关、但切换到虚拟线程后更常见的问题:请求 ID 放在 ThreadLocal 里,忘记清理。
static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
pool.submit(() -> {
if (REQUEST_ID.get() == null) {
REQUEST_ID.set("req-" + request);
}
System.out.println("task " + request + " sees " + REQUEST_ID.get());
});
固定 2 个线程的池,提交 6 个任务,本机输出:
task 2 on pool-1-thread-2 sees req-2
task 1 on pool-1-thread-1 sees req-1
task 3 on pool-1-thread-1 sees req-1
task 4 on pool-1-thread-2 sees req-2
task 5 on pool-1-thread-1 sees req-1
task 6 on pool-1-thread-2 sees req-2
task 3 跑在 thread-1 上,看到的是 req-1 的 ID。如果这个 ID 被写进审计日志,日志里就会出现「3 号请求记在了 1 号会员名下」。加上 try/finally 里的 REQUEST_ID.remove() 就正常了:
task 1 on pool-2-thread-1 sees req-1
task 2 on pool-2-thread-2 sees req-2
task 3 on pool-2-thread-2 sees req-3
...
虚拟线程下这个问题的形状变了:线程不再被池化复用,「串号」的概率下降,但每个虚拟线程一份 ThreadLocal 的成本上升。2000 个并发虚拟线程、每个存一个 4KB 的上下文对象,就是 8MB;如果存的是缓存的 byte[],很容易到几百 MB。而且虚拟线程结束后 ThreadLocal 才可回收,长生命周期的虚拟线程(比如常驻的消费循环)会一直持有它。
结论是:请求上下文不要用 ThreadLocal 存大对象;能用参数传就用参数;必须隐式传递时用 ScopedValue。
ScopedValue:绑定、自动还原、继承边界
ScopedValue 在 JDK 25 是正式 API(JEP 506)。用法是「绑定一个值,跑一段代码,结束后自动还原」:
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
ScopedValue.where(REQUEST_ID, "req-sv-7").run(() -> {
System.out.println("in scope: " + REQUEST_ID.get());
});
System.out.println("after run bound=" + REQUEST_ID.isBound());
本机输出:
in scope: req-sv-7
after run bound=false
三个性质:
- 不可变。绑定期间不能改值,要换值只能再套一层
where(内层覆盖外层,退出内层后外层恢复)。 - 自动还原。
run返回或抛异常,绑定都消失,不需要finally清理。 - 有作用域边界。
get()在没有绑定的线程里抛NoSuchElementException,isBound()可以先判断。
继承的边界必须实测,不能凭印象。在绑定作用域里分别用四种方式创建线程:
ScopedValue.where(REQUEST_ID, "req-7").run(() -> {
Thread a = Thread.ofVirtual().unstarted(() -> print("unstarted+start"));
a.start();
Thread b = Thread.ofVirtual().start(() -> print("ofVirtual().start"));
Thread c = new Thread(() -> print("new Thread"));
c.start();
try (var scope = StructuredTaskScope.open()) {
var sub = scope.fork(() -> { print("scope.fork"); return null; });
scope.join();
sub.get();
}
});
本机输出(JDK 25.0.5):
unstarted+start -> unbound
ofVirtual().start -> unbound
new Thread -> unbound
scope.fork -> req-7
只有 StructuredTaskScope.fork 出来的子任务继承了绑定,Thread.start() 创建的线程(平台或虚拟)都拿不到。所以:
- 请求内扇出用 6.2 的
StructuredTaskScope,上下文自动带过去。 - 需要交给独立线程(比如 5.2 的事件流水线)时,必须把上下文作为参数显式传过去,不能指望继承。
- 不要在
ThreadLocal和ScopedValue之间来回搬:一个请求只用一种。
连接池:400 个虚拟线程打 10 个连接
把「查询数据库」模拟成 25ms 的阻塞,400 个任务分别在不加闸门和加 10 个许可的情况下跑:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < tasks; i++) {
executor.submit(() -> {
if (gated) {
gate.acquire();
}
int now = inFlight.incrementAndGet();
maxInFlight.accumulateAndGet(now, Math::max);
try {
Thread.sleep(25);
} finally {
inFlight.decrementAndGet();
if (gated) {
gate.release();
}
}
});
}
}
本机输出:
tasks=400 gated=false elapsedMs=45 maxInFlight=400 maxWaitMs=0
tasks=400 gated=true elapsedMs=1167 maxInFlight=10 maxWaitMs=1126
- 不加闸门:400 个任务同时「进数据库」,
maxInFlight=400。真实的 Hikari 只有 10 个连接,多出来的 390 个会在getConnection()上排队,等待时间不会消失,只是不在你的代码里。 - 加闸门:并发峰值 10,最长等待 1126ms,总耗时 1167ms。这和 400/10×25ms=1000ms 的理论值接近。
这里有一个取舍要写清楚:加闸门会让总耗时变长(45ms → 1167ms),因为它把并行改成了排队。这不是退步,而是把「不可控的数据库排队」变成「可控的应用层排队」:等待可观测、可超时、可拒绝。如果不加,数据库侧的连接等待会以更不可控的方式出现——连接池耗尽、请求超时、连接被回收。
许可数应该等于 Hikari 的 maximumPoolSize,而不是拍脑袋。设成 20 而连接池只有 10,多出来的许可只会让 10 个线程拿到连接、另外 10 个在池里等,和设成 10 没有区别,还多了一层无效并发。
限流:许可会还,令牌不会
Semaphore 解决的是「同时多少个」,不解决「每秒多少个」。借阅接口需要后者:单个会员每秒最多 5 次借阅尝试,防止脚本刷接口。
令牌桶用 Semaphore 实现:桶容量是突发上限,定时任务按固定速率补充令牌。
public BorrowRateLimiter(int capacity, int permitsPerTick, long tickMillis,
ScheduledExecutorService scheduler) {
this.capacity = capacity;
this.tokens = new Semaphore(capacity, true);
scheduler.scheduleAtFixedRate(() -> {
tokens.release(permitsPerTick);
while (tokens.availablePermits() > capacity && tokens.tryAcquire()) {
// 桶满后多补的许可直接收回
}
}, tickMillis, tickMillis, TimeUnit.MILLISECONDS);
}
容量 5、每 100ms 补 2 个(稳态 20/s),每 10ms 发一个请求,共 60 个:
elapsedMs=730 allowed=19 rejected=41 allowedPerSec=26.0
19 个通过、41 个被拒。账算得通:初始桶里有 5 个,730ms 内补了 7 次共 14 个,5+14=19。「26/s」看起来超过 20,是因为开头那 5 个是突发额度;稳态仍然是 20/s。
三个注意点:
- 补充任务要有独立的调度线程,且是守护线程。不要让补充任务和业务共用一个已经饱和的线程池,否则桶永远不会被补。
tryAcquire()不阻塞,被拒的请求立刻返回 429,而不是排队。限流的目的是保护,不是延迟。- 回收多余的许可要在循环里做。
release之后availablePermits()可能已经超过容量,直接判断一次不够(并发下会被别的线程改)。
在 libraryd 里落地
请求上下文用 ScopedValue,并提供 call/run 两个入口:
package com.plume.library.web;
import java.time.Instant;
import java.util.Optional;
public final class RequestContext {
public record Request(String requestId, String memberId, Instant startedAt) {
public Request {
if (requestId == null || requestId.isBlank()) {
throw new IllegalArgumentException("requestId 不能为空");
}
}
}
private static final ScopedValue<Request> CURRENT = ScopedValue.newInstance();
private RequestContext() {
}
public static Request current() {
return CURRENT.orElseThrow(() ->
new IllegalStateException("当前作用域没有绑定 RequestContext"));
}
public static Optional<Request> currentOrEmpty() {
return CURRENT.isBound() ? Optional.of(CURRENT.get()) : Optional.empty();
}
public static <T, X extends Throwable> T call(Request request,
ScopedValue.CallableOp<T, X> body) throws X {
return ScopedValue.where(CURRENT, request).call(body);
}
public static void run(Request request, Runnable body) {
ScopedValue.where(CURRENT, request).run(body);
}
}
ScopedValue.CallableOp 是 JDK 25 的签名,不是 java.util.concurrent.Callable。写错会得到「无法将 Callable 应用到 CallableOp」的编译错误。
Javalin 的入口包装:
cfg.routes.get("/api/members/{id}/dashboard", ctx -> {
RequestContext.Request request = new RequestContext.Request(
ctx.header("X-Request-Id") == null ? UUID.randomUUID().toString()
: ctx.header("X-Request-Id"),
ctx.pathParam("id"), Instant.now());
RequestContext.run(request, () -> ctx.json(dashboardService.load(request.memberId(), LocalDate.now())));
});
dashboardService.load 内部的三个 fork 会自动继承这个绑定(6.2 已验证),审计日志里直接 RequestContext.current().requestId() 就能拿到。
本机用真实的 RequestContext 跑一遍:
handler: req-9f2c
service: audit(req-9f2c/M-1)
free thread: Optional.empty
scope fork: req-9f2c
fork result: M-1
注意第三行:用 Thread.ofVirtual().start() 起的自由线程拿不到上下文,返回 Optional.empty()。这不是 bug,是 6.3 前面实测的继承边界。
连接闸门复用 5.3 的 DbConcurrencyGate,许可数从 Hikari 配置读:
package com.plume.library.persistence;
public final class DbAccess {
private final DbConcurrencyGate gate;
public DbAccess(DbConcurrencyGate gate) {
this.gate = gate;
}
public <T> T call(Callable<T> work) throws Exception {
if (!gate.tryEnter(Duration.ofMillis(200))) {
throw new LibraryException(ErrorCode.DB_UNAVAILABLE, "数据库繁忙,请稍后重试");
}
try {
return work.call();
} finally {
gate.exit();
}
}
}
200ms 的超时是有意的:比连接池的 connectionTimeout(通常 30s)短得多,让请求快速失败,而不是在池里挂 30 秒。timeouts() 指标持续大于 0,说明该扩容连接池或优化 SQL 了。
借阅限流按会员维度建桶,用 ConcurrentHashMap<String, BorrowRateLimiter> 管理,配合定时清理空闲桶:
private final ConcurrentHashMap<String, BorrowRateLimiter> buckets = new ConcurrentHashMap<>();
public boolean allowBorrow(String memberId) {
return buckets.computeIfAbsent(memberId,
id -> new BorrowRateLimiter(5, 2, 100, scheduler)).tryAcquire();
}
每个会员一个桶,computeIfAbsent 保证并发下只创建一个。桶本身不会被回收,会员量大的时候需要加一个「最后访问时间」字段和定时清理任务,否则这张 map 会一直涨——这是 5.1 讲的弱一致容器不负责的事。
常见错误与边界
错误一:「虚拟线程可以无限创建,所以不用限流」。 创建确实便宜,但每个虚拟线程背后都对应一次下游调用。本机 400 个任务不加闸门时 maxInFlight=400,数据库只有 10 个连接。虚拟线程改变的是线程成本,不是下游容量。
错误二:把虚拟线程放进固定池。
// 反例:32 个虚拟线程被复用,退化成平台线程模型
Executors.newFixedThreadPool(32, Thread.ofVirtual().factory());
虚拟线程不需要池化,需要限制的是「同时进入下游的任务数」,用信号量。
错误三:以为 ScopedValue 会自动传给所有子线程。 本机实测 Thread.start() 创建的线程(平台和虚拟)都是 unbound,只有 scope.fork 继承。把上下文传给事件流水线时,必须作为 LoanEvent 的字段或方法参数显式携带。
错误四:在虚拟线程里放大的 ThreadLocal。 每个虚拟线程一份,几千个并发就是几千份。请求上下文用 ScopedValue,大对象(缓存、缓冲区)不要挂在 ThreadLocal 上。
边界:信号量许可泄漏。 tryEnter 成功但 work.call() 抛异常时,finally 里的 exit() 必须执行。上面 inDatabase 的写法把 exit 放在 finally,是唯一正确的形状。
边界:令牌桶的补充线程也可能成为瓶颈。 每个会员一个桶、每个桶一个定时任务,几千个会员就是几千个调度任务。正确做法是一个共享的 ScheduledExecutorService 按批补充,或者用「上次补充时间 + 速率」的惰性计算,不用定时任务。
小结
- 虚拟线程降低等待成本,不增加下游容量;400 个任务不加闸门时并发 400,加 10 个许可后并发 10、最长等待 1126ms。
- 池化线程上的
ThreadLocal会串号,本机task 3读到了req-1;虚拟线程下更要注意每线程一份的内存开销。 ScopedValue不可变、自动还原,但只有StructuredTaskScope.fork继承绑定,Thread.start()创建的线程拿不到。- 连接闸门的许可数应等于 Hikari 的
maximumPoolSize,超时要远小于连接池的connectionTimeout。 - 令牌桶解决「每秒多少次」,
Semaphore解决「同时多少个」,两者不能互相替代。 - libraryd 的三件套是
RequestContext、DbConcurrencyGate、BorrowRateLimiter,分别对应上下文、容量、速率。
并发这一章到此结束。接下来换到类型系统:从 record 的嵌套解构开始,看模式匹配能把多少 instanceof 判断从代码里消掉。
阅读导航:上一节:6.2 结构化并发:StructuredTaskScope 与作用域取消 · 下一节:7.1 记录模式与嵌套解构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。