《Java 并发编程·性能诊断与事故》12.2 阻塞点与调用链结合

以 200 ms 临界区把 4 个线程打成 BLOCKED 为场景,说明 ThreadMXBean 能给出状态、锁与持有者却给不出请求归属,ThreadLocal 又无法跨线程读取,于是用线程 ID 注册表把阻塞归因到 requestId,并给出结构化日志、采样策略与实测的扫描开销。

承接的问题

12.1 解决了「池子忙不忙」。紧接着的问题是「谁把池子占住了」。

构造一个 p99 劣化的场景:接口平均 40 ms,p99 从 60 ms 涨到 900 ms,CPU 只有 25%,线程池 4 个线程全部在工作。抓一份线程转储,能看到 3 个 BLOCKED (on object monitor),但转储只说「此刻在等」,不说:

  • 等了多久,是刚撞上还是已经卡了 800 ms;
  • 等的是哪个请求——线程是复用的,loan-worker-2 上午处理过几百个请求;
  • 锁被谁持有,持有者的那段临界区是不是本来就该 200 ms。

JFR(3.1)能回答「等了多久」,但它有阈值和事件量;转储(3.3)能回答「此刻卡在哪一行」,但它是一张照片;调用链能回答「这个线程正在服务哪个请求」,但需要请求上下文。本节把三者拼成一条能落日志的证据链:状态来自 ThreadMXBean,归属来自请求上下文注册表,时长来自连续两次扫描的差值。

先钉死一个限制:ThreadLocal 是线程私有的。监控线程无法读取 loan-worker-2 的 ThreadLocal,这不是 API 没找对,而是设计如此。想要跨线程看到「这个 worker 正在服务哪个请求」,必须在设置 ThreadLocal 的同时,往一个所有线程可见的注册表里写一份。

ThreadMXBean 能给什么、给不出什么

java.lang.management.ThreadMXBean 提供了三档接口:

ThreadInfo info  = mx.getThreadInfo(threadId);          // 单线程,默认栈深度
ThreadInfo[] all = mx.getThreadInfo(ids, 8);            // 批量,指定栈深度
ThreadInfo[] dump = mx.dumpAllThreads(true, true);      // 锁与 synchronizer 信息

单条 ThreadInfo 里对本节有用的字段:

字段含义注意事项
getThreadState()RUNNABLE / BLOCKED / WAITING / TIMED_WAITING只反映取快照那一刻
getStackTrace()栈帧深度受 maxDepth 限制,0 表示不要栈
getLockName()阻塞在哪个监视器同进程内地址可关联,跨进程无意义
getLockOwnerId() / getLockOwnerName()持有者监视器可能已经交接,只代表此刻
getBlockedCount()累计进入 BLOCKED 的次数是次数,不是本次时长
getBlockedTime()累计阻塞毫秒数默认关闭,关闭时返回 -1
getWaitedCount() / getWaitedTime()wait 的累计次数与时间与 BLOCKED 是两套口径

本机实测了两个容易写错的点:

contentionEnabled=false supported=true
cpuEnabled=true supported=true
virtualThreadCountInMXBean=false platformThreads=9
blockedTimeOfMain=-1

第一,线程竞争监控默认关闭。不调用 setThreadContentionMonitoringEnabled(true),getBlockedTime() 恒为 -1;getBlockedCount() 仍会计数(本节实跑未开启该开关,日志里照样读到 blockedCount=1)。开启之后,在一个阻塞约 100 ms 的线程上读到:

while-blocked state=BLOCKED blockedCount=1 blockedTimeMs=111 owner=main
afterExit=null enabled=true

111 ms 是累计值,包含该线程历史上所有阻塞;它不能告诉你「这一次等了多久」。而且线程退出后 getThreadInfo 返回 null,采集器必须判空。用它做「本次阻塞时长」是错的,正确做法是记录第一次观察到 BLOCKED 的时间,用后续扫描的时间差估算。

第二,虚拟线程不在 getAllThreadIds() 里。本机启动一个命名为 v-1 的虚拟线程后,getAllThreadIds() 返回的 9 个 ID 中没有它。虚拟线程的阻塞诊断要走 JFR(jdk.VirtualThreadPinned 等)或 6.2 讲的办法,不要指望 ThreadMXBean。

