承接的问题
第 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.active | getActiveCount() | 趋势、判断有无空闲 worker | 判断 CPU 是否饱和 |
pool.size | getPoolSize() | 线程数趋势、是否达到 max | 判断队列是否积压 |
pool.utilization | active / poolSize | 空闲比例 | 单独告警 |
pool.saturation | active / max | 距离扩容上限多远 | 在 max 很大时判断过载 |
queue.size / queue.fill | getQueue().size() 与容量 | 排队深度 | 用单点值判断抖动 |
pool.completed | getCompletedTaskCount() | 吞吐趋势 | 当成功数(含异常结束) |
pool.taskCount | getTaskCount() | 未完成量 = taskCount - completed | 和 submitted 做等式 |
pool.rejected | 自埋 handler | 拒绝率分子 | 用默认 handler 时期望非 0 |
pool.rejectRate | rejected / 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 阻塞点与调用链结合
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。