《Java 并发编程·性能诊断与事故》12.1 线程与池的指标口径

从一次「active=8、queue=0 却仍被判定池满」的误判出发,逐条拆解 ThreadPoolExecutor 的 active、pool、queue、completed、taskCount 各自在数什么、哪些是快照哪些是估值,给出拒绝计数器的自埋写法,并用 120 个任务的实跑时序数据核对 getTaskCount 的分解式与跨 getter 的不一致。

承接的问题

第 11 章讲的是把热点锁拆开。拆完之后,下一步通常是把它接进监控:面板上放几个数字,超过阈值就报警。问题恰恰出在「放哪几个数字」上。

构造一个很常见的误判。某次压测,面板显示 active=8、poolSize=8、queue=0,值班同学判定「线程池满了,CPU 打满」,于是把 maximumPoolSize 从 8 调到 16。改完重压,RT 没有下降,反而因为线程更多、上下文切换更频繁而略微上升。回头看,那 8 个线程都在 Thread.sleep(250) 里等下游,active 高只说明「有 8 个任务被领走了」,不说明 CPU 忙;queue=0 也不是「没有排队」,而是那一刻恰好没有任务在队列里。

另一种误判来自拒绝。JDK 的 ThreadPoolExecutor 没有拒绝计数器。用默认的 AbortPolicy 时,任务被丢弃并抛异常;如果异常被上层吞掉,监控面板上的「拒绝数」永远是 0,直到用户投诉。

这一节只解决一件事:把 ThreadPoolExecutor 暴露的几个数字逐个钉死——它数的是谁、什么时候取的、能不能拿来做等式、哪些必须自己埋。

本机口径:

Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8

写作时 uptime 的 load average 是 34.96 35.27 41.45。下面的计时都是单次运行、随调度波动、不作基准,只用于验证语义,不用于比较快慢。

六个 getter 各在数什么

先把结论列成表。所有「实现要点」都来自本机 $JAVA_HOME/lib/src.zip 里的 java.util.concurrent.ThreadPoolExecutor,不是凭印象。

getter在数什么实现要点快照性质单调性典型坑
getCorePoolSize()配置的 core 大小读 corePoolSize 字段配置值只在 setCorePoolSize 后变当成「当前核心线程数」
getMaximumPoolSize()配置的 max 大小读 maximumPoolSize 字段配置值只在 setMaximumPoolSize 后变以为 pool 会立刻长到 max
getPoolSize()当前存活的 worker 线程数mainLock 下读 workers.size();TIDYING 及以后直接返回 0同一把锁下的瞬时快照可升可降包含空闲线程;shutdown 后变 0
getActiveCount()正在执行任务的 worker 数mainLock 下遍历 workers,数 w.isLocked() 为真的个数近似值(javadoc 原文 “approximate”)可升可降睡在任务里也算 active;不含 getTask() 中的空闲线程
getLargestPoolSize()历史同时存活线程数的峰值mainLock 下读 largestPoolSize高水位单调不减不会因为线程回收而下降
getQueue().size()队列里排队的任务数队列自己的 size(),和线程池不是同一把锁队列的瞬时值可升可降与 active 不是同一时刻;无界队列没有上界
getCompletedTaskCount()已完成任务总数completedTaskCount 加上各 worker 的 completedTasks近似值,但 javadoc 承诺不回退单调不减包含抛异常结束的任务
getTaskCount()曾提交执行的任务总数已完成 + 各 worker 在跑(isLocked)+ 队列长度近似值javadoc 未承诺单调不含被拒绝的任务;跨 getter 不能做等式
拒绝数被拒绝的任务数JDK 没有,必须自己实现 handler 计数自埋自埋面板永远是 0

表里有三个点需要展开。

第一,getActiveCount() 数的是「worker 是否持有自己的锁」,而 worker 只在 task.run() 前后加解锁。getTask() 取任务的等待阶段是不持锁的,所以空闲但存活的线程不计入 active;反过来,任务里 Thread.sleep、等锁、等网络都算 active。它回答的是「有几个任务正在被处理」,不是「有几个核在忙」。这一点和 poolSize 一起看才有意义:active == poolSize 表示没有空闲 worker,但不表示系统过载。

第二,getPoolSize() 在 TIDYING 之后返回 0。源码里专门写了注释,避免出现 isTerminated() && getPoolSize() > 0。所以别把 poolSize==0 当成「从来没用过线程」。

第三,getTaskCount() 的分解式是:

getTaskCount() = completedTaskCount + Σ worker.completedTasks
               + (正在执行任务的 worker 数) + workQueue.size()