第三,getThreadInfo 不是免费的。本机 6 个平台线程、连续 500 次调用的平均值:

cost threads=6 depth0=9346ns depth8=25997ns

不要栈约 9.3 µs,带 8 层栈约 26 µs。线程数越多越贵,因为每次都要遍历线程列表并抓栈。按 20 ms 一次扫描算,26 µs 只占单核的 0.13%,可以接受;但如果 1000 个线程、每 5 ms 扫一次、每次都抓 32 层栈,就会变成可观的固定开销,而且抓栈需要 JVM 在安全点协作。采样是必须的,不是可选的。

实测:200 ms 临界区与 8 个请求

靶子做三件事:4 个 worker 线程从一个固定池取任务;每个任务在 ThreadLocal 里放 requestId,同时写入 ConcurrentHashMap<threadId, requestId>;临界区里 sleep(200)。监控线程每 20 ms 扫一次,只处理 BLOCKED 且线程名以 loan-worker- 开头的,按 1/4 采样打印。

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.time.Instant;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

public final class BlockingTraceProbe {

    static final ThreadLocal<String> CURRENT_REQUEST = new ThreadLocal<>();
    static final ConcurrentHashMap<Long, String> IN_FLIGHT = new ConcurrentHashMap<>();
    static final Object DAO_LOCK = new Object();
    static final AtomicLong detections = new AtomicLong();
    static final AtomicLong sampled = new AtomicLong();
    static final ThreadMXBean MX = ManagementFactory.getThreadMXBean();

    static void begin(String requestId) {
        CURRENT_REQUEST.set(requestId);
        IN_FLIGHT.put(Thread.currentThread().threadId(), requestId);
    }

    static void end() {
        IN_FLIGHT.remove(Thread.currentThread().threadId());
        CURRENT_REQUEST.remove();
    }

    static void log(String event, String message) {
        String requestId = CURRENT_REQUEST.get();
        System.out.printf(Locale.ROOT,
                "ts=%s level=INFO thread=%s requestId=%s event=%s msg=%s%n",
                Instant.now(), Thread.currentThread().getName(),
                requestId == null ? "-" : requestId, event, message);
    }

    public static void main(String[] args) throws Exception {
        measureCost();
        ThreadFactory factory = new ThreadFactory() {
            private final AtomicInteger seq = new AtomicInteger();
            @Override
            public Thread newThread(Runnable r) {
                return Thread.ofPlatform().name("loan-worker-" + seq.incrementAndGet()).unstarted(r);
            }
        };
        ExecutorService pool = Executors.newFixedThreadPool(4, factory);
        Thread monitor = Thread.ofPlatform().name("block-monitor").start(BlockingTraceProbe::monitorLoop);

        long start = System.nanoTime();
        for (int i = 1; i <= 8; i++) {
            String requestId = String.format("REQ-%02d", i);
            pool.execute(() -> handle(requestId, start));
            Thread.sleep(15);
        }
        pool.shutdown();
        pool.awaitTermination(10, TimeUnit.SECONDS);
        monitor.interrupt();
        monitor.join(2_000);
        System.out.printf(Locale.ROOT, "summary detections=%d sampled=%d inFlightLeft=%d%n",
                detections.get(), sampled.get(), IN_FLIGHT.size());
    }

    static void handle(String requestId, long startNanos) {
        begin(requestId);
        long t0 = System.nanoTime();
        log("request-begin", "enter service");
        log("dao-enter", "about to call repository under monitor");
        synchronized (DAO_LOCK) {
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        log("dao-exit", "repository returned");
        long costMs = (System.nanoTime() - t0) / 1_000_000L;
        log("request-end", "costMs=" + costMs
                + " sinceSubmitMs=" + (System.nanoTime() - startNanos) / 1_000_000L);
        end();
    }

    static void monitorLoop() {
        while (!Thread.currentThread().isInterrupted()) {
            long[] ids = MX.getAllThreadIds();
            ThreadInfo[] infos = MX.getThreadInfo(ids, 8);
            for (ThreadInfo info : infos) {
                if (info == null || info.getThreadState() != Thread.State.BLOCKED) {
                    continue;
                }
                if (!info.getThreadName().startsWith("loan-worker-")) {
                    continue;
                }
                long seq = detections.incrementAndGet();
                if (seq % 4 != 0) {
                    continue;
                }
                sampled.incrementAndGet();
                String requestId = IN_FLIGHT.getOrDefault(info.getThreadId(), "-");
                StackTraceElement top = info.getStackTrace().length == 0
                        ? null : info.getStackTrace()[0];
                System.out.printf(Locale.ROOT,
                        "ts=%s level=WARN thread=block-monitor detection=%d state=%s worker=%s tid=%d "
                                + "requestId=%s blockedCount=%d lock=%s owner=%s top=%s%n",
                        Instant.now(), seq, info.getThreadState(), info.getThreadName(),
                        info.getThreadId(), requestId, info.getBlockedCount(),
                        info.getLockName(), info.getLockOwnerName(),
                        top == null ? "-" : top.getClassName() + "." + top.getMethodName()
                                + ":" + top.getLineNumber());
            }
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                return;
            }
        }
    }

