系列目录
- 从单线程到线程池:云盘转 Java 后的第一堂并发课
- 线程池参数、队列与快慢接口隔离(本篇)
- 数据库连接池与 Spring 声明式事务:把 Node.js 的坑填上
上一篇讨论了 Node.js 单事件循环与 Java 请求线程池的差异。此前写过 Node.js、C# 等后端服务,我知道语言和框架改变后,阻塞调用、任务排队与共享资源都会有不同的表现。Java 版云盘上线后,我先把线程池作为需要补齐的基础知识,阅读 JDK 7 的 ThreadPoolExecutor 源码和 Javadoc,并将参数、队列和拒绝策略用于后续项目改造。
一个慢接口影响整个服务
3 月底的一天下午,监控上大盘接口的响应时间集体抬头,先是几个接口超时告警,随后几乎所有接口都超时,包括平时只要几毫秒的元数据查询。终端侧的列表刷不出来,下载卡住。
先检查下游。数据库的慢查询日志里有一个历史遗留的目录递归查询,数据量上来后执行时间从几十毫秒涨到秒级。它能解释文件列表接口变慢,但获取用户信息等不访问该表的接口也在超时。
jstack 的线程栈显示,业务池的 worker 线程都停在同一个 DAO 调用,栈顶在 socket read 上等待数据库返回。业务线程池已经打满。
按照上一篇的线程模型,当时所有接口的请求由 Jetty 线程接收后,统一提交到同一个业务线程池执行,Jetty 线程同步等待业务池返回结果。慢查询持续占用业务线程,池中的线程逐渐被该接口占满,后续任务在队列中等待。每个 Jetty 线程等待自己提交的任务返回,可用于处理新请求的 Jetty 线程减少;请求积压到连接器或操作系统的排队上限时,可能表现为连接超时。共享业务线程池使慢接口影响其他接口。
当时先重启服务清掉积压请求,让 RT 回落,并在入口对慢接口限流。慢 SQL 需要单独优化。线程池仍需要设置资源边界,否则其他慢接口也可能占满业务线程池。
ThreadPoolExecutor 的任务提交顺序
出问题的业务线程池使用 Executors.newFixedThreadPool(...) 创建。我先前只知道线程池用于复用线程,没有把参数与任务提交路径对应起来。回头阅读 JDK 7 的 ThreadPoolExecutor 源码和 Javadoc 后,execute(任务) 的主要处理顺序如下:
- 当前线程数 < 核心线程数(corePoolSize):创建新线程执行任务,即使池中已有空闲线程。
- 核心线程满了:尝试把任务放入队列(workQueue),等待线程取出执行。
- 队列也满了:只要总线程数还没到最大线程数(maximumPoolSize),就创建非核心线程立即执行。这类线程空闲超过 keepAliveTime 会被回收。
- 线程数到顶、队列也满:触发拒绝策略(RejectedExecutionHandler)。默认的
AbortPolicy抛出RejectedExecutionException;CallerRunsPolicy由调用方线程执行任务;DiscardPolicy和DiscardOldestPolicy会丢弃任务,后者会先丢弃队列中等待最久的任务,再尝试提交当前任务。
第 2、3 步的顺序会直接影响扩容条件。配置“核心 10、最大 50”时,线程池会先创建最多 10 个核心线程,再将任务放入队列;只有队列满后才会创建第 11 个线程。队列决定了线程池何时扩容。
池中的核心线程默认按需创建。核心线程数设为 50 后,没有任务进来时不会创建线程;可以调用 prestartAllCoreThreads 预启动核心线程,当时没有用到。建池本身的成本较低,参数需要结合负载选择。
无界队列的限制
带着这个提交流程查看 Executors.newFixedThreadPool(n) 的源码,可以看到它内部是:
new ThreadPoolExecutor(n, n, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());LinkedBlockingQueue 不传容量时,容量为 Integer.MAX_VALUE。队列在可用内存耗尽前不会满,因此不会进入第 3 步,也不会因队列满进入第 4 步的拒绝策略。maximumPoolSize 大于 corePoolSize 时也不会因此扩容。写个最小 demo 验证一下(JDK 7 可跑):
public class UnboundedQueueDemo {
public static void main(String[] args) throws InterruptedException {
// 核心 2、最大 4、无界队列、默认 AbortPolicy
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 4,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(),
new ThreadPoolExecutor.AbortPolicy());
for (int i = 0; i < 100; i++) {
final int id = i;
pool.execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(5000); // 模拟慢任务
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("task " + id + " done");
}
});
}
Thread.sleep(1000);
System.out.println("maximumPoolSize=4,当前线程数 = " + pool.getPoolSize());
System.out.println("队列里堆积的任务 = " + pool.getQueue().size());
pool.shutdown();
}
}在这段程序中,当前线程数为 2,队列中有 98 个任务,全程不会出现 RejectedExecutionException。“最大 4 个线程”没有参与调度。
把队列换成 new ArrayBlockingQueue<Runnable>(10) 再跑,线程数会涨到 4,第 15 个任务提交时抛出 RejectedExecutionException。池子的队列和线程数都有上限。
无界队列会持续接收任务,任务在队列中的等待时间随积压增加,调用方可能在任务完成前超时。队列中的任务对象也会占用内存;积压持续增长时,进程可能因内存不足而失败。那次不报错但接口超时的表现符合这一模式。
如何确定线程池参数
理解提交顺序后,四个参数的职责比较明确,但具体数值需要按负载确定。我当时先根据已有后端经验和任务性质设初值,再用压测数据调整。
- 核心线程数:先判断任务性质。CPU 密集型任务常以 CPU 核数附近的线程数作为起点;云盘接口大多是 IO 密集型任务,需要等待数据库、存储或缓存,线程在等待期间不占用 CPU,因此初始线程数可以高于核数。具体数值仍要结合 CPU 使用率、RT 和实际阻塞时间在压测中调整。
- 最大线程数:决定队列满后允许额外创建多少线程。它受可用 CPU、内存、下游服务的并发能力和可接受的突发流量影响。
- 队列长度:表示允许积压多少突发请求。队列过大,会把过载转化为更长的等待和调用方超时;队列过小,则可能在短暂波动时拒绝原本可处理的请求。
- 拒绝策略:Web 入口可使用
AbortPolicy让提交方快速得到失败,以便上层把“系统忙”尽快返回给调用方。CallerRunsPolicy会让提交任务的 Jetty 处理线程执行任务,降低可处理新请求的 Jetty 线程数,将压力传递到入口。当时只在内部任务池里用它。
压测调参的细节留到系列第 10 篇。当时还为线程工厂命名:使用自定义 ThreadFactory 给不同池子的线程加业务前缀,之后通过 jstack 可以识别线程所属的池子。
按快慢接口拆分线程池
分析清楚后,改造目标是让快慢接口使用独立线程池。
- 业务线程池一分为二。 元数据类快接口(目录信息、属性查询,毫秒级)使用一个池,文件操作类慢接口(大列表、转存、批量操作,秒级也是它)使用另一个池。两个池各自设置核心数、有界队列和拒绝策略。慢接口把自己的池打满后,快接口仍可使用自己的线程池。这是当时采用的线程池隔离。两个池长这样(数字是脱敏后的示意,量级感受即可):
// 快接口池:核心线程多、队列短,追求快进快出
ThreadPoolExecutor fastPool = new ThreadPoolExecutor(
32, 64, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(256),
new NamingThreadFactory("meta-fast-"), // 自定义 ThreadFactory:给线程名加前缀(实现略)
new ThreadPoolExecutor.AbortPolicy());
// 慢接口池:核心线程少、队列同样有限,打满就拒绝
ThreadPoolExecutor slowPool = new ThreadPoolExecutor(
8, 16, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(64),
new NamingThreadFactory("file-slow-"),
new ThreadPoolExecutor.AbortPolicy());- 队列全部换成有界的
ArrayBlockingQueue。 长度按压测中可容忍的排队时间反推,避免请求在队列中无限等待。 - 拒绝后快速失败。 业务池拒绝后,上层统一捕获
RejectedExecutionException,给终端返回“系统繁忙”类的明确错误,而不是让请求挂着直到超时。客户端可以据此决定是否重试和提示。 - 接入监控告警。 给每个池子的活跃线程数、队列长度做了埋点上报(公司监控平台,只写用法:暴露指标、配阈值告警)。队列持续积压或频繁拒绝时触发告警,使问题能在用户反馈前从监控曲线中发现。
改造上线后,慢查询在优化前又被触发过一次。监控显示慢接口池打满并出现拒绝告警,慢接口开始快速失败;快接口的 RT 曲线没有明显变化。线程池隔离限制了慢接口对快接口线程资源的影响。上一次是全站超时加手动重启,这一次是局部报错加一条告警。
线程隔离后的数据库连接问题
线程池隔离处理的是线程这一共享资源。如果慢接口持有的数据库连接来自快慢接口共用的连接池,慢接口占满连接池后,快接口即使在线程池中有可用线程,获取连接时仍会排队或超时。线程池隔离不能处理共享数据库连接池的竞争。
下一篇讨论数据库连接池的参数、等待超时,以及它和 Spring 声明式事务的关系。
参考资料
- JDK 7
ThreadPoolExecutor源码与 Javadoc - 《Java并发编程实战》(Brian Goetz)第 8 章

