服务时间、吞吐与并行度:队列是有成本的容量
core=8、max=200、无界队列的线程池,任务再多也只跑 8 个线程——因为入队成功就不会触发扩容。本页沿 OpenJDK 17u 源码解释线程池的接纳顺序,再用带单位的算例约束线程数、连接数与队列上限。队列网络各层如何串联见队列网络、扇出与瓶颈迁移。
服务时间、吞吐与并行度
单请求服务时间和系统响应时间不是同一个量。增加线程数可提高等待型负载的并行度,但会增加调度、内存、连接和下游争用;CPU 密集负载的并行度受有效核心数与临界区约束。
实例理论吞吐由资源瓶颈和工作单元服务时间决定。扩实例仅在线性扩展假设成立时近似提高吞吐;共享数据库、缓存、分区、网络、协调器和热点键可能成为新瓶颈。容量计划应设置工作区间和余量,不以压测出现的瞬时最大吞吐作为日常接近饱和的目标。预留故障节点后仍须满足 SLO,且故障切换本身的流量再分配可能触发第二次过载。
队列是有成本的容量
有限队列提供缓冲和突发吸收,但其容量对应最大排队时间和内存。若排队时间已经超过业务 deadline,保留该请求只会拖慢其他请求。无限队列把过载变成延迟、堆内存和级联故障;满队列策略可以拒绝、丢弃低优先级工作、覆盖旧状态或向上游背压,每种做法都要求明确可丢失性和反馈路径。
服务链中的每一层都可能排队:负载均衡、Web worker、应用线程池、连接池、Broker、数据库和远程服务。总延迟是各层排队和处理构成,若只观察入口队列会漏掉瓶颈迁移。
固定版本源码:为什么 maxPoolSize 没有生效
源码基线为 OpenJDK jdk17u,tag jdk-17.0.12+7,ThreadPoolExecutor.java。入口 execute 读取打包运行状态和 worker 数的 ctl,先尝试 addWorker(command,true) 创建核心 worker;核心数已满足后尝试 workQueue.offer;队列满才调用 addWorker(command,false) 在最大线程数范围内创建 worker;失败则交拒绝策略。任务执行由 worker 的 runWorker 循环取任务并运行,队列不为每个任务创建线程。
下面是核对上述源码后的精简改写,不是完整源码,省略空值、日志和部分异常处理:
if (workers < core && addCoreWorker(task)) return;
if (running && queue.offer(task)) {
// 入队与shutdown可竞争,必须重新检查。
if (!runningNow() && queue.remove(task)) reject(task);
else if (workersNow() == 0) addNonCoreWorker(null);
} else if (!addNonCoreWorker(task)) {
reject(task);
}关键竞争分支不是细枝末节。线程 A 看到运行状态后,线程 B 可能调用 shutdown,A 入队后需要复查,能移除就拒绝;若任务已由 worker 取走,不能简单声称它一定被拒绝。worker 全部意外退出而队列非空时,补一个 worker 避免无人消费。addWorker 内部还要重新校验状态与计数,所以不能把上层一次判断当成原子协议。Java 17 API也明确给出核心数、队列和最大数的关系。
无界队列的 offer 通常可成功,最大线程数就很少参与日常扩容;设置 core=8、max=200 并不意味着繁忙时会长到 200。错误修复也不是盲目换成直接交接队列,它可能把无界排队变成大量线程和下游连接竞争。容量策略必须连同拒绝语义一起设计。
完整算例:线程数、连接数和等待预算
图像元信息服务每秒 1,000 请求,单次 CPU 4ms、远端等待 36ms、总服务占用 40ms,容器有 8 个有效核心。CPU 需求为每秒 4 核秒,即约 4 个核心;若没有队列,平均在处理任务数约 40。不是 8 个线程一定足够,也不是 40 个线程保证 P99;请求混合、等待波动和同步锁会改变需求。
假设数据库连接每请求持有 10ms,在每秒 1,000 请求时平均占用 10 个连接。数据库实测安全并发若只有 24,设置应用 200 个线程加 200 个连接可能使数据库排队显著增加;一个线程池里的空闲线程不能抵消下游资源短缺。安全并发必须通过服务时间和吞吐曲线确认,不能仅看连接数没有达到服务器上限。
请求总时限 250ms,保留返回和调度 30ms,服务预算 40ms,最大排队预算为 180ms。若可持续吞吐为每秒 1,000,按确定性流体估算队列上限约 180 项;每个排队请求保留 64KiB,则仅任务数据约 11.25MiB,未计 Runnable、Future、上下文和框架引用。对象大小应实测,不能把这个估算当作堆精确占用。
两种接收方案与故障路径
方案 A 使用固定 40 worker、有限队列 180、明确拒绝。它限制本实例占用,也能吸收短尖峰,但尾端请求可能接近截止;应在真正执行前检查剩余时间,过期且未产生副作用的工作可安全拒绝。若任务已写出外部状态,再丢弃结果不等于取消业务,必须查询和恢复。
方案 B 维持较小队列,使用并发信号量和入口限流,让更多请求尽早收到可重试错误。排队延迟更低、系统更稳定,代价是突发期间拒绝更多。客户端若立即重试会抵消保护,因此需要退避、抖动和总重试预算。对关键与非关键请求可设隔离配额,但要避免每租户一个无限增长的线程池。
故障时序是下游从 36ms 变成 400ms,40 worker 全部等待,队列填满;上游超时重新提交,真实请求量继续放大。把 worker 调成 400 可能暂时减少入口队列,却把等待搬到连接池与数据库。先限制新并发、停止昂贵可选工作、控制重试,再逐步恢复下游,而不是一次性把积压全部释放。
拒绝策略也有协议差异。AbortPolicy 抛出异常,调用方必须转换为明确失败;CallerRunsPolicy 会让提交线程执行任务,能减速某些同步生产者,但若提交线程是共享事件循环,它可能阻塞所有连接。静默丢弃带 Future 的任务可能让调用者永远等不到完成。应按任务可丢失性和执行模型选择,不存在普遍最佳策略。
可运行实验与判定
运行受控机制实验(完整源码,JDK 17+ 单文件):
java labs/PoolAdmission.java实验一创建 core=1、max=2、队列容量 1 的池,用闩锁让首个任务占住 worker:第二个任务入队(池=1、队列=1)、第三个触发非核心 worker(池=2)、第四个被拒绝恰好一次;随后把队列改为无界,三个任务提交后两个入队而池保持 1——max 从未生效;再验证 shutdown 后提交被拒。实验输出 bounded: pool=2 queue=1 rejects=1、unbounded: pool=1 queued=2、shutdown: submit rejected,ALL CHECKS PASSED。全部断言已按 jdk17u 机制实际运行;这是受控机制实验,不是性能压测,shutdown 与 offer 的精确竞争不穷举所有调度,源码复查分支与额外并发压力验证应分别记录。
容量演算可用 capacity_models.py 的 resource 场景复算单位需求。
提交线程池参数、任务成本、排队时限、拒绝映射与实测状态。架构师变式为一半容器被撤走:必须重算每实例到达率和共享数据库余量,不能只把各实例 max 乘二。
并行度上限与下游一致性:线程数不等于处理能力
线程池算例给出 40 个 worker、10ms 连接持有与约 40 个在途任务,容易被误读成"40 线程恰好匹配 40 在途,所以配置合理"。这两件事只有在服务时间与排队结构完全一致时才等价;一旦下游服务时间变长或数据库安全并发更小,线程数再大也只是把等待搬进连接池。
用同一个 40ms 服务时间(4ms CPU + 36ms 远端等待)、每秒 1,000 请求的画像重算:按 Little 定律,在途 L=1,000×0.040=40。若每请求持数据库连接 10ms,平均占用 1,000×0.010=10 个连接;若下游故障把持有时间变成 40ms,平均占用升到 40 个。真正决定上限的是"数据库安全并发 × 每请求服务时间"的倒数:安全并发 24、服务时间 10ms 时,可持续吞吐 24/0.010=2,400/秒,比 1,000/秒有 2.4 倍余量;服务时间 40ms 时只剩 24/0.040=600/秒,1,000/秒的输入已经过载 400/秒,此时把线程从 40 加到 100 只会让 100 个线程一起等同一个连接池。
这条约束要和故障域一起看:连接池配 200、数据库安全并发 24、故障时服务时间 40ms,三者同时成立时,"池子够大"反而是事故放大器——它允许 200 个并发查询同时压在只能处理 24 个的数据库上,锁竞争与等待让服务时间进一步变长。正确做法是把池限制在安全并发附近(例如 24~30),让超出部分在应用侧排队并受 deadline 与拒绝策略约束。
执行 python3 labs/capacity_models.py parallel:输入 QPS 1,000、连接持有 10ms、安全并发 24、故障持有 40ms。预期输出 lambda_w=40、connections_normal=10、connections_fault=40、db_capacity_normal=2400、db_capacity_fault=600、overload_fault=400。脚本只做稳态算术,不模拟连接池排队与锁等待;真实数据库安全并发必须由压测曲线确认。
两种方案:A 按在途数配线程与连接(40/40),简单但把故障时的过载直接灌进数据库;B 按下游安全并发配连接(24),把余量留给队列与限流,并在入口按 deadline 拒绝。本例选 B,同时保留 40 个 worker 用于吸收远端等待——线程等待与数据库并发是两种资源,不能用一个数字同时表达。
架构师变化条件
把远端等待从 36ms 拉长到 400ms 后再恢复:恢复期到达率含重试放大,需按工作负载的形状重算净追赶,不能一次性释放积压。把 CallerRunsPolicy 用在共享事件循环的提交方上,验证它把背压变成了所有连接的阻塞——提交线程的执行模型决定该策略是否可用。若一半容器被撤走,每实例到达率翻倍、下游连接需求不变,应先降入口并发而不是扩线程。每次变化重填线程/连接/队列三张预算表。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。