    static void measureCost() {
        long[] ids = MX.getAllThreadIds();
        long t0 = System.nanoTime();
        for (int i = 0; i < 500; i++) MX.getThreadInfo(ids, 0);
        long depth0 = (System.nanoTime() - t0) / 500;
        t0 = System.nanoTime();
        for (int i = 0; i < 500; i++) MX.getThreadInfo(ids, 8);
        long depth8 = (System.nanoTime() - t0) / 500;
        System.out.printf(Locale.ROOT,
                "cost threads=%d depth0=%dns depth8=%dns%n", ids.length, depth0, depth8);
    }
}

运行后,与本条线索相关的输出如下(节选,ts 是 Instant.now() 的 UTC 表示;源码行号随排版变化,不必对齐):

cost threads=6 depth0=9346ns depth8=25997ns
ts=...05.228731Z level=INFO thread=loan-worker-1 requestId=REQ-01 event=dao-enter msg=about to call repository under monitor
ts=...05.249653Z level=INFO thread=loan-worker-2 requestId=REQ-02 event=dao-enter msg=about to call repository under monitor
ts=...05.272380Z level=INFO thread=loan-worker-3 requestId=REQ-03 event=dao-enter msg=about to call repository under monitor
ts=...05.295045Z level=INFO thread=loan-worker-4 requestId=REQ-04 event=dao-enter msg=about to call repository under monitor
ts=...05.302225Z level=WARN thread=block-monitor detection=4 state=BLOCKED worker=loan-worker-3 tid=28 requestId=REQ-03 blockedCount=1 lock=java.lang.Object@2fd17c20 owner=loan-worker-1 top=BlockingTraceProbe.handle:78
ts=...05.382167Z level=WARN thread=block-monitor detection=12 state=BLOCKED worker=loan-worker-2 tid=27 requestId=REQ-02 blockedCount=1 lock=java.lang.Object@2fd17c20 owner=loan-worker-1 top=BlockingTraceProbe.handle:78
ts=...05.441012Z level=INFO thread=loan-worker-1 requestId=REQ-01 event=request-end msg=costMs=212 sinceSubmitMs=218
ts=...05.723698Z level=WARN thread=block-monitor detection=48 state=BLOCKED worker=loan-worker-2 tid=27 requestId=REQ-06 blockedCount=2 lock=java.lang.Object@2fd17c20 owner=loan-worker-3 top=BlockingTraceProbe.handle:78
ts=...06.269684Z level=INFO thread=loan-worker-1 requestId=REQ-05 event=request-end msg=costMs=828 sinceSubmitMs=1049
ts=...06.875129Z level=INFO thread=loan-worker-4 requestId=REQ-08 event=request-end msg=costMs=815 sinceSubmitMs=1654
summary detections=126 sampled=31 inFlightLeft=0

这段输出能回答开头那三个问题。

等了多久。 REQ-03 在 05.272 打完 dao-enter,05.302 就被扫到 BLOCKED;REQ-01 在 05.438 才 dao-exit,临界区实测 212 ms。从 detection 计数看,监控线程在 REQ-03 的阻塞窗口内至少扫到它 4 次,可以据此估算等待时长。注意这是下界:真实开始等待的时刻在两次扫描之间,误差上限就是一个扫描周期(20 ms)。

