【JUC】线程池
一、线程池是什么?
线程池本质上是一个管理线程的组件。
它提前创建或按需创建一批线程,把线程维护起来。后续有任务提交时,不是每次都创建新线程,而是把任务交给线程池中的线程去执行。
这样做的核心目的有三个:
线程复用
避免频繁创建和销毁线程。
资源控制
限制线程数量,防止无限制创建线程把 CPU、内存打爆。
任务管理
通过阻塞队列、拒绝策略等机制,对任务进行排队、限流和兜底处理。
所以线程池不是单纯为了“快”,更重要的是为了可控地并发执行任务。
二、线程池的七个核心参数
Java 线程池通常指的是ThreadPoolExecutor,它有七个重要参数:
public ThreadPoolExecutor( int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler )可以这样理解:
1. corePoolSize:核心线程数
线程池中长期保留的线程数量。
只要当前线程数小于核心线程数,有新任务提交时,就会优先创建核心线程执行任务。
2. maximumPoolSize:最大线程数
线程池允许创建的最大线程数量。
最大线程数 = 核心线程 + 临时线程。
3. keepAliveTime:临时线程空闲存活时间
当线程池中的线程数量超过核心线程数时,多出来的临时线程如果空闲时间超过keepAliveTime,就会被回收。
4. unit:时间单位
比如秒、毫秒、分钟等。
5. workQueue:任务阻塞队列
当核心线程都在执行任务时,新任务会进入阻塞队列等待。
常见队列有:
ArrayBlockingQueue LinkedBlockingQueue SynchronousQueue PriorityBlockingQueue6. threadFactory:线程工厂
用于自定义线程创建方式。
实际开发中经常用它给线程命名,例如:
order-pool-1 order-pool-2这样线上排查问题时更容易定位。
7. handler:拒绝策略
当线程池和队列都满了,无法再接收任务时,就会触发拒绝策略。
三、线程池执行任务的流程
可以记成一句话:
先核心线程,再任务队列,再临时线程,最后拒绝策略。
具体流程是:
- 提交任务后,如果当前线程数小于
corePoolSize,创建核心线程执行任务。 - 如果核心线程都在忙,则把任务放入阻塞队列。
- 如果阻塞队列也满了,并且当前线程数小于
maximumPoolSize,则创建临时线程执行任务。 - 如果当前线程数已经达到
maximumPoolSize,并且队列也满了,就触发拒绝策略。
这里有一个重要细节:
不是一提交任务就创建到最大线程数。
线程池是先用核心线程,再入队,队列满了之后才创建临时线程。
所以队列大小会直接影响线程池扩容行为。
四、四种常见拒绝策略
1. AbortPolicy
默认策略。
任务无法提交时,直接抛出RejectedExecutionException。
适合希望快速感知异常的场景,比如核心业务任务不能静默丢失。
但是需要业务方捕获异常,否则可能影响主流程。
2. DiscardPolicy
直接丢弃新任务,不抛异常。
这种策略风险比较大,因为任务丢了也没有任何提示。
适合一些允许丢失的非核心任务,比如:
心跳上报 埋点统计 临时状态刷新3. DiscardOldestPolicy
丢弃队列中最早的任务,然后尝试提交当前新任务。
适合更关注新数据的场景,比如:
实时行情刷新 实时监控数据 实时位置上报但是如果任务之间有顺序依赖,不能用这个策略。
4. CallerRunsPolicy
由提交任务的线程自己执行这个任务。
比如主线程提交任务到线程池,线程池满了,这个任务就由主线程自己执行。
它的好处是:
不会丢任务 不会抛异常 可以反向降低任务提交速度因为调用线程自己去执行任务后,提交新任务的速度自然会变慢,相当于一种简单的削峰限流。
适合不希望任务丢失,但可以接受响应变慢的场景。
五、核心线程数和最大线程数怎么设置?
这个问题不能死背公式,要结合业务类型和压测结果。
1. CPU 密集型任务
比如:
复杂计算 加密解密 图片处理 规则计算这类任务主要消耗 CPU,线程太多反而会带来上下文切换开销。
常见参考值:
线程数 = CPU 核数 + 1加 1 是为了在个别线程阻塞时,仍然有线程可以继续利用 CPU。
2. I/O 密集型任务
比如:
数据库查询 Redis 访问 HTTP 调用 文件读写 MQ 消费这类任务大量时间在等待 I/O,CPU 并不是一直忙,所以可以设置更多线程。
常见参考值:
线程数 = 2 * CPU 核数更通用的公式是:
线程数 = CPU 核数 * (1 + 线程等待时间 / 线程运行时间)注意你原文里写的是:
线程数 = CPU核数 * (1 + 线程等待时间 / 线程运行总时间)
这里建议改成:
线程数 = CPU 核数 * (1 + 等待时间 / 计算时间)因为这个公式关注的是:线程等待时间相对于真正占用 CPU 计算时间的比例。
等待时间越长,说明线程越经常阻塞,就可以配置更多线程。
六、为什么不能硬套公式?
因为实际应用很复杂:
一个应用里可能有很多线程池,比如:
Tomcat 工作线程 Dubbo/RPC 线程 数据库连接池线程 MQ 消费线程 业务异步线程池 定时任务线程池 GC 线程如果每个线程池都按公式配置很多线程,总线程数可能非常大,反而导致:
上下文切换频繁 CPU 利用率异常 内存占用升高 响应时间变长 甚至 OOM所以合理做法是:
- 根据任务类型设置初始值;
- 通过压测观察吞吐量、响应时间、CPU、内存、队列堆积;
- 结合线上监控继续调整;
- 最终找到适合当前业务的参数。
七、上线后关注哪些线程池指标?
你这部分写得挺好,可以稍微压缩成面试表达。
上线后主要关注四类指标:
1. 线程维度
包括:
核心线程数 当前线程数 活跃线程数 最大线程数 线程创建和销毁频率如果活跃线程数长期接近最大线程数,说明线程池压力比较大。
2. 队列维度
包括:
队列长度 队列剩余容量 任务等待时间如果队列长期堆积,说明任务提交速度大于处理速度。
可能原因有:
线程数太少 任务执行太慢 下游服务变慢 数据库慢 SQL 锁竞争严重3. 任务维度
包括:
任务提交速率 任务完成速率 任务平均执行耗时 任务最大执行耗时 任务失败数如果提交速率长期大于完成速率,说明线程池迟早会堆满。
4. 异常维度
包括:
拒绝任务数 任务异常数 线程阻塞时间 死锁检测拒绝任务数是非常重要的告警指标。
一旦出现拒绝,说明线程池已经到达瓶颈,需要立刻排查是流量突增、任务变慢,还是配置不合理。
八、为什么不推荐使用 Executors 创建线程池?
这个问题很常见。
Executors虽然用起来方便,但它隐藏了很多关键参数,容易导致资源不可控。
1. newFixedThreadPool 和 newSingleThreadExecutor
它们使用的是无界队列:
LinkedBlockingQueue默认容量接近:
Integer.MAX_VALUE如果任务提交速度大于任务处理速度,队列会不断堆积,最终可能导致 OOM。
2. newCachedThreadPool
它使用的是:
SynchronousQueue并且最大线程数是:
Integer.MAX_VALUE如果瞬间来了大量任务,线程池可能疯狂创建线程,最终导致:
线程数过多 CPU 上下文切换严重 内存耗尽 OOM3. 推荐方式
推荐直接使用ThreadPoolExecutor,显式指定:
核心线程数 最大线程数 队列大小 线程工厂 拒绝策略这样线程池资源是可控的。
例如:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 10, 20, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() );当然,如果不用 Guava,也可以自己实现ThreadFactory。
九、面试回答
线程池主要是为了复用线程、控制资源和管理任务。它通过核心线程数、最大线程数、阻塞队列、线程工厂和拒绝策略等参数来控制任务执行流程。任务提交后,会先创建核心线程执行;核心线程满了之后进入阻塞队列;队列满了之后再创建临时线程;如果线程数达到最大值,就执行拒绝策略。
核心线程数和最大线程数的设置需要结合任务类型。CPU 密集型任务一般设置为 CPU 核数加一,I/O 密集型任务可以设置得更大,比如两倍 CPU 核数,但这些只是参考值,最终还是要结合压测和线上监控来调优。
实际开发中不推荐使用 Executors 创建线程池,因为它可能使用无界队列或者允许创建过多线程,存在 OOM 风险。更推荐直接使用 ThreadPoolExecutor,明确指定队列大小、线程数量和拒绝策略。
上线后需要重点关注活跃线程数、队列长度、任务耗时、任务完成速率、拒绝任务数等指标。如果业务流量变化较大,还可以引入动态线程池,把核心参数放到配置中心,实现在线调整和告警监控。
