一、 为什么使用线程池?
在Java并发编程中,线程池(ThreadPool)是最核心的组件之一。面试官常问:“为什么要用线程池?直接 new Thread() 有什么问题?”
⚡ 降低资源消耗
通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
⚡ 提高响应速度
当任务到达时,任务可以不需要等到线程创建就能立即执行。
⚡ 提高线程的可管理性
线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。
二、 线程池核心参数详解
掌握 ThreadPoolExecutor 的7个核心参数是回答面试题的基础。以下通过选项卡展示不同维度的解析。
1. corePoolSize (核心线程数)
线程池中的常驻核心线程数。在任务添加到线程池时,如果当前线程数小于 corePoolSize,则创建新线程执行任务。
2. maximumPoolSize (最大线程数)
线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于 maximumPoolSize,则创建新线程来执行任务。
3. keepAliveTime & unit
非核心线程的空闲存活时间。当线程数大于 corePoolSize 时,空闲时间超过 keepAliveTime 的非核心线程会被销毁。
4. workQueue (任务队列)
用于保存等待执行的任务的阻塞队列。常用队列包括:ArrayBlockingQueue, LinkedBlockingQueue, SynchronousQueue 等。
5. threadFactory (线程工厂)
用于创建新线程。默认工厂创建的线程同属一个组,名字为 "pool-1-thread-1"。建议自定义线程工厂,设置更有意义的名字和异常处理器。
6. handler (拒绝策略)
当队列和最大线程数都满了时的饱和策略。
在 ThreadPoolExecutor 源码中,线程状态和线程数是合并存储在一个 int 变量 workerCount 中的:
// high 3 bits = run state
// low 29 bits = run count
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
// 线程数掩码 29位
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;
// 运行状态掩码
private static final int RUNNING = -1 << COUNT_BITS;
面试官常问:为什么线程数要占用29位?
答案:因为线程数最多为 2^29 - 1,约5亿个,对于任何单机应用来说都足够了,且节省内存空间。
| 参数 | 通俗理解 |
|---|---|
| corePoolSize | 正式编制员工,一直在岗。 |
| workQueue | 待办事项清单(队列)。 |
| maximumPoolSize | 最大员工数(正式+临时)。 |
| keepAliveTime | 临时员工空闲多久被辞退。 |
| handler | 单子太多做不完时的处理办法(拒绝)。 |
三、 线程池执行流程与状态
理解线程池的执行流程是回答“任务提交流程”类问题的关键。
提交任务
调用 execute() 或 submit() 提交任务。
创建核心线程
如果当前线程数 < corePoolSize,创建新线程执行任务。
放入队列
如果当前线程数 >= corePoolSize,将任务加入 workQueue。
创建非核心线程
如果队列已满,且当前线程数 < maximumPoolSize,创建非核心线程执行任务。
执行拒绝策略
如果队列已满,且线程数达到 maximumPoolSize,则执行 RejectedExecutionHandler。
线程池的五种状态
- RUNNING: 接受新任务,处理排队任务。
- SHUTDOWN: 不接受新任务,但处理排队任务。
- STOP: 不接受新任务,不处理排队任务,并中断正在进行的任务。
- TIDYING: 所有任务都已终止,workerCount 为零。
- TERMINATED: terminated() 方法执行完毕。
四、 拒绝策略详解
当线程池资源耗尽时,如何优雅地处理拒绝任务?这是必考题。
AbortPolicy (默认)
直接抛出 RejectedExecutionException 异常,阻止系统正常运行。
CallerRunsPolicy
由调用线程(提交任务的线程)处理该任务。起到降级和缓速的作用。
DiscardPolicy
直接丢弃任务,没有任何异常抛出。
DiscardOldestPolicy
丢弃队列中最老的任务,尝试为当前任务腾出空间。
面试官追问:如果让你设计一个自定义拒绝策略,你会怎么做?
回答:可以将任务持久化到数据库或消息队列(如RocketMQ/Kafka)中,实现异步削峰填谷,保证数据不丢失。
五、 如何合理配置线程池大小?
这是最具实战意义的问题,没有标准答案,取决于业务类型。
| 任务类型 | 特点 | 配置建议 | 公式 |
|---|---|---|---|
| CPU 密集型 | 计算密集,少IO | 少线程 | N + 1 (N为CPU核数) |
| IO 密集型 | 大量阻塞等待 | 多线程 | 2N 或 N / (1 - 阻塞系数) |
| 混合型 | CPU+IO | 拆分 | 拆分为CPU和IO密集型分别处理 |
注意:以上公式仅为参考,实际生产中需通过压测(Load Testing)确定最佳参数。
六、 高频面试题 Q&A
Q1: 线程池中的 workQueue 队列为什么推荐用 LinkedBlockingQueue?
A: 默认容量为 Integer.MAX_VALUE,容易导致内存溢出(OOM)。在生产环境中,建议指定容量,如 new LinkedBlockingQueue(1000)。
Q2: 如何优雅地关闭线程池?
A: 推荐使用 shutdown() 或 shutdownNow()。
shutdown(): 停止接收新任务,等待队列中任务执行完。shutdownNow(): 停止接收新任务,尝试中断正在执行的任务,并返回等待执行的任务列表。
awaitTermination() 使用,以确保线程池完全关闭。
Q3: 线程池的 corePoolSize 可以在运行时动态调整吗?
A: 可以。通过 setCorePoolSize() 方法动态设置。但需要注意线程安全,建议在配置中心(如Nacos/Apollo)中管理线程池参数,实现动态刷新。
Q4: 什么是线程池的“浪涌”现象?如何解决?
A: 当大量任务同时提交时,即使队列未满,也可能瞬间创建大量线程(如果maximumPoolSize较大)。
解决: 使用有界队列,并合理设置 maximumPoolSize,或者使用 SynchronousQueue 配合适当的线程工厂。
Q5: ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 有什么区别?
A: ScheduledThreadPoolExecutor 继承自 ThreadPoolExecutor,主要用于执行定时或周期性任务(scheduleAtFixedRate, scheduleWithFixedDelay)。其内部使用了 DelayQueue。