相关问题
- 为什么需要线程池?直接 new Thread() 有什么问题?
- ThreadPoolExecutor 的核心参数有哪些?这些参数之间是什么关系?
- 一个任务提交到线程池后,完整执行流程是什么?
- 什么时候创建核心线程
- 什么时候进队列
- 什么时候扩到最大线程数
- 什么时候触发拒绝策略
- 为什么达到核心线程数以后,线程池选择先入队,而不是直接扩容线程?
- 任务队列为什么通常推荐有界队列?无界队列会带来什么问题?
- OOM
- 延迟不断增加
- maximumPoolSize 很难生效
- 线程池的几种拒绝策略分别是什么?实际业务应该怎么选?
- AbortPolicy
- CallerRunsPolicy
- DiscardPolicy
- DiscardOldestPolicy
- CallerRunsPolicy 为什么能形成反压
- CPU 密集型和 IO 密集型任务,线程数应该怎么配置?为什么?
- IO 密集型线程是不是越多越好?还要受哪些资源限制?
- 数据库连接池
- HTTP 连接池
- 下游服务容量
- CPU 上下文切换
- 线程池里的空闲线程是怎么回收的?
- keepAliveTime
- 核心线程和非核心线程的区别
- allowCoreThreadTimeOut
- 为什么生产环境通常不建议直接使用 Executors.newFixedThreadPool() 和 newCachedThreadPool()?
- 线程池实现异步和 MQ 实现异步有什么区别?什么时候用线程池,什么时候用消息队列?
- 如果让你设计一个生产环境线程池,你会重点考虑哪些指标和参数?
- 核心线程数
- 最大线程数
- 队列大小
- 拒绝策略
- 任务耗时
- CPU 使用率
- 活跃线程数
- 队列长度
- 下游承载能力
结构化八股
为什么需要线程池
如果每个任务都直接 new Thread(),高并发时线程数量会不断增长。线程的创建、销毁、内存占用以及上下文切换都有成本,线程过多反而会降低系统整体效率。
因此线程池首先解决的是线程复用和并发控制:提前维护一批线程,任务来了就交给这些线程执行,任务结束后线程不销毁,而是继续处理后续任务。
固定线程不够怎么办
固定线程数虽然避免了线程无限增长,但如果任务突然增多,线程全部忙起来,剩余任务就需要有地方暂存。
于是线程池引入了 workQueue。当线程暂时处理不过来时,新任务先进入队列,等已有线程执行完当前任务后,再继续从队列中取任务。
队列也不能无限增长
任务队列解决了短时间流量突增的问题,但如果任务进入速度长期大于处理速度,队列就会不断积压。无界队列可能最终占满内存,即使没有 OOM,任务等待时间也会越来越长。
所以线程池不能只限制线程数量,还需要限制任务积压数量,也就是使用有界队列。
队列满了怎么办
如果线程都在忙,队列也满了,说明当前处理能力已经不足。但这时候不一定要立即拒绝任务,因为可能只是短时间进入高峰期。
因此线程池又引入了 corePoolSize 和 maximumPoolSize。平时主要由核心线程处理任务,核心线程忙时任务先进入队列;如果队列也满了,再临时增加线程,直到达到最大线程数。
整个线程池都满了怎么办
如果线程数已经达到 maximumPoolSize,同时队列也满了,那么线程池已经达到自身容量上限。
这时就必须执行拒绝策略 RejectedExecutionHandler,决定新任务是直接报错、由提交线程自己执行,还是按照业务需求进行丢弃。
高峰过去以后怎么办
高峰期临时创建出来的线程不应该永久保留,否则会持续占用资源。因此线程池通过 keepAliveTime 回收长时间空闲的非核心线程,使线程数量重新回到正常水平。
整体来看
ThreadPoolExecutor 的设计其实是一层层问题推导出来的:线程不能无限创建,所以要复用;固定线程不够,所以需要队列;队列不能无限增长,所以要限制容量;队列满了还要继续处理,所以允许扩容线程;线程和队列都满了,就需要拒绝策略;高峰过去以后,还需要回收额外线程。
它本质上解决的是一个问题: 如何用有限的线程和有限的内存,稳定地处理不断变化的任务量。
一个任务提交以后,线程池到底怎么处理
有了核心线程、任务队列、最大线程数以后,接下来最重要的就是理解它们之间的执行顺序。
当一个新任务提交进来时,如果当前线程数还没有达到 corePoolSize,线程池会优先创建新的工作线程来执行任务。达到核心线程数以后,新任务不会马上继续创建线程,而是优先尝试放入 workQueue。
如果队列还能接收任务,任务就在队列中等待;如果队列已经满了,线程池才会继续创建新的非核心线程,直到线程数达到 maximumPoolSize。如果最大线程数也达到上限,同时队列仍然没有空间,就触发拒绝策略。
所以线程池的核心执行顺序可以概括为:
先核心线程,再任务队列,再扩容到最大线程数,最后拒绝。
为什么不是先扩容线程,再让任务排队
这个顺序并不是随便设计的。
如果达到核心线程数以后,每来一个任务都立刻继续创建线程,那么一次短暂的流量突增就可能迅速把线程数推到 maximumPoolSize。线程数量快速增长,会带来更多内存占用、上下文切换和对下游资源的瞬时压力。
任务队列的作用,就是给系统提供一层缓冲。短时间的流量高峰可以先由队列吸收,核心线程慢慢消费;只有当队列都已经满了,才说明当前处理能力长期跟不上任务进入速度,这时候再增加线程才更有意义。
所以“先入队、再扩线程”体现的是线程池对资源的一种保守策略:先用队列吸收波动,再通过增加线程应对持续压力。
队列怎么选
理解了任务队列的作用以后,就能看出队列类型会直接影响线程池的行为。
如果使用有界队列,例如 ArrayBlockingQueue,任务积压数量是可控的。当队列达到上限以后,线程池还有机会继续扩容到 maximumPoolSize,最终再通过拒绝策略做过载保护。
如果使用近似无界的 LinkedBlockingQueue,任务通常会一直进入队列。因为队列很难真正“满”,线程池也就很难进入继续创建非核心线程的阶段,maximumPoolSize 实际上可能长期不起作用。
这也是无界队列的两个典型问题:一是大量任务持续积压后可能导致内存压力甚至 OOM;二是任务会长时间排队,延迟越来越高。
因此生产环境里通常更倾向使用有界队列,因为只有线程数和队列容量都有限,系统的最大承载能力才是真正可控的。
拒绝策略怎么理解
当线程数已经达到最大值、队列也已经满了以后,线程池已经没有能力继续承载更多任务。拒绝策略本质上就是告诉系统:
过载时,我们希望牺牲什么?
AbortPolicy 会直接抛出 RejectedExecutionException,让调用方明确知道任务没有被接受。它适合不能静默丢失任务、希望快速失败并由上层处理的场景。
CallerRunsPolicy 会让提交任务的线程自己执行这个任务。它最大的特点是能够形成一定的反压,因为调用线程被占用以后,继续提交任务的速度自然会下降。不过它也会拖慢上游线程,因此不能无脑使用。
DiscardPolicy 会直接丢弃新任务,而且不抛异常;DiscardOldestPolicy 会丢掉队列中等待时间最久的任务,再尝试提交当前任务。这两种策略都意味着允许任务丢失,因此更适合非核心、可丢弃的业务。
所以拒绝策略并不是单纯记四个名字,而是要根据业务回答一个问题:
线程池满了以后,是要快速失败、让上游自己扛,还是允许部分任务丢失?
空闲线程怎么回收
线程池在高峰期可能扩容出很多非核心线程,但这些线程并不应该一直存在。
默认情况下,超过 corePoolSize 的线程如果空闲时间超过 keepAliveTime,就会被回收。核心线程默认不会因为空闲而退出。
如果开启:
allowCoreThreadTimeOut(true);
那么核心线程也会受到 keepAliveTime 的限制,空闲足够久后同样可以被回收。
所以 keepAliveTime 解决的是线程池的收缩问题:高峰期可以扩,低谷期要能缩。
CPU 密集型和 IO 密集型线程数怎么配
线程数并不是越多越好,它取决于任务到底是在“占 CPU”,还是在“等资源”。
CPU 密集型任务大部分时间都在做计算,比如加密、压缩、复杂运算。这类任务通常不需要太多线程,因为 CPU 核心数量是固定的,线程过多只会增加上下文切换。因此线程数一般会设置在接近 CPU 核数的位置。
IO 密集型任务则不同,例如访问数据库、调用 RPC、HTTP 请求、文件 IO。线程在执行这类任务时,大量时间其实处于等待状态,因此可以配置更多线程,让一部分线程等待 IO 时,其他线程继续利用 CPU。
常见的估算思路是:
线程数 ≈ CPU 核数 ×(1 + 等待时间 / 计算时间)
但这个公式只能作为起点。实际生产中还必须考虑数据库连接池大小、HTTP 连接池大小、下游服务承载能力以及上下文切换成本。
比如数据库连接池只有 30 个连接,那么线程池即使开 300 个线程,大量线程最终也只是阻塞在等待数据库连接,并不会真正提高吞吐。
所以线程池参数最终还是要通过监控和压测来调整,而不是单纯套公式。
为什么不建议直接使用 Executors
Executors 提供了很多方便的线程池创建方式,但它隐藏了一些关键参数,容易导致资源失控。
例如 newFixedThreadPool() 默认使用容量非常大的 LinkedBlockingQueue。如果任务处理速度长期跟不上提交速度,大量任务会堆积在队列中,最终可能导致 OOM。
newCachedThreadPool() 则允许创建非常多的线程,在高并发场景下可能导致线程数量快速膨胀,造成内存和 CPU 压力。
因此生产环境通常更推荐直接使用 ThreadPoolExecutor,显式指定核心线程数、最大线程数、队列容量和拒绝策略。
这并不是因为 Executors 不能用,而是因为生产系统更需要明确地知道线程池的容量边界在哪里。
线程池最终应该关注什么
理解完线程池以后,可以发现它并不是简单地“配置几个参数”。
真正需要关注的是三个问题:
第一,当前任务的生产速度和消费速度是否匹配;第二,线程和队列的容量是否能够吸收正常的流量波动;第三,当系统超过承载能力时,是否有明确的过载保护策略。
因此线上通常需要关注活跃线程数、队列长度、任务执行时间、拒绝次数、CPU 使用率以及下游资源使用情况。
线程池参数也不是一次配置后永远不变,而是要根据这些运行指标不断调整。