承接的问题
14.1 把线程成本从约 1 MB 降到约 1 KB,14.2 说明 pinning 只剩 native 和类初始化两块。于是很多人得出一个推论:既然线程便宜,就把所有请求都丢给虚拟线程,并发自然上去了。
这个推论漏掉了后半句:虚拟线程只改变了「等待」的成本,没有改变下游的容量。数据库连接池还是 20 个连接,下游短信网关还是每秒 200 条,ThreadLocal 还是每条线程一份。并发数从 200 涨到 10 万,只是把排队从线程池挪到了连接池和网关。
这一节把三件事补齐:不要池化虚拟线程、ThreadLocal 在虚拟线程下的数量变化、以及用 Semaphore 把并发压回下游能承受的范围。
不要池化虚拟线程
平台线程池的存在理由是两个:创建线程贵,且线程数必须受控。虚拟线程把第一条消掉了,第二条则应该交给真正稀缺的资源去管。Executors.newVirtualThreadPerTaskExecutor() 返回的执行器每个任务开一条新虚拟线程,任务结束线程就结束:
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (var task : tasks) {
ex.submit(task);
}
} // close() 等待所有任务结束
不要写成 new ThreadPoolExecutor(4, 4, 60, SECONDS, queue, Thread.ofVirtual().factory())。固定大小的虚拟线程池会把「M 条虚拟线程映射到 N 条载体」退化成「N 条虚拟线程抢 N 条载体」,阻塞时没有多余的虚拟线程可以顶上来,虚拟线程的唯一优势就没了。
| 做法 | 结果 | 评价 |
|---|---|---|
newVirtualThreadPerTaskExecutor() | 每任务一条,阻塞即卸载 | 正确默认 |
| 固定大小虚拟线程池 | 并发被池大小锁死 | 反模式 |
| 平台线程池 | 回到 1:1,扩展性受栈内存限制 | CPU 密集时仍可用 |
每请求 Thread.ofVirtual().start() | 等价于第一行,但少了结构化关闭 | 可用,注意生命周期 |
实验:并发上限到底卡在哪
下面用 1 000 个任务打一个模拟下游,下游每次耗时 30 ms。第一轮不设限,第二轮用 Semaphore(16) 包住下游调用,记录同时在途的最大任务数。
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class SemaphoreCap {
static final int TASKS = 1_000, PERMITS = 16;
static final AtomicInteger inFlight = new AtomicInteger();
static final AtomicInteger maxInFlight = new AtomicInteger();
static final AtomicInteger completed = new AtomicInteger();
static void downstream() {
maxInFlight.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
try { Thread.sleep(30); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
finally { inFlight.decrementAndGet(); completed.incrementAndGet(); }
}
static long run(Semaphore permits) throws Exception {
inFlight.set(0); maxInFlight.set(0); completed.set(0);
CountDownLatch go = new CountDownLatch(1);
long t0 = System.nanoTime();
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < TASKS; i++) {
ex.submit(() -> {
try {
go.await();
if (permits == null) {
downstream();
} else {
permits.acquire();
try { downstream(); } finally { permits.release(); }
}
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
go.countDown();
}
return (System.nanoTime() - t0) / 1_000_000;
}
public static void main(String[] args) throws Exception {
System.out.println("java.version = " + System.getProperty("java.version")
+ " tasks=" + TASKS + " permits=" + PERMITS);
for (int round = 1; round <= 3; round++) {
long a = run(null);
System.out.printf("round %d no-limit : maxInFlight=%d completed=%d elapsed=%dms%n",
round, maxInFlight.get(), completed.get(), a);
long b = run(new Semaphore(PERMITS));
System.out.printf("round %d semaphore: maxInFlight=%d completed=%d elapsed=%dms%n",
round, maxInFlight.get(), completed.get(), b);
}
}
}
本机输出(Apple M1 Pro,10 核,load averages 约 132–183):
java.version = 25.0.5 tasks=1000 permits=16
round 1 no-limit : maxInFlight=1000 completed=1000 elapsed=57ms
round 1 semaphore: maxInFlight=16 completed=1000 elapsed=2256ms
round 2 no-limit : maxInFlight=1000 completed=1000 elapsed=36ms
round 2 semaphore: maxInFlight=16 completed=1000 elapsed=2258ms
round 3 no-limit : maxInFlight=1000 completed=1000 elapsed=42ms
round 3 semaphore: maxInFlight=16 completed=1000 elapsed=2258ms
两组数字的含义完全不同:
- 不设限:1 000 条虚拟线程在 36–57 ms 内全部进入下游,
maxInFlight=1 000。对模拟下游没事,对真实下游就是 1 000 个并发连接或 1 000 QPS,超过容量直接超时或被打限流。 Semaphore(16):maxInFlight三轮都精确等于 16,说明限流生效;总耗时 2 256–2 286 ms。理论值是1000 / 16 × 30 ms = 1 875 ms,多出的约 380 ms 是任务创建、许可竞争和机器高负载的排队开销。
Semaphore.acquire() 在虚拟线程里是可卸载的阻塞,不占载体——14.2 的矩阵里它产生 0 个 jdk.VirtualThreadPinned 事件。所以「10 万条虚拟线程抢 16 个许可」不会把调度器拖死,只会让 99 984 条安静地等在堆里。
ThreadLocal 与 MDC
虚拟线程不池化,意味着线程本地变量不再有「复用」这回事:每条虚拟线程一份 ThreadLocalMap。数量从「池大小份」变成「并发任务数份」。
先用小程序确认隔离性和继承行为:
static final ThreadLocal<Object> TL = new ThreadLocal<>();
static final InheritableThreadLocal<String> ITL = new InheritableThreadLocal<>();
static final ScopedValue<String> REQ = ScopedValue.newInstance();
static void isolation() throws Exception {
TL.set("main");
String[] seen = new String[2];
Thread a = Thread.ofVirtual().start(() -> {
seen[0] = String.valueOf(TL.get());
TL.set("vt-a");
seen[0] = seen[0] + "->" + TL.get();
});
a.join();
Thread b = Thread.ofVirtual().start(() -> seen[1] = String.valueOf(TL.get()));
b.join();
System.out.println("isolation: vt-a saw=" + seen[0]
+ " vt-b saw=" + seen[1] + " main saw=" + TL.get());
}
static void inheritance() throws Exception {
ITL.set("itl-main");
String[] itlSeen = new String[1];
Thread c = Thread.ofVirtual().start(() -> itlSeen[0] = ITL.get());
c.join();
String[] scopedSeen = new String[1];
ScopedValue.where(REQ, "req-42").run(() -> {
try {
Thread d = Thread.ofVirtual().start(() ->
scopedSeen[0] = REQ.orElse("<unbound>"));
d.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println("inheritance: InheritableThreadLocal=" + itlSeen[0]
+ " ScopedValue=" + scopedSeen[0] + " main-bound=" + REQ.isBound());
}
本机输出:
isolation: vt-a saw=null->vt-a vt-b saw=null main saw=main
inheritance: InheritableThreadLocal=itl-main ScopedValue=<unbound> main-bound=false
三个结论都和直觉有出入,值得逐条说:
- ThreadLocal 天然隔离。主线程设的值,子虚拟线程看不到;子线程设的值也不会回流。这是好事,但也意味着任何依赖「线程池预热 ThreadLocal」的优化都失效了。
InheritableThreadLocal会继承,在虚拟线程创建时复制一份。它能解决传递问题,但复制发生在start()时刻,之后父线程再改就不同步;而且每个任务一份副本,上下文对象大时内存会放大。ScopedValue不会被Thread.ofVirtual().start()继承。JDK 25 的ScopedValue文档写得很清楚:绑定只由StructuredTaskScope创建时捕获,并由fork()的子任务继承。普通Thread/Thread.Builder启动的线程读不到父线程的绑定。要用它做请求上下文,子任务必须放在StructuredTaskScope里(JDK 25 仍是 preview,15.1 展开)。
那 MDC 怎么办?日志框架的 MDC 本质是 ThreadLocal<Map>,上面三条规则原样适用。可选方案:
| 方案 | 传递方式 | 代价 |
|---|---|---|
| 显式传 context 参数 | 方法签名携带 | 最可靠,侵入性大 |
InheritableThreadLocal | 创建时复制 | 之后修改不同步,副本多 |
在任务内重新绑定 ScopedValue | 父线程捕获值,子任务 where(...).run(...) | 需要改任务包装层 |
StructuredTaskScope + ScopedValue | 自动继承 | preview API,15.1 展开 |
数量上也要算账:10 万条虚拟线程各存 1 KB 的上下文,就是 100 MB 量级的堆。本机实测(-Xmx512m,两轮):
round 1 baseline heapDelta=123.3MB
round 1 +1KB TL heapDelta=228.0MB
round 2 baseline heapDelta=108.8MB
round 2 +1KB TL heapDelta=213.1MB
基线包含 10 万条阻塞线程的栈片段,约 109–123 MB;每条再挂 1 KB 数组后增加约 104 MB,正好对应 100 000 × 1 024 B。所以「虚拟线程很轻」的前提是不要给每条线程挂大对象。缓存、大 buffer、完整用户对象都不该进 ThreadLocal。
限流应该放在哪一层
Semaphore 放在虚拟线程里只是手段,真正要决定的是「许可数从哪来」。三条原则:
许可数对齐真实稀缺资源,而不是拍脑袋。 连接池 20 个连接,许可就给 20;下游文档写 200 QPS、单次 30 ms,按 200 × 0.03 = 6 个并发就够,给 16 是留余量。许可大于资源,等于没限。
每个下游一个信号量,不要全局一把。 三路通知里短信慢,不应该把邮件和推送一起拖住。按渠道拆分,慢渠道自己排队。
拿不到许可要快速失败或降级,不要无限等。 acquire() 会一直等,上游请求可能先超时。用 tryAcquire(timeout) 把等待时间纳入预算:
if (!permits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
throw new LoanException(ErrorCode.CONFLICT, "downstream saturated: " + channel);
}
try {
client.send(reservation);
} finally {
permits.release();
}
等待时间的账也要算:16 个许可、每个 30 ms,1 000 个任务的第 1 000 个要等约 (1000/16 - 1) × 30 ≈ 1.8 s。如果上游 SLA 是 500 ms,许可数就不该是 16,而应该让请求在排队前失败,或者把扇出改成异步补偿。
在 libraryd-conc 里落地
VirtualLoanService 给三个下游各配一个信号量,并把请求上下文做成 record 显式传进任务;任务内重新绑定 ScopedValue,因为普通虚拟线程不会继承它。
public final class VirtualLoanService implements LoanService, AutoCloseable {
public record RequestContext(String requestId, String memberId) {}
private static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();
private final BookRepository books;
private final LoanRepository loans;
private final ExecutorService fanout = Executors.newVirtualThreadPerTaskExecutor();
private final Map<String, Semaphore> permits = Map.of(
"mail", new Semaphore(8),
"sms", new Semaphore(16),
"push", new Semaphore(16));
private final Map<String, DownstreamClient> clients;
public VirtualLoanService(BookRepository books, LoanRepository loans,
Map<String, DownstreamClient> clients) {
this.books = books;
this.loans = loans;
this.clients = clients;
}
@Override
public Reservation reserve(String isbn, String memberId) {
Reservation r = reserveLocked(isbn, memberId);
RequestContext ctx = new RequestContext("req-" + r.loanId(), memberId);
for (String channel : List.of("mail", "sms", "push")) {
fanout.submit(() -> ScopedValue.where(CTX, ctx).run(() -> send(channel, r)));
}
return r;
}
private void send(String channel, Reservation r) {
Semaphore sem = permits.get(channel);
try {
if (!sem.tryAcquire(200, TimeUnit.MILLISECONDS)) {
throw new LoanException(ErrorCode.CONFLICT, "saturated: " + channel);
}
try {
clients.get(channel).send(r, CTX.get());
} finally {
sem.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LoanException(ErrorCode.INTERRUPTED, "notify " + channel);
}
}
private synchronized Reservation reserveLocked(String isbn, String memberId) {
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
if (book.stock() <= 0) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
}
books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
Loan loan = new Loan("L-" + isbn + "-" + memberId, isbn, memberId,
Instant.now().plusSeconds(86_400));
loans.save(loan);
return new Reservation(loan.loanId(), isbn, memberId, loan.dueAt());
}
@Override
public boolean returnBook(String loanId) {
return returnLocked(loanId);
}
private synchronized boolean returnLocked(String loanId) {
Loan loan = loans.findById(loanId)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, loanId));
Book book = books.findByIsbn(loan.isbn())
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, loan.isbn()));
books.save(new Book(book.isbn(), book.title(), book.stock() + 1));
return true;
}
@Override
public void close() throws InterruptedException {
fanout.shutdown();
fanout.awaitTermination(5, TimeUnit.SECONDS);
}
}
注意 CTX.get() 只在这个子任务自己 where 出来的作用域内有效。父线程捕获 ctx 对象、在任务内重新绑定,是普通虚拟线程传递上下文的可靠写法;直接指望子线程继承,本机实测拿到的是 <unbound>。
什么时候不要用
| 场景 | 问题 | 替代 |
|---|---|---|
| CPU 密集任务 | 没有阻塞可卸载,信号量也救不了 | 固定平台线程池 / ForkJoin |
| 给每条虚拟线程挂大 ThreadLocal | 10 万 × 1 KB 就是 100 MB | 显式传参、放请求对象 |
| 许可数大于连接池 | 限流形同虚设,压力照旧打到数据库 | 许可 ≤ 池上限,按下游容量算 |
| 全局一把信号量 | 慢下游拖住快下游 | 按渠道/租户拆分 |
| 用固定大小虚拟线程池 | 并发被池大小锁死 | newVirtualThreadPerTaskExecutor |
以为 ScopedValue 会被 Thread.start 继承 | 子线程读到未绑定,运行期才炸 | 任务内重新绑定或用 15.1 的作用域 |
最后一条尤其容易踩:单元测试里在父线程绑定、在子线程读,看起来能跑,是因为测试框架可能复用了同一条线程;换成虚拟线程就变成 <unbound>。
小结
虚拟线程把线程成本降下来,但没有增加连接池、下游 QPS 和 ThreadLocal 副本的预算。三件事必须一起做:
- 用
newVirtualThreadPerTaskExecutor(),不要池化虚拟线程; - 用
Semaphore把并发压到下游容量,许可按渠道拆分、按真实资源计算,拿不到许可要快速失败; - 认清 ThreadLocal 是每条虚拟线程一份,10 万条各 1 KB 就是约 104 MB 额外堆;MDC 不会自动传,
ScopedValue也只被StructuredTaskScope的fork()继承。
本机实测:1 000 个任务不设限时 maxInFlight=1 000、36–57 ms 打穿下游;Semaphore(16) 后恒定 16、2 256–2 286 ms。下一章进入结构化并发,把这些散落的生命周期和限流收进一个作用域。
阅读导航:上一节 14.2 pinning:JDK 25 还剩什么 | 下一节 15.1 StructuredTaskScope 的作用域
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。