是哪个请求。 每条 WARN 都带 requestId=REQ-03。这个字段不是 ThreadMXBean 给的,而是监控线程拿 info.getThreadId() 去 IN_FLIGHT 里查出来的。如果没有这张表,日志里只有 loan-worker-3 BLOCKED;而 loan-worker-3 在 05.850 又接了 REQ-07,线程名和请求不是一一对应。

锁被谁持有。 owner=loan-worker-1,再用同一个注册表查 26 号线程,就能把持有者归因到 REQ-01。lock=java.lang.Object@2fd17c20 在本次进程内唯一标识这把监视器;跨进程、GC 之后不能拿来比较。

还有一个值得注意的现象:临界区只有 200 ms,但 REQ-05 的 costMs=828、REQ-08 的 costMs=815。原因是 synchronized 是非公平的,刚释放的锁容易被新到达的线程抢走,排队的老请求被反复插队。这不是 ThreadMXBean 的问题,而是「200 ms 临界区 + 非公平监视器」的尾延迟放大;定位到这一点之后,修复方向是缩短临界区或换公平锁/分段锁(11.3),而不是继续加线程。

只有访问日志行不行? 不行。在 05.30 那一刻,日志里有 4 条 request-begin 没有对应的 request-end,候选是 REQ-01 到 REQ-04 四个。要缩到一个,必须知道「哪个线程正在服务哪个请求」,而线程名在这一批里恰好还能对上,下一批就换人了。注册表就是把这个映射显式化。

埋点方案:ThreadLocal + 注册表 + 结构化日志 + 采样

把实验代码收敛成可复用的采集器,核心是四件事:

  1. 请求入口写上下文。 ThreadLocal 供本线程日志使用,ConcurrentHashMap<threadId, requestId> 供其他线程查询;退出时必须两边都清理。
  2. 日志字段固定。 至少包含 ts、level、thread、requestId、event、msg;有 traceId 的话一并带上,方便跳到调用链系统。
  3. 只在超过阈值后上报。 第一次看到 BLOCKED 只记开始时间,连续观察到同一线程仍在阻塞且超过 100 ms 才输出,避免把正常的短临界区刷屏。
  4. 采样与限流。 状态计数每次扫描都做(便宜),抓栈按 1/N 采样;同一 (threadId, requestId, owner) 在 5 秒内只报一次。
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.LongFunction;

public final class BlockageWatch {

    private final ThreadMXBean mx = ManagementFactory.getThreadMXBean();
    private final LongFunction<String> requestOf;
    private final long thresholdNanos;
    private final long reportIntervalNanos;
    private final int stackDepth;
    private final Map<Long, Long> blockedSince = new ConcurrentHashMap<>();
    private final Map<Long, Long> lastReported = new ConcurrentHashMap<>();
    private long scans, reports;

    public BlockageWatch(LongFunction<String> requestOf, long thresholdMillis,
                         long reportIntervalMillis, int stackDepth) {
        this.requestOf = requestOf;
        this.thresholdNanos = thresholdMillis * 1_000_000L;
        this.reportIntervalNanos = reportIntervalMillis * 1_000_000L;
        this.stackDepth = stackDepth;
    }

    public void scan() {
        scans++;
        long now = System.nanoTime();
        Set<Long> stillBlocked = new HashSet<>();
        for (ThreadInfo info : mx.getThreadInfo(mx.getAllThreadIds(), stackDepth)) {
            if (info == null || info.getThreadState() != Thread.State.BLOCKED) {
                continue;
            }
            long id = info.getThreadId();
            stillBlocked.add(id);
            long since = blockedSince.computeIfAbsent(id, ignored -> now);
            long waited = now - since;
            if (waited < thresholdNanos || scans % 4 != 0) {
                continue;
            }
            Long last = lastReported.get(id);
            if (last != null && now - last < reportIntervalNanos) {
                continue;
            }
            lastReported.put(id, now);
            reports++;
            System.out.printf("level=WARN event=pool-blocked worker=%s requestId=%s "
                            + "waitedMsAtLeast=%d owner=%s top=%s%n",
                    info.getThreadName(), requestOf.apply(id), waited / 1_000_000L,
                    info.getLockOwnerName(), topFrame(info));
        }
        blockedSince.keySet().retainAll(stillBlocked);
        lastReported.keySet().retainAll(stillBlocked);
    }

