《Java 并发编程·性能诊断与事故》7.3 混合栈的边界与调试

事件循环、虚拟线程、平台线程池同时存在时,事故往往发生在边界上:阻塞调用混进循环、上下文在换线程时丢失、jstack 看不到未挂载的虚拟线程。本节用本机实测标出三种边界与 trace 断点,并给出线程转储、JFR 事件与边界策略的具体判读方法。

承接的问题

构造场景:libraryd-conc 的线上形态是三段式——最前面是事件循环网关(10 个循环线程),中间是虚拟线程业务层,慢下游(mail/sms/push)各自走独立的平台线程池做隔离。某天开始,借阅接口的 P99 从 40 ms 涨到 3.5 s,但平均延迟只涨了 15%。

排查时遇到三个障碍:

  1. 网关线程转储看起来「很正常」:KQueue.poll,没有业务帧,也没有死锁。
  2. 日志里同一条请求有三个不同的 trace id,或者后一段干脆没有 trace id。
  3. jcmd Thread.print 里数不到业务线程——虚拟线程一个都看不见。

这三个现象分别对应混合栈的三类边界:执行边界(阻塞代码跑在了不该跑的线程上)、上下文边界(trace 在换线程时断掉)、观测边界(工具默认只看平台线程)。本节按这三条线展开,每条都给本机可复现的证据。

三种边界

边界一:事件循环里调阻塞 API

最常见的形态是「回调里顺手查一下」:网关收到请求,在 IO 回调里直接调 bookRepository.findByIsbn(),而这个实现走 JDBC。单次 8 ms,看起来无害。

证据在 7.1 已经量过:循环线程执行 50 ms 的阻塞处理时,三个本该 5 ms 完成的并发请求都等到 47–54 ms。根因不是 JDBC 慢,而是这 8 ms 里 select() 没有被调用,所有就绪事件都堆在内核里。

修复分两步,缺一不可:

  1. 把阻塞调用提交到一个有界的独立执行器,回调只负责组装结果。
  2. 结果回来后,如果协议要求在循环线程上写回,就再投递回循环,而不是在工作线程上直接 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 的测试模型

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收