并发 - 线程池 (ThreadPool)
并发编程 —— 线程池 (ThreadPool)
1. 核心理论:为什么需要线程池?
在并发编程中,如果我们为每个任务都创建一个新线程 (new Thread(task).start()),会遇到几个严重问题:
- 资源消耗大:频繁地创建和销毁线程,会占用大量内存和 CPU 时间。
- 响应速度慢:创建线程是一个耗时操作,任务无法立即执行。
- 管理失控:无限制地创建线程,可能导致服务器资源耗尽,系统崩溃(OOM)。
线程池就是为了解决这些问题而生的。它的核心思想是:将线程的创建和管理与任务的执行相分离。它预先创建好一定数量的线程放在一个“池子”里,当有任务来时,就从池子中取一个空闲线程来执行任务;任务执行完毕后,线程并不销毁,而是放回池子中,等待下一个任务。
线程池的好处:
- 降低资源消耗:通过复用线程,避免了频繁创建和销毁的开销。
- 提高响应速度:任务来了可以直接用池中的线程执行,无需等待线程创建。
- 提高可管理性:可以统一地分配、调优和监控池中的所有线程。
2. 深度剖析:ThreadPoolExecutor 核心参数与工作流程
java.util.concurrent.ThreadPoolExecutor 是 Java 中线程池最核心的实现类。理解它的构造方法中的七个参数,是掌握线程池的关键。
2.1 七大核心参数
corePoolSize(核心线程数):线程池中长期保持活动的线程数量,即使它们处于空闲状态,也不会被回收(除非设置了allowCoreThreadTimeOut)。这是线程池的基本大小,代表了线程池的常驻服务能力。maximumPoolSize(最大线程数):线程池能够容纳的最大线程数量。当工作队列已满,并且当前线程数小于最大线程数时,线程池会创建新的“临时”线程来处理任务,但总数不能超过这个值。keepAliveTime(存活时间):当线程池中的线程数量超过corePoolSize时,这些多余的“临时”线程在空闲状态下能够存活的最长时间。一旦超过这个时间,它们就会被回收(销毁),直到线程数降回corePoolSize。unit(时间单位):keepAliveTime的时间单位,如TimeUnit.SECONDS。workQueue(工作队列):一个阻塞队列,用于存放当核心线程都在忙时,新提交的、等待执行的任务。- 常见的队列类型:
ArrayBlockingQueue:有界的数组队列,基于数组,需要指定容量。LinkedBlockingQueue:可有界或无界的链表队列,默认是Integer.MAX_VALUE(近乎无界),存在 OOM 风险。SynchronousQueue:不存储元素的队列,每个插入操作必须等待一个对应的移除操作。PriorityBlockingQueue:支持优先级的无界阻塞队列。
- 常见的队列类型:
threadFactory(线程工厂):用于创建新线程的工厂。可以自定义线程名称、是否为守护线程等,便于排查问题和管理。rejectedExecutionHandler(拒绝策略):当工作队列已满,并且线程数也达到了maximumPoolSize时,线程池用来处理新提交任务的策略。- 常见的拒绝策略 (Handler):
ThreadPoolExecutor.AbortPolicy(默认):直接抛出RejectedExecutionException异常,阻止系统正常运行。ThreadPoolExecutor.CallerRunsPolicy:由提交任务的线程(调用execute()的线程)自己来执行这个任务。ThreadPoolExecutor.DiscardPolicy:直接丢弃这个任务,不做任何处理。ThreadPoolExecutor.DiscardOldestPolicy:丢弃工作队列中最旧的一个任务,然后重新尝试提交当前任务。
- 常见的拒绝策略 (Handler):
2.2 工作流程 (任务调度机制)
当一个新任务通过 execute() 方法提交给线程池时,线程池会按照以下顺序和规则进行调度:
- 判断核心线程池是否已满:
- 如果当前运行的线程数小于
corePoolSize,线程池会立即创建一个新的核心线程来执行任务。 - 注意:即使当前有空闲的核心线程,只要当前线程数未达到
corePoolSize,线程池也会优先创建新线程。
- 如果当前运行的线程数小于
- 判断工作队列是否已满:
- 如果当前运行的线程数已经达到
corePoolSize,线程池会尝试将新任务放入workQueue工作队列中等待。
- 如果当前运行的线程数已经达到
- 判断最大线程池是否已满:
- 如果工作队列也满了(队列无法接收任务),线程池会检查当前运行的线程数是否小于
maximumPoolSize。 - 如果小于
maximumPoolSize,线程池会创建一个新的“临时”线程来执行这个任务。
- 如果工作队列也满了(队列无法接收任务),线程池会检查当前运行的线程数是否小于
- 执行拒绝策略:
- 如果当前运行的线程数已经等于
maximumPoolSize,这意味着线程池已达到其最大容量,无法再处理新任务。此时,就会启动拒绝策略 (rejectedExecutionHandler) 来处理这个任务。
- 如果当前运行的线程数已经等于
2.3 深度解析:为什么有空闲线程也要创建新线程?
这是一个线程池设计中的关键点,也是面试常考的细节。
核心目标:最大化吞吐量和降低任务处理的延迟。
我们用超市收银台的例子来解释:
- 顾客 (Task):源源不断前来结账的顾客。
- 收银员 (Thread):负责结账的员工。
corePoolSize(核心收银台数量):你计划在正常营业时段,至少要开启 5个 收银台。workQueue(等待队列):收银台前的排队区域。
假设:
- 目前你只开了 2 个收银台,还有 3 个核心收银员在休息室待命。
- 2 号收银台的收银员(线程)刚刚结完一个顾客的账,正处于空闲状态,他正准备看自己面前的“待处理任务篮”(
workQueue),看有没有新任务。 - 这时,来了一个新顾客(新任务)。
作为经理(ThreadPoolExecutor),你面临两个选择:
-
让新顾客去排队 (将任务放入
workQueue):- 你把这个新顾客的商品单放入 2 号收银员面前的“待处理任务篮”。收银员会从篮子里取出任务并开始服务。
- 代价:这个过程涉及到“入队”操作(加锁),以及收银员“出队”操作(也加锁)。有排队等待和锁竞争的开销。
-
新开一个收银台 (创建新线程):
- 你立刻从休息室叫来一个新收银员,直接打开 3 号收银台,让这个新顾客直接过去结账。
- 优点:新顾客立即得到服务,避免了排队和等待。
- 代价:创建了一个新线程。
线程池设计者选择了后者。原因在于:
- 避免“入队-出队”的开销和锁竞争:
- 将任务放入共享的
workQueue需要加锁,线程从队列取出任务也需要加锁。在高并发场景下,这个队列的锁竞争可能会成为性能瓶颈。 - 通过创建新线程,任务直接被分配,完全绕过了队列的锁竞争和排队延迟,对于新任务而言,响应速度最快。
- 将任务放入共享的
- 最大化并行度,快速响应突发流量 (Bursty Loads):
- 这种设计倾向于认为,如果新任务不断提交,并且当前线程数还未达到
corePoolSize,那么系统很可能正迎来一个“任务爆发”阶段。 - 此时,最有效的策略是尽快将处理能力提升到核心线程数,让更多的任务能够并行执行,从而降低整体延迟和提高吞吐量。
- 简单且高效的规则:规则“如果核心线程数未满,就创建新线程”非常简单直接,没有复杂的“探测空闲线程状态”的逻辑,避免了额外的开销和复杂性。
- 这种设计倾向于认为,如果新任务不断提交,并且当前线程数还未达到
总结:在未达到 corePoolSize 时,线程池优先创建新线程,是为了以最快的速度将处理能力提升到预期的核心水平,从而避免队列等待开销,提高新任务的响应速度和系统整体吞吐量。
2.4 空闲线程何时工作?以及为何容忍队列开销?
这是一个非常深入的问题,它揭示了线程池在不同工作阶段的策略权衡。
1. 空闲线程何时启用?
当线程池中正在运行的线程数已经达到 corePoolSize 时,空闲的核心线程才会被启用。
具体流程是:
- 核心线程池已满:假设
corePoolSize是 5,现在已经有 5 个线程在运行。 - 新任务到来:第 6 个任务提交进来。
- 任务入队:线程池检查发现,当前线程数(5)不小于
corePoolSize(5),于是不再创建新线程,而是把这个新任务放入workQueue(工作队列)。 - 空闲线程被唤醒:此时,假设之前的 5 个核心线程中,有 1 个(比如线程A)刚好完成了它的任务,正处于空闲状态。这个空闲线程正在阻塞地等待从
workQueue中获取新任务(调用workQueue.take())。当第 6 个任务被放入队列后,线程A立刻被唤醒,成功地从队列中取出第 6 个任务,并开始执行。
简单来说:空闲线程是“清扫战场”的,它们专门负责处理在核心线程全都在忙时,被“缓冲”到工作队列里的积压任务。
2. 为什么此时能够容忍“入队-出队”的开销?
您的观察完全正确:当任务进入队列后,确实会产生“入队-出队”的开销和锁竞争。
线程池的设计之所以能容忍这一点,是因为它将工作分为了两个阶段,并采用了不同的策略:
-
阶段一:快速响应阶段 (
workerCount < corePoolSize)- 目标:不惜一切代价,最快地响应新任务,降低延迟。
- 策略:在这个阶段,线程池认为系统的处理能力尚未饱和。对于新来的任务,“排队等待”的延迟是不可接受的。相比之下,“创建新线程”的开销虽然存在,但能换来任务的立即执行,所以是值得的。因此,它选择绕过队列,直接创建新线程。
-
阶段二:稳定处理与缓冲阶段 (
workerCount >= corePoolSize)- 目标:在核心处理能力饱和的情况下,稳定系统,并作为流量洪峰的缓冲带。
- 策略:当核心线程数已满,线程池认为其“常规战斗力”已经达到顶峰。此时,它改变了策略:
- “创建新线程”的成本被认为变高了,系统不再轻易扩张其核心团队。
- 工作队列(
workQueue)的“缓冲”价值体现出来了。它能够吸收超出核心处理能力的瞬时流量,防止因任务过多而直接拒绝请求导致系统崩溃。 - 在这个阶段,“入队-出队”的锁竞争开销,是为了换取整个系统的稳定性和可预测性而付出的、完全可以接受的必要成本。
总结:线程池的设计非常精妙,它不是简单地选择“成本最低”的方案,而是在不同的负载阶段,对“响应速度”和“系统稳定性”这两个目标有着不同的侧重,并为此采取了不同的调度策略。
2.5 案例分析:任务派发分步解析
为了彻底理解工作流程,我们以一个具体的例子来手动模拟线程池的调度过程。
设定:
corePoolSize: 2maximumPoolSize: 4workQueue: 容量为 2 的ArrayBlockingQueuehandler:CallerRunsPolicy- 连续提交 10 个任务
派发过程:
- 任务 1: 线程数(0) < 核心数(2),创建核心线程1执行。
- 任务 2: 线程数(1) < 核心数(2),创建核心线程2执行。
- 状态:2个核心线程在忙,队列为空。
- 任务 3: 线程数(2) >= 核心数(2),放入队列。队列占用 1/2。
- 任务 4: 线程数(2) >= 核心数(2),放入队列。队列占用 2/2。队列已满。
- 状态:2个核心线程在忙,队列已满。
- 任务 5: 队列已满,判断线程数(2) < 最大数(4),创建临时线程3执行。
- 任务 6: 队列已满,判断线程数(3) < 最大数(4),创建临时线程4执行。
- 状态:4个线程在忙(2核+2临),队列已满。线程池满负荷!
- 任务 7: 队列已满,线程数(4) >= 最大数(4),触发拒绝策略
CallerRunsPolicy。任务由提交者(main 线程)自己执行。 - 任务 8、9、10: 同上,都由 main 线程执行。
最终分工:
- 核心线程1、2:执行任务1、2,以及后续从队列中取出的任务3、4。
- 临时线程3、4:执行任务5、6。
- main 线程:执行任务7、8、9、10。
3. 实践指导:参数配置与 Executors 陷阱
在生产环境中,合理地配置线程池参数是保证系统稳定性和性能的重要一环。计算这些参数没有“万能公式”,但我们可以遵循一套成熟的分析方法和指导原则。
3.1 如何合理配置核心参数?
1. corePoolSize 和 maximumPoolSize (线程数量)
这是最关键的参数,其配置完全取决于任务的类型。
-
CPU 密集型任务 (CPU-bound)
- 特点:任务需要进行大量的计算,消耗 CPU 资源,例如视频编码、复杂算法、大数据分析等。CPU 会一直高速运转。
- 分析:如果线程数过多(远超 CPU 核心数),会导致线程频繁地进行上下文切换,这会带来巨大的性能开销。
- 配置策略:
corePoolSize=maximumPoolSize=CPU 核心数 + 1 - 原因:
CPU 核心数是能真正并行处理任务的数量。+1是一个“备用线程”,它可以在某个核心线程因偶然原因(如缺页中断)暂停时,立刻顶上去,保证 CPU 的利用率不下降。在这种场景下,通常将corePoolSize和maximumPoolSize设置为相等,以创建一个固定大小的线程池。
-
IO 密集型任务 (IO-bound)
- 特点:任务的大部分时间都在等待 IO 操作(如等待网络响应、数据库查询、磁盘读写),CPU 在这段时间是空闲的。
- 分析:因为线程在等待时是不消耗 CPU 的,所以我们可以配置更多的线程,以提高 CPU 在线程切换间的利用率。当线程A在等待数据库返回结果时,CPU 可以立刻切换去执行线程B的任务。
- 配置策略:
一个经典的理论公式是:
线程数 = CPU 核心数 * (1 + 平均等待时间 / 平均计算时间) - 这个公式在实际中很难精确计算。因此,工程上更常用的策略是:
线程数 = CPU 核心数 * N (其中 N 通常 >= 2)
- 可以从
CPU 核心数 * 2开始,然后通过压力测试,根据系统的实际负载和响应时间,逐步调整到一个最优值。
如何获取 CPU 核心数?
int cores = Runtime.getRuntime().availableProcessors();
2. workQueue (工作队列)
队列的选择和容量,直接影响线程池的工作行为,必须谨慎。
-
LinkedBlockingQueue(无界队列):生产环境慎用! 如果任务生产速度远大于消费速度,会导致任务在队列中无限堆积,最终耗尽内存,引发 OOM。使用它会导致maximumPoolSize参数失效。 -
ArrayBlockingQueue(有界队列):强烈推荐。它强制你思考队列的容量,从而对系统负载有一个预估和控制。容量大小需要根据业务能接受的最大延迟和内存占用情况来估算,通常通过压测决定。 -
SynchronousQueue(同步队列):一个不存储元素的队列。它会强制线程池快速扩张到maximumPoolSize,适用于处理大量、耗时短的突发任务。但如果maximumPoolSize设置为无界,同样有耗尽系统资源的 OOM 风险。
3. keepAliveTime (存活时间)
-
此参数用于回收超出
corePoolSize的“临时线程”。 -
配置:取决于突发任务的频率。如果流量高峰是持续性的,可以设置长一些(如 60 秒),让临时线程能被复用;如果流量高峰是偶发的,可以设置短一些(如 10 秒),以便快速释放资源。
4. rejectedExecutionHandler (拒绝策略)
这是系统的最后一道防线。
AbortPolicy(默认):生产环境不推荐。它会抛出异常,可能导致调用方程序崩溃。CallerRunsPolicy:一个很好的“反压”策略。它不会丢弃任务,而是让提交任务的那个线程自己去执行。这会自然地降低任务提交的速度,因为提交者自己正忙于处理任务,无法再快速提交新任务。DiscardPolicy/DiscardOldestPolicy:只有在你的任务可以被安全丢弃的情况下才能使用(例如,非关键的日志记录)。
5. threadFactory (线程工厂)
- 最佳实践:总是提供一个自定义的
ThreadFactory来为你的线程池命名,例如my-business-worker-%d。有意义的线程名称在排查问题时(如打印线程堆栈、使用jstack命令)至关重要。
3.2 为什么禁止使用 Executors 创建线程池?
阿里巴巴的《Java 开发手册》中强制规定,不允许使用 Executors 工具类来创建线程池,而是要通过 ThreadPoolExecutor 的构造函数手动创建。原因就是 Executors 提供的方法存在严重的资源耗尽风险,最终可能导致 OOM (内存溢出)。
-
Executors.newFixedThreadPool(n)和Executors.newSingleThreadExecutor():- 风险: 它们都使用了
LinkedBlockingQueue作为工作队列,且容量为Integer.MAX_VALUE(近乎无界)。如果任务堆积,会吃光所有内存。
- 风险: 它们都使用了
-
Executors.newCachedThreadPool():- 风险: 它的
maximumPoolSize被设置为Integer.MAX_VALUE。如果瞬时任务过多,会无限制地创建新线程,最终耗尽系统资源。
- 风险: 它的
结论:为了资源可控,避免 OOM 风险,我们必须手动创建 ThreadPoolExecutor,并为其指定合理的队列容量和拒绝策略。
4. 生活中的例子与代码示例:银行叫号系统
-
生活比喻: 我们可以把线程池想象成一个银行的客户服务系统。
corePoolSize: 银行的常规服务窗口数量,比如有 2 个,它们一直在岗。workQueue: 大厅里的等候区座位,比如有 10 个。常规窗口忙不过来时,客户就在这里排队等候。maximumPoolSize: 银行总共能开设的最大窗口数,比如是 5 个。当等候区也坐满了,银行大堂经理就会临时增开 3 个“机动窗口”。keepAliveTime: “机动窗口”如果空闲超过一定时间(比如60秒),就会被关闭。RejectedExecutionHandler: 当等候区坐满,所有 5 个窗口也都占满时,门口的保安就会跟新来的客户说:“抱歉,现在人太多了,请您稍后再来”。
-
核心代码示例:
package com.study.concurrency;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class BankServiceWindow {
public static void main(String[] args) {
// 创建一个线程池,模拟银行的服务窗口
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2, // 2个常规窗口
5, // 最多开5个窗口
60L, TimeUnit.SECONDS, // 临时窗口60秒没活就关闭
new LinkedBlockingQueue<>(10), // 等候区有10个座位
new ThreadPoolExecutor.AbortPolicy() // 人满了就拒绝新客户
);
// 模拟来了20个客户
for (int i = 1; i <= 20; i++) {
final int customerId = i;
try {
// 客户取号,提交任务
threadPool.execute(() -> {
System.out.println("窗口 " + Thread.currentThread().getName() + " 正在为客户 " + customerId + " 办理业务...");
try {
// 模拟办理业务的时间
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(">>> 客户 " + customerId + " 的业务办理完成。");
});
} catch (Exception e) {
System.out.println("!!! 对不起,客户 " + customerId + ",现在太忙了,请稍后再来!");
}
}
// 关闭线程池
threadPool.shutdown();
}
}

浙公网安备 33010602011771号