一、线程池各个参数含义
参数名称 | 含义 |
---|---|
corePoolSize | 核心线程数 |
maxPoolSize | 最大线程数 |
keepAliveTime + 时间单位 | 空闲线程的存活时间 |
ThreadFactory | 线程工厂、用来创建新线程 |
workQueue | 用于存放任务的队列 |
Handler | 处理被拒绝的任务 |
-
corePoolSize 与 maxPoolSize
- corePoolSize 指的是核心线程数,线程池初始化时线程数默认为 0,当有新的任务提交后,会创建 新线程执行任务,如果不做特殊设置,此后线程数通常不会再小于 corePoolSize ,因为它们是核心 线程,即便未来可能没有可执行的任务也不会被销毁corePoolSize 指的是核心线程数,线程池初始化时线程数默认为 0,当有新的任务提交后,会创建 新线程执行任务,如果不做特殊设置,此后线程数通常不会再小于 corePoolSize ,因为它们是核心 线程,即便未来可能没有可执行的任务也不会被销毁
- 随着任务量的增加,在任务队列满了之后,线程池会进一步创建新线程,最多可以达到 maxPoolSize 来应对任务多的场景,如果未来线程有空闲,大于 corePoolSize 的线程会被合理回收
- 所以正常情况下,线程池中的线程数量会处在 corePoolSize 与 maxPoolSize 的闭区间内
- 线程池只有在任务队列填满时才创建多于 corePoolSize 的线程,如果使用的是无界队列 (例如 LinkedBlockingQueue),那么由于队列不会满,所以线程数不会超过 corePoolSize
- 通过设置 corePoolSize 和 maxPoolSize 为相同的值,就可以创建固定大小的线程池
- 通过设置 maxPoolSize 为很高的值,例如 Integer.MAX_VALUE,就可以允许线程池创建任意多的线程
keepAliveTime + 时间单位
当线程池中线程数量多于核心线程数时,而此时又没有任务可做,线程池就会检测线程的 keepAliveTime 如果超过规定的时间,无事可做的线程就会被销毁,以便减少内存的占用和资源消耗 如果后期任务又多了起来,线程池也会根据规则重新创建线程,所以这是一个可伸缩的过程,比较灵活 我们也可以用 setKeepAliveTime 方法动态改变 keepAliveTime 的参数值-
ThreadFactory
- ThreadFactory 实际上是一个线程工厂,它的作用是生产线程以便执行任务 我们可以选择使用默认的线程工厂,创建的线程都会在同一个线程组并拥有一样的优先级,且都不是守护线程 我们也可以选择自己定制线程工厂,以方便给线程自定义命名 不同的线程池内的线程通常会根据具体业务来定制不同的线程名
-
ThreadFactoryBuilder builder = new ThreadFactoryBuilder(); ThreadFactory rpcFactory = builder.setNameFormat("rpc-pool- %d").build();
生成名字为 rpcFactory 的 ThreadFactory
它的 nameFormat 为 "rpc-pool-%d" ,那么它 生成的线程的名字是有固定格式的,它生成的 线程的名字分别为"rpc-pool-1" , "rpc- pool-2" ,以此类推
-
Handler 拒绝策略
- AbortPolicy
这种拒绝策略在拒绝任务时,会直接抛出一个类型为RejectedExecutionException 的 RuntimeException让你感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略 - DiscardPolicy
这种拒绝策略正如它的名字所描述的一样,当新任务被提交后直接被丢弃掉,也不会给你任何的通知 相对而言存在一定的风险,因为我们提交的时候根本不知道这个任务会被丢弃,可能造成数据丢失 - DiscardOldestPolicy
如果线程池没被关闭且没有能力执行,则会丢弃任务队列中的头结点,通常是存活时间最长的任务 这种策略与第二种不同之处在于它丢弃的不是最新提交的,而是队列中存活时间最长的,这样就可以腾 出空间给新提交的任务,但同理它也存在一定的数据丢失风险 - CallerRunsPolicy
相对而言它就比较完善了,当有新任务提交后,如果线程池没被关闭且没有能力执行,则把这个任务交于提交任务的线程执行,也就是谁提交任务,谁就负责执行任务
这样做主要有两点好处:- 第一点新提交的任务不会被丢弃,这样也就不会造成业务损失。
- 第二点好处是,由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务, 而执行任务又是比较耗时的,在这段期间,提交任务的线程被占用,也就不会再提交新的任务, 减缓了任务提交的速度,相当于是一个负反馈。在此期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期。
- 实现 RejectedExecutionHandler 接口来实现自己的拒绝策略
在接口中我们需要实现 rejectedExecution 方 法,在 rejectedExecution 方法中,执行例如 打印日志、暂存任务、重新执行等自定义的拒 绝策略,以便满足业务需求
- AbortPolicy
-
线程创建的时机
- 假设我们的任务特别的多,已经达到了 workQueue 的容量上限,这时线程池就会启动后备力量,也就是 maxPoolSize 最大线程数,线程池会在 corePoolSize 核心线程数的基础上继续创建线程来执行任务
- 假设任务被不断提交,线程池会持续创建线程直到线程数达到 maxPoolSize 最大线程数
- 如果依然有任务被提交,这就超过了线程池的最大处理能力,这个时候线程池就会拒绝这些任务
-
我们可以看到实际上任务进来之后,线程池会逐一判断 corePoolSize 、workQueue 、maxPoolSize , 如果依然不能满足需求,则会拒绝任务
二、6种常见的线程池
- FixedThreadPool
核心线程数和最大线程数是一样的,所以可以把它看作是固定线程数的线程。 - CachedThreadPool
在于线程数是几乎可以无限增加的(实际最大可以达到Integer.MAX_VALUE,为 2^31-1,这个数非常大, 所以基本不可能达到),而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固 定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为 0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高 - ScheduledThreadPool
ScheduledExecutorService service = Executors. newScheduledThreadPool(10);
- 第一种:
service.schedule(new Task(), 10, TimeUnit.SECONDS);
表示延迟指 定时间后执行一次任务,如果代码中设置参数 为 10 秒,也就是 10 秒后执行一次任务后就结束 - 第二种:
service.scheduleAtFixedRate(new Task(), 10, 10,TimeUnit.SECONDS);
表示以固定 的 频 率 执 行 任 务 , 它 的 第 二 个 参 数 initialDelay 表示第一次延时时间,第三个参 数 period 表示周期,也就是第一次延时后每 次延时多长时间执行一次任务 - 第三种:
service.scheduleWithFixedDelay(new Task(), 10, 10, TimeUnit. SECONDS);
与第二 种方法类似,也是周期执行任务,区别在于对 周期的定义,之前的 scheduleAtFixedRate 是 以任务开始的时间为时间起点开始计时,时间 到就开始执行第二次任务,而不管任务需要花 多久执行;而 scheduleWithFixedDelay 方法 以任务结束的时间为下一次循环的时间起点开 始计时 - 举个例子
假设某个同学正在熬夜写代码,需 要喝咖啡来提神,假设每次喝咖啡都需要花10 分钟的时间- 如果此时采用第2种方法 scheduleAtFixedRate,时间间隔设置为 1 小 时,那么他将会在每个整点喝一杯咖啡
时间表:
00:00: 开始喝咖啡
00:10: 喝完了
01:00: 开始喝咖啡
01:10: 喝完了
02:00: 开始喝咖啡
02:10: 喝完了 - 但是假设他采用第3种方法 scheduleWithFixedDelay,时间间隔同样设置 为 1 小时,那么由于每次喝咖啡需要10分钟, 而 scheduleWithFixedDelay 是以任务完成的 时间为时间起点开始计时的,所以第2次喝咖啡 的时间将会在1:10,而不是1:00整
时间表:
00:00: 开始喝咖啡
00:10: 喝完了
01:10: 开始喝咖啡
01:20: 喝完了
02:20: 开始喝咖啡
02:30: 喝完了
- 如果此时采用第2种方法 scheduleAtFixedRate,时间间隔设置为 1 小 时,那么他将会在每个整点喝一杯咖啡
- 第一种:
- SingleThreadExecutor
SingleThreadExecutor会使用唯一的线程去执行任务 原理和 FixedThreadPool 是一样的,只不过这里线程只有一个 如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务
这种线程池由于只有一个线程,所以非常适合所有线程都需要按照被提交的顺序,依次执行的场景 - SingleThreadScheduledExecutor
实际和 ScheduledThreadPool 线程池非常相似,它只是ScheduledThreadPool 的一个特例,内部只有一个线程new ScheduledThreadPoolExecutor(1)
- ForkJoinPool
- 分裂汇总线程池
- 每个分裂的线程都有自己独立的一个队列
deque
- 假设有两个线程A和B,A的
deque
任务繁重,B很空闲 - B就会帮助A执行任务,俗称偷任务
- A获取任务的顺序是后进先出
- B偷任务的顺序是先进先偷
- 具体看下图 steal task
- 假设有两个线程A和B,A的
- 举例【斐波那契数列】:后一个数等于前两个数之和
- 首先继承了 RecursiveTask,RecursiveTask 类是对 ForkJoinTask 的一个简单的包装,这 时我们重写 compute() 方法
- 当 n<=1 时直接返回,当 n>1 就创建递归任务, 也就是 f1 和 f2
- 然后我们用 fork() 方法分裂任务并分别执行
- 最后在 return 的时候,使用 join() 方法把 结果汇总,这样就实现了任务的分裂和汇总
- main函数会打印出斐波那契数列的第 0 到 9 项的 值:
0;1;1;2;3;5;8;13;21;34
class Fibonacci extends RecursiveTask {
int n;
public Fibonacci(int n) {
this.n = n;
}
@Override
public Integer compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
f2.fork();
return f1.join() + f2.join();
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
ForkJoinPool forkJoinPool = new ForkJoinPool();
for (int i = 0; i < 10; i++) {
ForkJoinTask task = forkJoinPool.submit(new Fibonacci(i));
System.out.println(task.get());
}
}
三、线程池内部结构
- 线程池管理器
主要负责管理线程池的创建、 销毁、添加任务等管理操作, 是整个线程池的管家 - 工作线程
- 任务队列
作为一种缓冲机制,线程池会把 当下没有处理的任务放入任务队 列中,由于多线程同时从任务队列中获取任务是并发场景,此时就需要任务队列满足线程安全的要求,所以线程池中任务队列采用 BlockingQueue 来保障线程安全 - 任务
任务要求实现统一的接口,以 便工作线程可以处理和执行 - 阻塞队列
- LinkedBlockingQueue
- 被使用:FixedThreadPool 和 SingleThreadExector
- 含义:容量为 Integer.MAX_VALUE,可以认为是无界队列
- 解释:由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要 LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务
- 注意:由于线程池的任务队列永远不会放满,所以线程池只会创建核心线程数量的线程,所以此时的最大线程数对线程池来说没有意义,因为并不会触发生成多于核心线程数的线程
- SynchronousQueue
- 被使用:CachedThreadPool
- 解释:线程池 CachedThreadPool 的最大线程数是 Integer 的最大值,可以理解为线程数是可以无限扩展的 CachedThreadPool 和上一种线程池 FixedThreadPool 的情况恰恰相反,FixedThreadPool 的情况是阻塞队列的容量是无限的,而这里 CachedThreadPool 是线程数可以无限扩展 所以 CachedThreadPool 线程池并不需要一个任务队列来存储任务,因为一旦有任务被提交就直接转发给线程或者创建新线程来执行,而不需要另外保存它们
- DelayedWorkQueue
- 被使用:ScheduledThreadPool 和 SingleThreadScheduledExecutor
- 解释:这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行 一次任务 DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构之所以线程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 选择 DelayedWorkQueue,是因为它们本身正是基于时间执行任务的, 而延迟队列正好可以把任务按时间进行排序,方便任务的执行
- ArrayBlockingQueue
- 被使用:自己新建线程池
- 解释:有界队列,不能扩容,实际最好的阻塞队列
- LinkedBlockingQueue
线程池 | 阻塞队列 |
---|---|
FixedThreadPool | LinkedBlockingQueue |
SingleThreadExecutor | LinkedBlockingQueue |
CachedThreadPool | SynchronousQueue |
ScheduledThreadPool | DelayedWorkQueue |
SingleThreadScheduledExecutor | DelayedWorkQueue |
四、为什么不应该自动创建线程池
- FixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue< Runnable>()); }
- 通过往构造函数中传参,创建了一个核心线程数和最大线程数相等的线程池
- 它们的数量也就是我们传入的参数,这里的重 点 是 使 用 的 队 列 是 容 量 没 有 上 限 的 LinkedBlockingQueue
- 如果我们对任务的处理速度比较慢,那么随着 请求的增多,队列中堆积的任务也会越来越多, 最终大量堆积的任务会占用大量内存,并发生 OutOfMemoryError,这几乎会影响到整个程序,会造成很严重的后果
- SingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue< Runnable>())); }
- newSingleThreadExecutor 和 newFixedThreadPool 的原理是一样的
- 只不过把核心线程数和最大线程数都直接设置 成了 1,但是任务队列仍是无界的 LinkedBlockingQueue,所以也会导致同样的问 题,也就是当任务堆积时,可能会占用大量的 内存并导致 OOM
- CachedThreadPool
public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue
()); } - CachedThreadPool 和前面两种线程池不一样的地方在于任务队列使用的是 SynchronousQueue, SynchronousQueue 本身并不存储任务,而是对任务直接进行转发
- 这本身是没有问题的,但构造函数的第二个参 数被设置成了 Integer.MAX_VALUE,这个参数 的含义是最大线程数,所以由于 CachedThreadPool 并不限制线程的数量,当任务数量特别多的时候,就可能会导致创建非常 多的线程
- 最终因为超过了操作系统的上限而无法创建新 线程,或者是内存不足
- ScheduledThreadPool 和 SingleThreadScheduledExecutor
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); }
public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); }
- 采用的任务队列是 DelayedWorkQueue
- 这是一个延迟队列,同时也是一个无界队列, 所以和 LinkedBlockingQueue 一样,如果队列中存放过多的任务,就可能导致 OOM
五、CPU 核心数和线程数的关系
- CPU 密集型任务
- 比如∶加密、解密、压缩、计算等一系列需要大量耗费 CPU 资源的任务对于这样的任务最佳的线程数为 CPU 核心数的 1~2 倍,如果设置过多的线程数,实际上并不会起到很好的效果
- 此时假设我们设置的线程数量是 CPU 核心数的 2 倍以上,因为计算任务非常重,会占用大量的 CPU 资源,所以这时 CPU 的每个核心工作基本都是满负荷的,而我们又设置了过多的线程,每个线程都 想去利用 CPU 资源来执行自己的任务,这就会造成不必要的上下文切换,此时线程数的增多并没有 让性能提升,反而由于线程数量过多会导致性能下降
- 耗时 IO 型任务
- 线程数 = CPU 核心数 *(1 + 平均等待时间 / 平均工作时间)
- 通过这个公式,可以计算出一个合理的线程数量,如果任务的平均等待时间长,线程数就随之增加,而如果平均工作时间长,也就是对于我们上面的 CPU 密集型任务,线程数就随之减少
- 结论
- 线程的平均工作时间所占比例越高,就需要越少的线程
- 线程的平均等待时间所占比例越高,就需要越多的线程
- 针对不同的程序,进行对应的实际测试就可以得到最合适的选择
六、如何正确关闭线程池
- void Shutdown()
- 它可以安全地关闭一个线程池,调用 shutdown()方法之后线程池并不是立刻就被关闭,因为这时线程池中可能还有很多任务正在被执行,或是任务队列中有大量正在等待被执行的任务,调用 shutdown)方法后线程池会在执行完正在执行的任务和队列中等待的任务后才彻底关闭。
- 但这并不代表 shutdown()操作是没有任何效果的,调用 shutdown()方法后如果还有新的任务被提交,线程池则会根据拒绝策略直接拒绝后续新提交的任务
- boolean isShutdown()
- 可以返回 true 或者 false 来判断线程池是否已经开始了关闭工作,也就是是否执行了shutdown 或者 shutdownNow方法
- 这里需要注意,如果调用isShutdown()方法的返回的结果为 true 并不代表线程池此时已经彻底关闭了,这仅仅代表线程池开始了关闭的流程也就是说,此时可能线程池中依然有线程在执行任务,队列里也可能有等待被执行的任务
- boolean isTerminated()
- 这个方法可以检测线程池是否真正 "终结"
- 这不仅代表线程池已关闭,同时代表线程池中的所有任务都已经都执行完毕了
- 比如此时已经调用了 shutdown 方法,但是有一个线程依然在执行任务,那么此时调用 isShutdown 方法返回的是 true,而调用isTerminated 方法返回的便是 false,因为线程池中还有任务正在被执行,线程池并没有真正"终结"直到所有任务都执行完毕了,调用isTerminated()方法才会返回 true,这表示线程池已关闭并且线程池内部是空的,所有剩余的任务都执行完毕了
- boolean awaitTermination()
- 本身并不是用来关闭线程池的,而是主要用来判断线程池状态的
- 比如我们给 awaitTermination 方法传入的参数是 10 秒,那么它就会陷入 10 秒钟的等待,直到发生以下三种情况之一:
- 等待期间(包括进入等待状态之前)线程池已关闭并且所有已提交的任务(包括正在执行的和队列 中等待的)都执行完毕,相当于线程池已经“终结”了,方法便会返回 true
- 等待超时时间到后,第一种线程池“终结”的情况始终未发生,方法返回 false
- 等待期间线程被中断,方法会抛出 InterruptedException 异常
- List
shutdownNow() - 给所有线程发送 Interrupt 信号,请求终止,并将等待队列中的任务放入一个list中返回出来以便补救
- 这里需要注意的是,由于 Java 中不推荐强行停止线程的机制的限制,即便我们调用了 shutdownNow 方法,如果被中断的线程对于中断信号不理不睬,那么依然有可能导致任务不会停止
public List shutdownNow() {
List tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
advanceRunState(STOP);
interruptWorkers();
tasks = drainQueue();
} finally {
mainLock.unlock();
}
tryTerminate();
return tasks;
}
七、线程池实现“线程复用”的原理
- 线程池可以把线程和任务进行解耦,线程归线程,任务归任务,摆脱了之前通过Thread创建线程时的一个线程必须对应一个任务的限制
- 在线程池中,同一个线程可以从 BlockingQueue 中不断提取新任务来执行,其核心原理在于线程池对Thread进行了封装,并不是每次执行任务都会调用Thread.start()来创建新线程,而是让每个线程去执行一个“循环任务”,在这个“循环任务”中,不停地检查是否还有任务等待被执行,如果有则直接去执行这个任务,也就是调用任务的 run 方法,把 run 方法当作和普通方法一样的地位去调用,相当于把每个任务的 run 方法串联了起来,所以线程数量并不会增加