承接的问题
构造场景:libraryd-conc 的线上形态是三段式——最前面是事件循环网关(10 个循环线程),中间是虚拟线程业务层,慢下游(mail/sms/push)各自走独立的平台线程池做隔离。某天开始,借阅接口的 P99 从 40 ms 涨到 3.5 s,但平均延迟只涨了 15%。
排查时遇到三个障碍:
- 网关线程转储看起来「很正常」:
KQueue.poll,没有业务帧,也没有死锁。 - 日志里同一条请求有三个不同的 trace id,或者后一段干脆没有 trace id。
jcmd Thread.print里数不到业务线程——虚拟线程一个都看不见。
这三个现象分别对应混合栈的三类边界:执行边界(阻塞代码跑在了不该跑的线程上)、上下文边界(trace 在换线程时断掉)、观测边界(工具默认只看平台线程)。本节按这三条线展开,每条都给本机可复现的证据。
三种边界
边界一:事件循环里调阻塞 API
最常见的形态是「回调里顺手查一下」:网关收到请求,在 IO 回调里直接调 bookRepository.findByIsbn(),而这个实现走 JDBC。单次 8 ms,看起来无害。
证据在 7.1 已经量过:循环线程执行 50 ms 的阻塞处理时,三个本该 5 ms 完成的并发请求都等到 47–54 ms。根因不是 JDBC 慢,而是这 8 ms 里 select() 没有被调用,所有就绪事件都堆在内核里。
修复分两步,缺一不可:
- 把阻塞调用提交到一个有界的独立执行器,回调只负责组装结果。
- 结果回来后,如果协议要求在循环线程上写回,就再投递回循环,而不是在工作线程上直接
write——SocketChannel本身是线程安全的,但连接的写状态机(半包、排队、背压水位)不是。
判断有没有踩线,不用读代码,看转储:循环线程栈顶是业务方法、栈里没有 select/poll 帧,就是踩了(7.1 给了两段真实转储对照)。
光靠评审拦不住,给边界加一条运行期断言更可靠。下面这个类用 ThreadLocal 标记「当前是不是循环线程」,在入口和异步任务里各查一次:
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** 边界断言:阻塞代码只能在 IO 池里跑,不能回到循环线程。 */
public final class LoopOffload {
static final ThreadLocal<Boolean> ON_LOOP = ThreadLocal.withInitial(() -> false);
static final ExecutorService IO = Executors.newFixedThreadPool(4);
static CompletableFuture<String> handle(String isbn) {
if (!ON_LOOP.get()) throw new IllegalStateException("handle 必须在循环线程调用");
return CompletableFuture.supplyAsync(() -> {
if (ON_LOOP.get()) throw new IllegalStateException("阻塞代码跑回了循环线程");
return "stock:" + isbn;
}, IO);
}
public static void main(String[] args) throws Exception {
String[] result = new String[1];
Thread loop = Thread.ofPlatform().name("event-loop").start(() -> {
ON_LOOP.set(true);
try {
result[0] = handle("978-7-111-12345-6").join();
} finally {
ON_LOOP.remove();
}
});
loop.join();
System.out.println("result=" + result[0]);
IO.shutdown();
}
}
本机输出 result=stock:978-7-111-12345-6。这段代码的价值不在功能,而在把「不许在循环线程上阻塞」变成运行期可失败的条件:一旦有人把 supplyAsync 的 executor 换成 Runnable::run(同步执行),第二个断言立刻抛异常,而不是等到线上 P99 飙起来才发现。
边界二:虚拟线程里进 synchronized
这是最容易被旧文章误导的一条。JDK 21 时代,虚拟线程在 synchronized 块里阻塞会 pin 住载体线程;JDK 24 起 JEP 491 让 monitor 操作可以在不 pin 的情况下卸载,JDK 25 沿用这个行为。但「JDK 25 不再 pin」不能靠一次默认配置的录制来证明,先把观测口径说清。
本机 lib/jfr/profile.jfc 里 jdk.VirtualThreadPinned 的默认阈值是 20 ms:低于 20 ms 的 pin 不会入库。所以「2 ms 的 Thread.sleep 没录到事件」既可能是没 pin,也可能是 pin 了但太短——这种观测不足以支撑任何结论。我用 jfr configure 生成了一份 threshold=0 ms 的配置,再做两组对照:300 个虚拟线程,分别在 synchronized (LOCK) 与 ReentrantLock 保护的临界区里 Thread.sleep(50),50 ms 高于任何合理阈值。
# settings=/tmp/custom.jfc,其中 jdk.VirtualThreadPinned#threshold=0 ms
== synchronized 组 ==
jdk.ThreadPark 793 26537
jdk.VirtualThreadPinned 0 0
== ReentrantLock 组 ==
jdk.ThreadPark 900 30294
jdk.VirtualThreadPinned 0 0
synchronized 组累计跑 4 次、ReentrantLock 组跑 5 次,除下面这一条外 jdk.VirtualThreadPinned 全部为 0。中间有一次 ReentrantLock 组录到 1 条 0.022 ms 的 pin,pinnedReason 是等待 AbstractQueuedSynchronizer$ExclusiveNode 的类初始化,carrierThread 是 ForkJoinPool-1-worker-3——这条恰好是正向对照:阈值 0 ms 下亚毫秒级的 pin 也能被记录,所以两组 50 ms 的 0 不是「事件没开」造成的。它的成因是类初始化等待,与锁本身无关,后续重复运行没有再出现。
所以「虚拟线程里不能写 synchronized」这条规则在 JDK 25 已经不成立。但它留下的另一半问题仍然成立:synchronized 仍然是互斥的。300 个虚拟线程抢同一把锁,载体线程不会被 pin,可它们还是要排队等锁;如果临界区里有 2 ms 的阻塞,整体吞吐仍然被这把锁串行化。成本从「占住载体」变成了「等锁」,事故形态从「载体耗尽」变成「锁竞争」。
真正还会 pin 的是原生帧内阻塞:JNI 调用、某些文件 IO、以及 native 方法里再进入阻塞。这种场景本机没有构造出来,jdk.VirtualThreadPinned 的触发条件与判读留给 6.2;本节只需要记住:JDK 25 上看到 pin,先怀疑 native 帧,不要先怀疑 synchronized。
边界三:线程池隔离的边界要写死
给每个慢下游一个独立池是对的,但「独立」必须包含四件事,少一件就等于没隔离:
| 要素 | 不写死会怎样 | 建议 |
|---|---|---|
| 并发上限 | 池无限扩,慢下游把线程吃光 | 固定大小,等于下游连接数或配额 |
| 队列容量 | 无界队列把内存吃光,延迟无限涨 | 有界,且小于可接受排队的请求数 |
| 拒绝策略 | CallerRuns 把阻塞带回调用线程 | 明确降级或快速失败,不回调进循环线程 |
| 超时 | 线程永久占住,池慢慢枯竭 | 下游超时 < 上游超时,留出组装余量 |
CallerRunsPolicy 在纯线程池里是个好东西:它让提交者自己跑,形成天然背压。但在混合栈里,如果提交者是事件循环线程,CallerRuns 就等于把阻塞调用搬回了循环线程,前面所有隔离工作一次性作废。这条边界必须在代码评审里单独检查。
trace 上下文跨模型的断点
先看三种上下文载体在 JDK 25 上的实际传播行为。本机运行结果(# 后是我加的注释,不是程序输出):
TL in virtual = null # ThreadLocal:不继承
ITL in virtual = itl-main # InheritableThreadLocal:创建时复制
TL in platform = null
ITL in platform = itl-main
SV in virtual = UNBOUND # ScopedValue:普通新线程读不到
SV in platform = UNBOUND
SV after scope = false
ScopedValue 还有一个关键差异:用 StructuredTaskScope fork 出来的子任务能读到绑定值。本机实测(--enable-preview):
SV in STS fork = sv-main
SV in plain vt = UNBOUND
这组结果直接解释了混合栈里 trace 为什么会断:
ThreadLocal一定断。MDC、大多数 trace SDK 的上下文都挂在这里。从循环线程切到工作线程,值就没了。InheritableThreadLocal看起来能救,实际不能。它只在线程创建时复制一次。线程池的线程在应用启动时就创建好了,之后每个请求设置的值根本传不进去;虚拟线程如果每次新建,反而「碰巧」能继承到——这种「有时有、有时没有」比稳定丢失更难查。ScopedValue只在自己的绑定作用域内有效,普通Thread.ofVirtual()/Thread.ofPlatform()新建的线程读不到;只有StructuredTaskScope的 fork 会继承。想跨线程池传递,仍然要在边界上显式搬运。
显式搬运的写法如下。核心是「在提交方快照、在工作线程恢复、执行完还原」,三件事都要做:
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
/** 事件循环线程里的上下文,怎么带进工作线程。 */
public final class ContextBridge {
static final ThreadLocal<String> TRACE = new ThreadLocal<>();
/** 在工作线程里恢复提交方的上下文,执行完再还原,避免污染池内线程。 */
static Executor contextAware(Executor delegate, Supplier<Map<String, String>> snapshot) {
return task -> {
Map<String, String> captured = snapshot.get();
delegate.execute(() -> {
String previous = TRACE.get();
TRACE.set(captured.get("traceId"));
try {
task.run();
} finally {
if (previous == null) TRACE.remove(); else TRACE.set(previous);
}
});
};
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
pool.submit(() -> { }).get(); // 先把池内线程创建出来
TRACE.set("trace-42");
pool.submit(() -> System.out.println("裸线程池看到 = " + TRACE.get())).get();
Executor bridged = contextAware(pool, () -> Map.of("traceId", TRACE.get()));
bridged.execute(() -> System.out.println("桥接后看到 = " + TRACE.get()));
bridged.execute(() -> System.out.println("第二个任务看到 = " + TRACE.get()));
Thread vt = Thread.ofVirtual().start(() ->
System.out.println("虚拟线程直接看到 = " + TRACE.get()));
vt.join();
pool.shutdown();
TRACE.remove();
}
}
本机输出:
裸线程池看到 = null
桥接后看到 = trace-42
第二个任务看到 = trace-42
虚拟线程直接看到 = null
finally 里的还原不能省。池内线程会被复用,如果只 set 不还原,上一个请求的 trace id 会泄漏到下一个请求;这种污染在日志里表现为「A 的 trace 里出现 B 的日志」,排查成本极高。ScopedValue 在这一点上更安全,因为绑定是结构化作用域,作用域结束自动失效——但前提是整条链都在同一个作用域内,跨线程池仍然要显式搬运。
调试手段
转储:先分清三种帧
混合栈的线程转储要按「栈顶是什么」分类,而不是按线程名猜:
| 栈顶特征 | 含义 | 下一步 |
|---|---|---|
KQueue.poll(本机)/ epoll 原生帧(Linux) | 循环线程空闲,等事件 | 正常;看其他线程 |
jdk.internal.misc.Unsafe.park + ForkJoinPool.awaitWork | 载体线程空闲 | 正常;虚拟线程可能未挂载 |
jdk.internal.misc.Unsafe.park + 业务帧 | 业务在等锁或等条件 | 查锁持有者 |
Thread.sleep/socket 读 + 业务帧 | 业务阻塞,若在循环线程上即事故 | 把调用移出循环 |
Continuation.run + Carrying virtual thread | 载体正在执行某个虚拟线程 | 看 Mounted virtual thread 段 |
本机实测还有一条容易踩的坑:jcmd <pid> Thread.print 默认看不到未挂载的虚拟线程。同一个进程里,vt-sleeper 在 Thread.sleep,四条命令的结果是:
jcmd <pid> Thread.print -> 0 处 vt-sleeper
jcmd <pid> Thread.print -l -> 0 处 vt-sleeper
jcmd <pid> Thread.dump_to_file -format=plain <file> -> 1 处,且标记 virtual
jcmd <pid> Thread.dump_to_file -format=json <file> -> 1 处,virtual=true
plain 转储里的样子是 #27 "vt-sleeper" virtual TIMED_WAITING,一眼能看出它不是平台线程。所以排查虚拟线程时不要用 jstack/Thread.print 就下结论「没有业务线程」——那只是没看见。
JFR:看虚拟线程与载体
JFR 在 JDK 25 里能直接标记虚拟线程。本机 jdk.ExecutionSample 的一条样本:
jdk.ExecutionSample {
sampledThread = "vt-85" (javaThreadId = 120, virtual)
state = "STATE_RUNNABLE"
stackTrace = [ java.lang.VirtualThread.run(Runnable) line: 460, ... ]
}
对比同一份录制里载体线程的样本,sampledThread 是 ForkJoinPool-1-worker-N。两类样本放在一起,就能回答「CPU 到底花在虚拟线程的业务代码上,还是花在载体调度上」。
jdk.VirtualThreadPinned 事件本身带 Carrier Thread 字段(jfr metadata 可查),一旦触发可以直接读出是哪个载体被占住。本节的对照实验把阈值降到 0 ms 后,两组 50 ms 阻塞仍是 0,而那条 0.022 ms 的类初始化 pin 证明事件链路是通的。反过来说,直接用默认 profile 看 2 ms 的阻塞得到 0,什么也说明不了——profile.jfc 的 20 ms 阈值会把它过滤掉。
在 libraryd-conc 里落地
把边界规则写成可执行的对象,评审时逐条对,比口头约定可靠:
/** 一条隔离边界的策略:四个要素缺一不可。 */
public record BoundaryPolicy(String name, int maxConcurrent, int queueCapacity,
java.time.Duration timeout, RejectPolicy reject) {
public enum RejectPolicy { ABORT, DEGRADE, CALLER_RUNS }
public BoundaryPolicy {
if (maxConcurrent <= 0 || queueCapacity < 0) {
throw new IllegalArgumentException("maxConcurrent 必须为正,queueCapacity 不得为负");
}
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("timeout 必须为正");
}
}
/**
* callerOnEventLoop 为 true 时禁止 CALLER_RUNS:
* 它会把阻塞调用搬回事件循环线程,隔离失效。
*/
public void validate(boolean callerOnEventLoop) {
if (callerOnEventLoop && reject == RejectPolicy.CALLER_RUNS) {
throw new IllegalStateException(name + ": 事件循环线程上不允许 CallerRuns");
}
if (queueCapacity > maxConcurrent * 100) {
throw new IllegalStateException(name + ": 队列相对并发过大,等于无界");
}
}
/** 下游超时必须小于上游,给组装与写回留余量。 */
public void validateAgainst(java.time.Duration upstreamTimeout) {
if (timeout.compareTo(upstreamTimeout) >= 0) {
throw new IllegalStateException(name + ": 下游超时未小于上游超时");
}
}
}
三个方法对应三条硬规则:循环线程不许 CallerRuns、队列不得相对并发过大、下游超时必须小于上游。每一条都来自前面的事故形态,而不是风格偏好。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 回调里直接查数据库 | 循环线程停摆,全部连接排队 | 提交到有界执行器,回调组装 |
依赖 InheritableThreadLocal 传 trace | 池化线程里是旧值或 null | 提交时快照,执行时恢复 |
只 set 不还原上下文 | 上一个请求的 trace 泄漏到下一个 | finally 里还原或 remove |
用 Thread.print 判断有没有虚拟线程 | 误判「无业务线程」 | 用 Thread.dump_to_file 或 JFR |
看到 pin 就怪 synchronized | 在 JDK 25 上方向错误 | 先查 native 帧,参见 6.2 |
| 给每个下游都开独立池 | 线程总数失控,共享资源仍互相拖累 | 池数受控,共享连接池时合并隔离 |
什么时候不要做线程池隔离:下游本身共享同一个连接池或同一台数据库时,Java 层的池隔离挡不住资源竞争,只会把线程数堆高;请求量很小、慢调用偶发时,隔离带来的线程与监控成本可能超过收益;以及团队还没有统一超时口径时,先统一超时,再谈隔离。
小结
混合栈的问题几乎都出在边界上。执行边界:循环线程里出现业务帧就是踩线,阻塞调用必须移到有界执行器,且不能用 CallerRuns 把它搬回来。锁边界:JDK 25 上 synchronized 不再 pin(本机 threshold=0 ms 下 300 虚拟线程 × 50 ms 实测 0 条),但互斥仍在,事故形态从「载体耗尽」变成「锁竞争」;真正还会 pin 的是原生帧。上下文边界:ThreadLocal 必断,InheritableThreadLocal 只在创建时复制所以池化后失效,ScopedValue 只被 StructuredTaskScope 的 fork 继承,跨线程池必须显式快照与恢复。
观测边界同样要记住:Thread.print 看不见未挂载的虚拟线程,Thread.dump_to_file 与 JFR 可以;转储按栈顶帧分类,比按线程名猜快得多。
下一节进入正确性测试:用 JCStress 的测试模型把这类「有时对、有时错」的问题固定成可重复的用例。
阅读导航:上一节 7.2 两种模型的取舍矩阵 | 下一节 8.1 JCStress 的测试模型
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。