前三项合起来就是 getCompletedTaskCount(),所以它近似等于 completed + active + queue。但三个 getter 各自拿一次 mainLock,中间状态会变,不能把连续三次调用的结果当成同一时刻的等式。下面的实测里会看到一个差 1 的例子。

实跑:120 个任务、4 到 8 个线程、16 槽队列

靶子的参数:core 4、max 8、ArrayBlockingQueue(16)、空闲 2 秒回收、拒绝只计数。提交线程以每 20 ms 一个的节奏提交 120 个任务(约 50 个/秒),每个任务睡 250 ms。8 个线程的理论吞吐是 32 个/秒,队列只有 16 槽,必然出现拒绝。

import java.util.Locale;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public final class PoolMetricsProbe {

    static final AtomicLong submitted = new AtomicLong();
    static final AtomicLong rejected = new AtomicLong();

    public static void main(String[] args) throws Exception {
        ThreadFactory factory = new ThreadFactory() {
            private int seq;
            @Override
            public Thread newThread(Runnable r) {
                return Thread.ofPlatform().name("loan-worker-" + (++seq)).unstarted(r);
            }
        };
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                4, 8, 2, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(16),
                factory,
                (r, executor) -> rejected.incrementAndGet());

        System.out.println("t_ms,active,pool,core,max,largest,queue,taskCount,completed,rejected");
        long t0 = System.nanoTime();
        Thread sampler = Thread.ofPlatform().name("sampler").start(() -> {
            while (System.nanoTime() - t0 < 6_000_000_000L) {
                long t = (System.nanoTime() - t0) / 1_000_000L;
                System.out.printf(Locale.ROOT, "%d,%d,%d,%d,%d,%d,%d,%d,%d,%d%n",
                        t,
                        pool.getActiveCount(),
                        pool.getPoolSize(),
                        pool.getCorePoolSize(),
                        pool.getMaximumPoolSize(),
                        pool.getLargestPoolSize(),
                        pool.getQueue().size(),
                        pool.getTaskCount(),
                        pool.getCompletedTaskCount(),
                        rejected.get());
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    return;
                }
            }
        });

        for (int i = 0; i < 120; i++) {
            submitted.incrementAndGet();
            pool.execute(() -> {
                try {
                    Thread.sleep(250);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
            Thread.sleep(20);
        }

        Thread.sleep(1_000);
        System.out.printf(Locale.ROOT,
                "before-shutdown submitted=%d rejected=%d taskCount=%d completed=%d active=%d queue=%d%n",
                submitted.get(), rejected.get(), pool.getTaskCount(),
                pool.getCompletedTaskCount(), pool.getActiveCount(), pool.getQueue().size());

        pool.shutdown();
        boolean drained = pool.awaitTermination(10, TimeUnit.SECONDS);
        System.out.printf(Locale.ROOT,
                "after-shutdown drained=%s poolSize=%d largest=%d taskCount=%d completed=%d%n",
                drained, pool.getPoolSize(), pool.getLargestPoolSize(),
                pool.getTaskCount(), pool.getCompletedTaskCount());
        System.out.printf(Locale.ROOT, "accounting: submitted - rejected = %d, taskCount = %d%n",
                submitted.get() - rejected.get(), pool.getTaskCount());
        sampler.join(2_000);
    }
}

编译运行:

javac --release 25 -d classes PoolMetricsProbe.java
java -cp classes PoolMetricsProbe

本机输出的时序(原样,单位毫秒):

t_ms,active,pool,core,max,largest,queue,taskCount,completed,rejected
2,0,0,4,8,0,0,0,0,0
223,4,4,4,8,4,4,8,0,0
434,4,4,4,8,4,7,15,4,0
636,4,4,4,8,4,11,23,8,0
840,4,4,4,8,4,16,30,10,0
1051,8,8,4,8,8,16,37,13,1
1259,8,8,4,8,8,15,43,20,2
1469,8,8,4,8,8,16,50,26,2
1680,8,8,4,8,8,15,56,33,4
1885,8,8,4,8,8,16,63,39,4
2092,8,8,4,8,8,15,69,46,5
2297,8,8,4,8,8,16,76,52,6
2506,8,8,4,8,8,16,82,58,7
2714,8,8,4,8,8,15,88,65,8
2924,8,8,4,8,8,16,95,71,10
3125,8,8,4,8,8,15,101,78,12
3332,8,8,4,8,8,15,107,84,13
3540,8,8,4,8,8,7,107,92,13
3747,8,8,4,8,8,2,107,97,13
3956,3,8,4,8,8,0,107,105,13
4163,0,8,4,8,8,0,107,107,13
before-shutdown submitted=120 rejected=13 taskCount=107 completed=107 active=0 queue=0
after-shutdown drained=true poolSize=0 largest=8 taskCount=107 completed=107

从这张表能读出四个事实。

线程池的扩容顺序是 core → 队列 → max。 840 ms 时队列已经 16/16 满了,poolSize 还停在 4;到 1051 ms 才变成 8。也就是说「队列满了」比「池满了」早出现约 200 ms,而且在这 200 ms 里线程池正在扩容。拿「队列满」直接判断「池满」会误判,拿「队列满」判断「该扩容」也要看 maximumPoolSize 是否还有空间。

taskCount 等于 completed + active + queue。 1051 ms 这一行:37 = 13 + 8 + 16,完全对上;1680 ms 这一行:56 = 33 + 8 + 15,也对上。最终 submitted - rejected = 120 - 13 = 107,正好等于 taskCount 和 completed,说明被拒绝的任务既不进 taskCount,也不进 completed。

跨 getter 的等式会差 1。 3956 ms 那一行:active=3、completed=105、taskCount=107,按分解式应该是 105 + 3 = 108。差的这 1 个不是 bug,而是采样顺序造成的:getActiveCount() 先取,getTaskCount() 后取,两次调用之间一个任务结束、worker 解锁,于是 active 少了一个,completed 多了一个。监控代码里如果想算「队列里还有多少没完成」,用一次 getTaskCount() - getCompletedTaskCount(),不要自己拼三个 getter。

shutdown 之后 poolSize 变 0,largestPoolSize、taskCount、completed 保留。 largest=8 是历史峰值,不会因为线程回收而回落到 4。这也是判断「这个池历史上有没有扩到过 max」的唯一公开入口。

拒绝数必须自己埋

ThreadPoolExecutor 只提供 RejectedExecutionHandler 回调,不维护计数。想要「既计数又保持 AbortPolicy 语义」,写一个包装:

import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.atomic.AtomicLong;

public final class CountingAbortPolicy implements RejectedExecutionHandler {
    private final AtomicLong rejected = new AtomicLong();

    @Override
    public void rejectedExecution(Runnable task, ThreadPoolExecutor executor) {
        long total = rejected.incrementAndGet();
        String pool = executor.toString();
        System.err.printf("level=WARN event=pool-reject pool=%s rejectedTotal=%d%n", pool, total);
        throw new RejectedExecutionException("rejected by " + pool + ", total=" + total);
    }

    public long rejected() {
        return rejected.get();
    }
}

三个细节:

  • 用 AtomicLong 而不是 LongAdder 也可以;两者在高并发下都够用。LongAdder 的 sum() 是弱一致快照,做告警看趋势没问题,做账要对账就在采集时读一次并接受它是近似值。
  • CallerRunsPolicy 不会走到这里,它把任务交给提交线程执行,表现为「提交变慢」而不是「拒绝」。如果面板只统计拒绝数,CallerRuns 造成的背压是看不见的,必须同时看提交线程的耗时。
  • 「提交数」也要自己记,拒绝率的分母是提交数,不是完成数。只记 completed 会得到 rejected / completed,在严重过载时这个比值会失真。

把三个数字放进一个快照对象,采集端只读一次:

import java.util.concurrent.ThreadPoolExecutor;

public record PoolSnapshot(
        long atMillis,
        int active, int poolSize, int core, int max, int largest,
        int queueSize, int remainingCapacity,
        long taskCount, long completed, long submitted, long rejected) {

    public static PoolSnapshot of(ThreadPoolExecutor pool, long submitted, long rejected) {
        return new PoolSnapshot(
                System.currentTimeMillis(),
                pool.getActiveCount(),
                pool.getPoolSize(),
                pool.getCorePoolSize(),
                pool.getMaximumPoolSize(),
                pool.getLargestPoolSize(),
                pool.getQueue().size(),
                pool.getQueue().remainingCapacity(),
                pool.getTaskCount(),
                pool.getCompletedTaskCount(),
                submitted,
                rejected);
    }

    public double utilization() {
        return poolSize == 0 ? 0 : active / (double) poolSize;
    }

    public double saturation() {
        return max == 0 ? 0 : active / (double) max;
    }

    public double queueFill() {
        int capacity = queueSize + remainingCapacity;
        return capacity == 0 ? 0 : queueSize / (double) capacity;
    }

    public double rejectRate() {
        return submitted == 0 ? 0 : rejected / (double) submitted;
    }
}

注意 queueFill() 的分母是 size + remainingCapacity,这对有界队列成立;无界队列的 remainingCapacity() 是 Integer.MAX_VALUE,算出来恒等于 0,别拿它做水位。无界队列的水位只能看绝对值加堆内存,这条在 10.1 已经讲过。

在 libraryd-conc 里落地

conc-ops 的采集器不直接暴露 ThreadPoolExecutor 的原始 getter,而是固定输出下面这组字段。这样面板和告警用的是同一套口径,换实现(比如以后换成虚拟线程的 ThreadPerTaskExecutor)时只改采集器。

输出字段来源用途不要用来做什么
pool.activegetActiveCount()趋势、判断有无空闲 worker判断 CPU 是否饱和
pool.sizegetPoolSize()线程数趋势、是否达到 max判断队列是否积压
pool.utilizationactive / poolSize空闲比例单独告警
pool.saturationactive / max距离扩容上限多远在 max 很大时判断过载
queue.size / queue.fillgetQueue().size() 与容量排队深度用单点值判断抖动
pool.completedgetCompletedTaskCount()吞吐趋势当成功数(含异常结束)
pool.taskCountgetTaskCount()未完成量 = taskCount - completed和 submitted 做等式
pool.rejected自埋 handler拒绝率分子用默认 handler 时期望非 0
pool.rejectRaterejected / submitted过载程度用 completed 做分母

采集频率建议 1 秒一次起步。getActiveCount()、getPoolSize()、getTaskCount() 都要拿 mainLock,而 addWorker、processWorkerExit 也拿这把锁;在每秒成百上千次创建/销毁线程的场景里,高频采集会和线程增删抢锁。本机在 8 个 worker(都睡在任务里)、JIT 预热后的紧循环里实测单次调用约 22–49 ns(getActiveCount)和 34–40 ns(getTaskCount),这是无竞争的理想值;1 秒一次可以忽略,10 ms 一次就没有必要了。

生产上通常把这些字段交给 Micrometer 或 Prometheus 客户端导出,但那只是传输层。口径仍然是这一节讲的这些:active 是估值、queue.size() 是另一个时刻、拒绝数要自己埋。换库不改变语义。

常见错误与边界

  • 把 active 当 CPU 使用率。 本机 8 个线程都在 sleep(250),active=8,CPU 接近空闲。要判断 CPU,看 OperatingSystemMXBean.getSystemCpuLoad() 或容器指标。
  • 把 queue=0 当「没排队」。 它只说明采样那一刻队列为空;任务可能在 worker 手里,也可能刚被取走。
  • 把 poolSize == max 当池满。 池满的判据是「队列满 + 活跃线程 = max」;单看线程数会把「已经扩容但队列还空」误判成过载。
  • 用 getTaskCount() 和 submitted 对账。 拒绝的任务不进 taskCount;两者差的就是拒绝数,前提是提交侧也自己计数。
  • 跨 getter 拼等式。 三次调用三个时刻,差 1 是常态;要组合就读一次快照对象。
  • 把 getCompletedTaskCount() 当成功数。 任务抛异常也会被计为完成。
  • 在 shutdown 后继续用 poolSize 做除法。 TIDYING 之后它返回 0,active / poolSize 会得到 NaN 或除零。
  • 开了 CallerRunsPolicy 却只看拒绝数。 背压转移到提交线程,面板上拒绝数是 0,但接口 RT 已经上去了。
  • 给无界队列算填充率。 remainingCapacity() 是 Integer.MAX_VALUE,填充率恒为 0。

小结

这一节把六个 getter 拆成了三类:配置值(core、max)、瞬时快照(poolSize、queue.size)、近似/高水位(activeCount、completed、taskCount、largestPoolSize)。判断过载要组合看:active == max 且 queue.fill 持续上升才叫饱和;active 单独高只说明「任务在被处理」。

两个实测结论值得记住:线程池的扩容顺序让「队列满」比「线程池满」早出现约 200 ms;taskCount = completed + active + queue 在单次采样内成立,但跨三次 getter 调用会差 1。拒绝数 JDK 不提供,必须自己埋 handler,并且拒绝率的分母是提交数。下一节把这些数字和调用链对上:知道池子忙,还要知道是哪个请求在阻塞。

阅读导航:上一节 11.3 锁粒度、热点与拆分 | 下一节 12.2 阻塞点与调用链结合

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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