「Java 进阶之路」系列 Day10
写在前面
Day09 讲的 BlockingQueue 是线程池的核心组件之一,这篇正式进入线程池本身。线程池大概是面试里问法最"细节控"的话题------不少人能背出"核心线程数、最大线程数、拒绝策略"这几个词,但问到"任务提交之后到底是先加线程还是先进队列",或者"拒绝策略默认是哪个、都有什么区别",就容易卡壳。这篇把 ThreadPoolExecutor 的 7 个核心参数、任务提交流程和状态机、4 种拒绝策略一次讲透。
一、是什么:ThreadPoolExecutor 的 7 个核心参数
java
new ThreadPoolExecutor(
int corePoolSize, // 核心线程数 长期保留
int maximumPoolSize, // 最大线程数上限
long keepAliveTime, // 非核心线程的空闲存活时间
TimeUnit unit, // 时间单位
BlockingQueue workQueue, // 任务等待队列
ThreadFactory threadFactory, // 线程创建工厂 可以自定义线程名
RejectedExecutionHandler handler // 拒绝策略
);
| 参数 | 作用 |
|---|---|
| corePoolSize | 常驻核心线程数,即使空闲也不会被销毁(除非显式设置了allowCoreThreadTimeOut) |
| maximumPoolSize | 线程总数的上限,只有队列满了才会突破corePoolSize去创建新线程 |
| keepAliveTime | 超出corePoolSize的那部分线程,空闲多久没有新任务就会被销毁 |
| workQueue | 核心线程都在忙的时候,新任务进这里排队等待 |
| threadFactory | 自定义线程创建方式,最常见的用法是给线程起个有意义的名字,方便排查问题 |
| handler | 队列满了并且线程数也到了上限时,怎么处理新提交的任务 |
一句话理解为什么需要线程池 :每次任务都 new Thread 来跑,线程创建和销毁本身就有开销(申请系统资源、上下文切换),任务一多还可能把机器资源耗尽。线程池把线程这种"重量级资源"提前准备好、反复复用,同时通过参数把并发规模控制在可控范围内。
二、任务提交的完整流程:不是"线程不够就扩"
这是最容易被问错的一点------很多人以为"线程不够用就创建新线程",实际顺序是先填满核心线程,再填满队列,最后才扩展到最大线程数:
关键点 :只有当队列已经满了(workQueue.offer 失败),线程池才会考虑创建超出 corePoolSize 的新线程;如果连 maximumPoolSize 都到了上限,新任务才会真正走到拒绝策略这一步。这个顺序的设计初衷是:核心线程数是"稳定态"下应该维持的并发度,队列是用来削峰填谷的缓冲区,而超出核心线程数的额外线程只是应急手段,不应该轻易创建。
三、线程池的生命周期状态机
| 状态 | 含义 |
|---|---|
| RUNNING | 正常运行,接受新任务,也处理队列里已有的任务 |
| SHUTDOWN | 调用了shutdown,不再接受新任务,但会把队列里已有的任务处理完 |
| STOP | 调用了shutdownNow,不接受新任务,中断正在执行的任务,并把队列里剩余任务返回 |
| TIDYING | 所有任务都终止了,线程数量归零,即将调用terminated钩子方法 |
| TERMINATED | terminated执行完毕,线程池彻底终止 |
shutdown() 和 shutdownNow() 的核心区别:前者是"优雅关闭"------不再接收新任务,但让已经在排队和执行的任务善始善终;后者是"强制关闭"------正在执行的任务会被中断,队列里还没执行的任务直接原样返回,不会再被执行。
四、4 种拒绝策略:队列和线程都撑不住之后怎么办
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy(默认) | 直接抛出RejectedExecutionException | 快速失败,让上层代码感知并处理 |
| CallerRunsPolicy | 让提交任务的线程自己去执行这个任务 | 降速反压,不希望丢任务 |
| DiscardPolicy | 直接丢弃,不抛任何异常 | 允许丢失的非关键任务 |
| DiscardOldestPolicy | 丢弃队列里最老的任务,然后重新尝试提交当前任务 | 优先保证新任务能被处理 |
默认的 AbortPolicy 虽然会抛异常,但至少能让调用方明确知道"任务被拒绝了";DiscardPolicy 看起来省事,但任务被静默丢弃、完全没有任何痕迹,出了问题很难排查。生产环境更推荐自定义拒绝策略------至少要记日志、上报告警,不能让任务凭空消失还不留任何记录。
CallerRunsPolicy 有个很实用的效果:它会让提交任务的线程(通常是业务主线程)被迫去执行这个本该丢给线程池的任务,这段时间业务线程没法再继续提交新任务,客观上起到了"给下游降速"的反压效果,避免任务无节制地涌入一个已经跑不动的线程池。
五、面试追问
Q1:线程池提交任务后,是先创建新线程还是先进队列?
先尝试用核心线程执行,如果核心线程已经全部在忙,任务会先尝试进入工作队列排队等待,只有队列也满了之后,才会考虑创建超出核心线程数、直到最大线程数为止的新线程;如果连最大线程数都达到了,才会触发拒绝策略。顺序是核心线程优先,其次队列,最后才是额外线程,不是"线程不够就扩容"。
Q2:shutdown() 和 shutdownNow() 有什么区别?
shutdown() 是优雅关闭:不再接受新任务,但队列里已有的任务会被继续执行完,正在执行的任务也不会被打断;shutdownNow() 是强制关闭:不接受新任务,会尝试中断所有正在执行的任务,并且把队列里还没来得及执行的任务作为返回值返还,不保证这些任务会被执行。
Q3:线程池默认的拒绝策略是什么,为什么不建议用 DiscardPolicy?
默认是 AbortPolicy,任务被拒绝时会抛出 RejectedExecutionException。不建议用 DiscardPolicy 是因为它会静默丢弃任务、不抛任何异常也不留任何痕迹,一旦线上出现任务堆积导致大量任务被拒绝,运维和开发完全感知不到,问题会被隐藏得很深,等发现的时候可能已经造成了业务影响。生产环境更推荐自定义拒绝策略,至少要记录日志和告警。
Q4:CallerRunsPolicy 这个拒绝策略的效果是什么,为什么说它能起到反压作用?
它会让提交任务的那个线程自己去执行这个原本要提交给线程池的任务。因为执行任务需要时间,提交任务的线程在这段时间里没法继续提交新任务,相当于给上游的任务提交速度踩了一脚刹车,客观上降低了任务涌入线程池的速度,给线程池喘息、消化积压任务的机会,这就是"反压"的效果。
Q5:为什么线程池要先把核心线程用满、再用队列,而不是任务一来就尽量多开线程?
因为线程是相对重量级的资源,创建和销毁都有开销,线程数太多还会导致频繁的上下文切换,反而拖累整体性能。核心线程数代表的是"日常稳定负载下应该维持的并发规模",工作队列是用来缓冲瞬时超出这个规模的任务、削峰填谷的;只有当队列都装不下了,才说明真的出现了需要额外并发能力的情况,这时候才值得付出创建更多线程的代价,这是一种"优先复用、按需扩容"的设计思路。
下一篇预告
Day11 讲线程池进阶------CPU 密集型和 IO 密集型任务分别该怎么科学设置线程数,以及为什么不推荐直接用 Executors 提供的几个工厂方法。