文章目录
- 一、七个参数,先解决「谁决定什么」
- 1.1 提交任务时的真实顺序(这段是全文骨架)
- 1.2 线程工厂:一定要自己传
- 二、实验一:同一批任务,只换拒绝策略
- 三、实验二:`submit` 的异常是静悄悄的
- 四、四种拒绝策略对照与选型
- 五、核心线程数怎么定:两个公式 + 一个必做的动作
- 六、还有三个容易忽略的点
- 七、小结
面试问线程池,多数人能背出七个参数的名字,但问到「核心线程满了之后先排队还是先加线程」「maximumPoolSize什么时候是摆设」「submit里抛异常为什么静悄悄」,就开始含糊了。
这篇不背概念,用三个可运行的小实验把执行顺序和拒绝策略跑给你看:同一个池子、同一批任务,只换队列或拒绝策略,输出完全不同。文末给出参数估算公式和五个线上必踩的坑。
实测环境:JDK 21(ThreadPoolExecutor的行为在 JDK 8 到 21 之间没有语义变化,JDK 8 同样适用)。
一、七个参数,先解决「谁决定什么」
publicThreadPoolExecutor(intcorePoolSize,// 1. 核心线程数intmaximumPoolSize,// 2. 最大线程数longkeepAliveTime,// 3. 非核心线程空闲存活时间TimeUnitunit,// 4. 上面的时间单位BlockingQueue<Runnable>workQueue,// 5. 任务队列ThreadFactorythreadFactory,// 6. 线程工厂(起名!)RejectedExecutionHandlerhandler)// 7. 拒绝策略七个数里真正需要你反复权衡的只有三个:核心线程数、队列类型与容量、拒绝策略。
1.1 提交任务时的真实顺序(这段是全文骨架)
execute(task) │ ├─ 1. 线程数 < corePoolSize ? → 直接新建核心线程执行 │ ├─ 2. 核心线程都忙 → 尝试放入 workQueue │ 放入成功 → 排队等空闲线程来取 │ ├─ 3. 队列满了 → 线程数 < maximumPoolSize ? │ → 新建非核心线程执行 │ └─ 4. 队列满 + 线程数 = maximumPoolSize → 交给拒绝策略关键点:先排队,后扩容。很多人下意识以为「任务多了就加线程」,实际是「核心线程满了先塞队列,队列满了才加线程」。这个顺序决定了队列类型的重要性——下面实验三会看到,队列选错,maximumPoolSize直接变成摆设。
1.2 线程工厂:一定要自己传
ThreadFactoryfactory=newThreadFactory(){privatefinalAtomicIntegern=newAtomicInteger(1);@OverridepublicThreadnewThread(Runnabler){Threadt=newThread(r,"order-pool-"+n.getAndIncrement());t.setDaemon(false);returnt;}};默认工厂起的线程叫pool-1-thread-1,线上jstack一导,几十个池子的线程混在一起分不清是哪个业务的。给池子起名是排查问题的第一笔投资,成本一行代码。
二、实验一:同一批任务,只换拒绝策略
实验参数刻意设小,方便一眼看出排队和拒绝:核心 2、最大 4、ArrayBlockingQueue容量 2、一次性提交 8 个任务。算一下就知道必然有任务被拒:2 个核心线程 + 2 个队列位 + 2 个扩容线程 = 6 个能容纳,第 7、8 个触顶。
staticvoidrun(Stringtitle,BlockingQueue<Runnable>queue,RejectedExecutionHandlerhandler,inttaskCount)throwsException{ThreadPoolExecutorpool=newThreadPoolExecutor(2,4,60,TimeUnit.SECONDS,queue,newThreadFactory(){privateintn=0;publicThreadnewThread(Runnabler){returnnewThread(r,"worker-"+(++n));}},handler);for(inti=1;i<=taskCount;i++){try{pool.execute(newTask(i));}catch(RejectedExecutionExceptione){System.out.println(" 任务"+i+" 被拒绝(抛 RejectedExecutionException)");}}pool.shutdown();pool.awaitTermination(10,TimeUnit.SECONDS);System.out.println(" 峰值线程数="+pool.getLargestPoolSize()+", 完成任务数="+pool.getCompletedTaskCount());}A 组:AbortPolicy(默认),本机实际输出:
== A. 核心2/最大4/有界队列(cap=2) + AbortPolicy,提交 8 个任务 == 任务7 被拒绝(抛 RejectedExecutionException) 任务8 被拒绝(抛 RejectedExecutionException) 任务1 由 [worker-1] 执行 任务6 由 [worker-4] 执行 任务5 由 [worker-3] 执行 任务2 由 [worker-2] 执行 任务3 由 [worker-3] 执行 任务4 由 [worker-1] 执行 峰值线程数=4, 完成任务数=6任务 7、8 被拒。注意打印顺序:拒绝信息先出现,因为execute是同步判断的,而任务的实际执行在子线程里,输出顺序随机。这说明一件事——拒绝决定了任务连排队资格都没有,是唯一的硬性丢失点。
B 组:只换CallerRunsPolicy:
== B. 同上参数 + CallerRunsPolicy,提交 8 个任务 == 任务7 由 [main] 执行 任务8 由 [main] 执行 任务1 由 [worker-1] 执行 ... 峰值线程数=4, 完成任务数=6任务 7、8 由main线程执行了——CallerRunsPolicy让提交任务的线程自己去跑。这个策略的价值不是「多干点活」,而是天然的背压:提交方被拖住执行,就没法继续往池子里灌任务,上游速度自动降下来。对不能丢任务的场景(数据同步、可靠消息消费),这是最合适的选择。
C 组:无界队列(这是最值得看的一组):
== C. 核心2/最大4/无界队列 + AbortPolicy,提交 8 个任务(看峰值线程数) == 任务1 由 [worker-1] 执行 任务2 由 [worker-2] 执行 任务4 由 [worker-1] 执行 任务3 由 [worker-2] 执行 任务6 由 [worker-1] 执行 任务5 由 [worker-2] 执行 任务7 由 [worker-1] 执行 任务8 由 [worker-2] 执行 峰值线程数=2, 完成任务数=8任务全部完成了,但峰值线程数只有 2——maximumPoolSize=4一个线程都没多起。
原因就是第 1.1 节的顺序:无界队列永远装得下 → 永远进不到「扩容」那一步 →maximumPoolSize形同虚设。这就是阿里开发规约里禁止Executors.newFixedThreadPool/newSingleThreadExecutor的根本原因——它们用的都是无界队列,任务堆积只会在队列里涨,最后以 OOM 收场,而不是干脆地抛拒绝。
同样的道理,Executors.newCachedThreadPool另一个极端:maximumPoolSize = Integer.MAX_VALUE,任务一多线程数无上限,直接打爆操作系统线程数。
结论一句话:自己new ThreadPoolExecutor,用有界队列,显式写清七个数。
三、实验二:submit的异常是静悄悄的
同一个池子,execute和submit执行同样的抛异常任务,本机实测:
System.out.println("-- 执行 execute(Runnable),任务里抛异常 --");pool.execute(()->{thrownewRuntimeException("execute 的任务炸了");});Thread.sleep(300);System.out.println("-- 执行 submit(Runnable),任务里抛同样的异常 --");pool.submit(()->{thrownewRuntimeException("submit 的任务炸了");});Thread.sleep(300);System.out.println("-- submit + Future.get() 才能看到异常 --");Future<?>f=pool.submit(()->{thrownewRuntimeException("这次被 get 出来了");});try{f.get();}catch(ExecutionExceptione){System.out.println(" get() 抛出 ExecutionException,原因是: "+e.getCause().getMessage());}真实输出:
-- 执行 execute(Runnable),任务里抛异常 -- Exception in thread "pool-1-thread-1" java.lang.RuntimeException: execute 的任务炸了 at SubmitDemo.lambda$main$0(SubmitDemo.java:8) ... -- 执行 submit(Runnable),任务里抛同样的异常 -- -- submit + Future.get() 才能看到异常 -- get() 抛出 ExecutionException,原因是: 这次被 get 出来了 -- 主线程正常结束 --中间那三行输出后面什么都没有——submit把异常封进了Future,你不调get()就永远看不到。这是线上最难查的一类问题:任务明明跑了,业务数据没写,日志一片干净。
两种解法:
- 用
submit就必须get()(或者在CompletableFuture链上加exceptionally/whenComplete); - 更省事的办法——在任务体里自己 try/catch,别指望池子替你兜。任务最外层包一层 try-catch 打印日志并上报,是生产代码的标配。
四、四种拒绝策略对照与选型
| 策略 | 行为 | 适合场景 | 风险 |
|---|---|---|---|
AbortPolicy(默认) | 抛RejectedExecutionException | 需要严格感知过载、能接受快速失败 | 调用方不捕获就直接异常上抛 |
CallerRunsPolicy | 提交线程自己执行 | 不能丢任务,且希望自动降速 | 提交线程被拖慢,可能拖垮上游 |
DiscardPolicy | 静默丢弃 | 极少使用 | 静默丢数据,最难排查 |
DiscardOldestPolicy | 丢队列里最老的,再插入新的 | 只关心最新数据的场景(如实时指标) | 老任务无声消失 |
生产建议:优先CallerRunsPolicy(有背压)或自定义策略。自定义策略通常长这样——先记录再降级,而不是丢得无声无息:
newRejectedExecutionHandler(){@OverridepublicvoidrejectedExecution(Runnabler,ThreadPoolExecutorexecutor){// 1) 打点计数,便于告警rejectCounter.increment();log.warn("task rejected, poolSize={}, queueSize={}",executor.getPoolSize(),executor.getQueue().size());// 2) 降级:落盘/入 MQ 重试,或直接在调用方同步执行if(!executor.isShutdown()){r.run();// 退化成同步执行,至少不丢}}}五、核心线程数怎么定:两个公式 + 一个必做的动作
CPU 密集型(加解密、复杂计算):N + 1(N = CPU 核心数),多一个线程用来填补偶发的页缺失停顿。
IO 密集型(RPC、DB、缓存):理论公式N × (1 + 等待时间 / 计算时间)。落到实践更好用的是从 QPS 反推:
线程数 = QPS × 平均响应时间(秒) × 冗余系数(建议 1.2~2)例:接口 QPS 200,平均耗时 50ms → 200 × 0.05 = 10,乘 1.5 冗余 ≈15 个线程。
必做的一个动作:这个估算值只是起点,上线后要盯着getActiveCount()和getQueue().size()调。队列长期为空、活跃数远低于核心数 → 可以减;队列持续堆积 → 加线程或找下游慢在哪(很可能是 DB 慢查询,加线程只是把压力转嫁给数据库)。
监控埋点建议至少在日志里定期输出这三个数:
log.info("pool: active={}, poolSize={}, queue={}, completed={}",pool.getActiveCount(),pool.getPoolSize(),pool.getQueue().size(),pool.getCompletedTaskCount());六、还有三个容易忽略的点
优雅关闭:shutdown()是「不再接新任务,等已提交的跑完」;shutdownNow()是「打断所有线程 + 返回未执行的任务列表」。应用下线时正确写法:
pool.shutdown();if(!pool.awaitTermination(30,TimeUnit.SECONDS)){pool.shutdownNow();// 超时还没完,强制中断}参数可以动态改:setCorePoolSize()/setMaximumPoolSize()在运行期生效(核心数调小时,多余线程会在下次空闲后回收)。做配置中心动态调参不用重启应用,这是生产上非常实用的一招。
线程池不是越多越好:每个池子都占线程(栈内存)、都要维护一个队列。一个应用里到处new ThreadPoolExecutor、几十个池子互相抢 CPU,是比单池参数调错更常见的问题。按业务边界收敛池子数量,一般一个进程控制在个位数。
七、小结
按重要性排,线程池真正需要记住的是这四句:
- 执行顺序是「核心线程 → 队列 → 扩容到最大 → 拒绝」,所以队列类型决定了
maximumPoolSize有没有意义。 - 永远用有界队列,
Executors那几个工厂方法(Fixed / Single / Cached)要么 OOM 要么打爆线程数。 submit会吞异常,要么get(),要么任务体里自己兜住。CallerRunsPolicy的价值是背压而不只是「多跑一个任务」;自定义拒绝策略要留打点和降级路径。