    private static String topFrame(ThreadInfo info) {
        StackTraceElement[] frames = info.getStackTrace();
        if (frames.length == 0) {
            return "-";
        }
        StackTraceElement top = frames[0];
        return top.getClassName() + "." + top.getMethodName() + ":" + top.getLineNumber();
    }
}

这段代码有两个刻意的设计。waitedMsAtLeast 的命名提醒读者它是下界;blockedSince 与 lastReported 在扫描结束时按当前阻塞集合裁剪,避免长时间运行后 map 只增不减。scans % 4 是采样,生产上可以换成按 threadId 哈希或按请求 token 采样,保证不同线程都能被轮到。

ThreadLocal 一侧仍然要 remove()。线程池复用线程时,不清理会把上一个请求的 ID 带给下一个请求,日志归因直接错位,而且 ThreadLocal 的值会跟着线程活很久。InheritableThreadLocal 也不解决问题:它只在创建子线程时复制,线程池里的线程不是请求的子线程。JDK 25 已转正的 ScopedValue 更适合结构化并发,但跨线程查询同样需要注册表,原因还是那条:别人读不到你的绑定值。

在 libraryd-conc 里落地

conc-ops 里把采集器接在 LoanService.reserve 的入口和出口:

public Reservation reserve(String isbn, String memberId) {
    String requestId = RequestIds.next();
    RequestContext.begin(requestId);
    try {
        log("request-begin", "isbn=" + isbn);
        Reservation reservation = delegate.reserve(isbn, memberId);
        log("request-end", "loanId=" + reservation.loanId());
        return reservation;
    } finally {
        RequestContext.end();
    }
}

监控线程持有 BlockageWatch,每 20 ms 调一次 scan(),requestOf 直接传 RequestContext::currentRequestOf(内部读 IN_FLIGHT)。告警侧不直接对「检测到 BLOCKED」报警,而是对「同一请求的阻塞下界超过 500 ms」或「每分钟阻塞请求数超过阈值」报警;前者是症状,后者才是容量信号。采样日志进本地文件,聚合计数进指标系统,两者用同一个 requestId 关联。

什么时候不要用这套:

  • 虚拟线程为主的应用。ThreadMXBean 看不到虚拟线程,应该用 JFR 的虚拟线程事件;混跑时只对平台线程池开启。
  • 极短临界区(微秒级)。20 ms 的扫描周期基本抓不到,收益低于开销,直接用 JFR 的阈值录制。
  • 线程数上千且每个请求都要抓栈。把 stackDepth 降到 4,采样比降到 1/16,否则抓栈本身就是负担。

常见错误与边界

  • 从监控线程读别人的 ThreadLocal。 读到的永远是自己线程的值(通常是 null),必须用注册表。
  • 把 getBlockedCount() 当本次阻塞时长。 它是累计次数;getBlockedTime() 是累计毫秒,且默认关闭、返回 -1。
  • 忘了 ThreadLocal.remove()。 线程复用时请求 ID 串台,注册表也会留下已结束请求的映射。
  • 只认 BLOCKED。 等连接池、等队列、LockSupport.park 都是 WAITING/TIMED_WAITING,只筛 BLOCKED 会漏掉大部分真实阻塞。
  • 不留 requestId 就退出。 线程结束时注册表项不清理,既是内存泄漏,也会让后续归因指向错误的请求。
  • 在虚拟线程上等 getAllThreadIds()。 它不会出现,采集器会永远报「没有阻塞」。

小结

阻塞诊断的证据链由三段拼成:ThreadMXBean 给出「谁在等、等什么锁、谁持有」,请求上下文注册表给出「这个线程正在服务哪个请求」,两次扫描的时间差给出「至少等了多久」。ThreadLocal 只解决本线程日志的归属,跨线程可见性必须靠注册表。

本机实测的三个数字:6 个平台线程、8 层栈的一次全量快照约 26 µs,所以采样不可省;200 ms 的临界区在非公平监视器下把个别请求推到 815 ms,尾延迟不是临界区长度本身;1.7 秒内检出 126 次 BLOCKED、按 1/4 采样输出 31 条,每条都能落到具体 requestId。下一节把这些证据变成阈值:什么水位该 P1,什么水位只配 P3,以及为什么「指标已经饱和但没告警」。

阅读导航:上一节 12.1 线程与池的指标口径 | 下一节 12.3 告警阈值与容量水位

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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