相关问题

  1. 为什么需要线程池?直接 new Thread() 有什么问题?
  2. ThreadPoolExecutor 的核心参数有哪些?这些参数之间是什么关系?
  3. 一个任务提交到线程池后,完整执行流程是什么?
    • 什么时候创建核心线程
    • 什么时候进队列
    • 什么时候扩到最大线程数
    • 什么时候触发拒绝策略
  4. 为什么达到核心线程数以后,线程池选择先入队,而不是直接扩容线程?
  5. 任务队列为什么通常推荐有界队列?无界队列会带来什么问题?
    • OOM
    • 延迟不断增加
    • maximumPoolSize 很难生效
  6. 线程池的几种拒绝策略分别是什么?实际业务应该怎么选?
    • AbortPolicy
    • CallerRunsPolicy
    • DiscardPolicy
    • DiscardOldestPolicy
    • CallerRunsPolicy 为什么能形成反压
  7. CPU 密集型和 IO 密集型任务,线程数应该怎么配置?为什么?
  8. IO 密集型线程是不是越多越好?还要受哪些资源限制?
    • 数据库连接池
    • HTTP 连接池
    • 下游服务容量
    • CPU 上下文切换
  9. 线程池里的空闲线程是怎么回收的?
    • keepAliveTime
    • 核心线程和非核心线程的区别
    • allowCoreThreadTimeOut
  10. 为什么生产环境通常不建议直接使用 Executors.newFixedThreadPool() 和 newCachedThreadPool()?
  11. 线程池实现异步和 MQ 实现异步有什么区别?什么时候用线程池,什么时候用消息队列?
  12. 如果让你设计一个生产环境线程池,你会重点考虑哪些指标和参数?
    • 核心线程数
    • 最大线程数
    • 队列大小
    • 拒绝策略
    • 任务耗时
    • 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 使用率以及下游资源使用情况。

线程池参数也不是一次配置后永远不变,而是要根据这些运行指标不断调整。