Java 中的线程池
Contents
在多线程并发的环境下创建多个线程去执行任务,每当有一个任务到来的时候都需要创建一个线程去执行。这种方式虽然是可行的,但如果线程并发的数量很多,并且每个线程都是执行一个时间很短的任务就结束掉了,这样频繁的创建线程就会大大降低系统的运行效率,因为频繁的创建和销毁线程需要消耗额外的时间。对于这种情况,我们可以使用线程池技术让线程达到复用的目的。
概述
在《阿里巴巴 Java 开发手册》中的并发处理
部分有这样一条规定:**线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。**这是因为使用线程池可以减少在创建和销毁线程上所消耗的时间以及系统的开销,从而解决资源不足的问题。如果不适用线程池,则有可能造成系统创建大量同类线程而导致消耗内存或者“过度切换”问题。
下面通过两个例子来说明使用线程池和不使用线程池的差异。
不使用线程池:
|
|
输出结果如下:
|
|
使用线程池:
|
|
输出结果如下:
|
|
从以上两个运行结果可以看到,在使用线程池的情况下,执行任务所用的时间更少。从而可以引出使用线程池的好处:
- 降低资源消耗。由于重复利用已创建的线程去执行任务,从而降低了线程创建和销毁造成的消耗。
- 提高响应速度。当某任务到达时,此任务不需要再等到线程创建完之后再执行,而是立即执行此任务。
- 方便管理线程。使用线程池可以对已创建的线程进行统一的分配、调优和监控。
也就是说,在使用线程池的时候,需要根据系统具体的环境情况,分析任务的特性,手动或自动设置线程数目。例如需要考虑是 CPU 密集型任务、I/O密集型任务还是混合型任务,需要考虑任务优先级的高低、任务执行时间的长短、任务是否依赖其它资源环境等。
需要注意的是,如果线程数目设置过少,则系统运行效率不高;如果设置过多,则会占用系统内存较多。线程池在处理任务的时候,只要线程池里有空闲的线程,则该任务就会分配给一个线程执行。如果任务过多,则它们会进入到阻塞队列中,线程池里面的线程会去取该队列里面的任务,进而执行。
ThreadPoolExecutor
java.util.concurrent.ThreadPoolExecutor 类是线程池中最核心的类,在介绍线程池之前,这里先介绍 ThreadPoolExecutor 类的内部结构。ThreadPoolExecutor 一共有 4 个构造函数:
|
|
ThreadPoolExecutor 继承了 AbstractExecutorService 类,通过以上四个构造函数可以看出,前三个构造函数都调用的是第四个构造函数来进行初始化的。以第四个构造参数为例,创建一个线程池需要确定以下几个参数:
- 1. corePoolSize:线程池的基本大小。
- 在创建完线程池之后,默认情况下是没有任何线程的,而是等到有任务来到的时候才开始创建线程去执行任务。
- 除非调用了 prestartAllCoreThreads() 或者 prestartAllCoreThread() 方法进行
预创建线程
,即在没有任务来到之前就创建 corePoolSize 个线程或 1 个线程,这种方式称为“预热”,在抢购系统中经常会用到。 - 也就是说,当提交一个任务到线程池时,线程池会创建一个线程来执行该任务,即使其它空闲的线程能够执行该任务,但也是会创建新线程的,等到需要执行的任务数量大于线程池基本大小时就不会再创建。
- 2. maximumPoolSize:线程池允许创建的最大线程数量。
- 如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。
- 3. keepAliveTime:表示线程没有任务执行时,最多保持多久时间才会终止。
- 默认情况下,只有当线程池中的线程数量大于 corePoolSize 时,keepAliveTime 才会起作用,直到线程池中的线程数量不大于 corePoolSize。
- 也就是说,如果线程池中的线程数大于 corePoolSize 时,如果一个线程空闲的时间达到 keepAliveTime 时,则会终止。
- 但如果调用了 allowCoreThreadTimeOut(boolean)方法,并且线程池中的线程数量不大于 corePoolSize 的话,那么参数 keepAliveTime 同样会起作用,直到线程池中的线程数量为 0 。
- 4. unit:用于指定 keepAliveTime 的时间单位。可选值如下:
- 天:TimeUnit.DAYS
- 小时:TimeUnit.HOURS
- 分钟:TimeUnit.MINUTES
- 秒:TimeUnit.SECONDS
- 毫秒:TimeUnit.MILLISECONDS
- 微妙:TimeUnit.MICROSECONDS
- 纳秒:TimeUnit.NANOSECONDS
- 5. workQueue:阻塞队列。用来存储等待执行的任务。有以下几种等待队列:
- ArrayBlockingQueue:基于数组的有界阻塞队列,按照 FIFO(先进先出)原则对元素进行排序;
- LinkedBlockingQueue:基于链表的阻塞队列,按照 FIFO(先进先出)原则对元素进行排序,吞吐量高于 ArrayBlockingQueue;
- SynchronousQueue:该队列不会保存提交的任务,而是直接新建一个线程来执行新来的任务;
- PriorityBlockingQueue:具有优先级的无界阻塞队列。
- **6. threadFactory:线程工厂。**用来创建线程。
- 7. handler:表示当拒绝处理任务时的策略。当队列和线程池都满了,说明线程池处于饱和状态,那么需要采取一种策略来处理提交的新任务。有以下四种取值,它们均属于 ThreadPoolExecutor 里面的静态内部类:
- ThreadPoolExecutor.AbortPolicy:表示无法处理新任务时则丢弃新任务并抛出异常,属于默认的拒绝策略;
- ThreadPoolExecutor.DiscardPolicy:表示丢弃任务,但不抛出异常;
- ThreadPoolExecutor.DiscardOldestPolicy:表示丢失队列里最前面的任务,即最近的一个任务,然后重新尝试执行当前任务;
- ThreadPoolExecutor.CallerRunsPolicy:表示由调用者所在的线程来处理任务。
下图给出了 ThreadPoolExecutor 类的继承关系。可以看出,ThreadPoolExecutor 类继承了 AbstractExecutorService 类,而 AbstractExecutorService 类实现了 ExecutorService 接口,最后 ExecutorService 接口继承了 Executor 接口。
ThreadPoolExecutor 类中向线程池提交任务的方法以及关闭线程的方法如下所示:
|
|
其中 execute() 和 submit() 方法用于向线程池提交任务的方法,其区别在于:
- execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功;
- submit() 方法用于提交需要返回值的任务,线程池会返回一个 Future 类型的对象,通过这个 Future 类型的对象可以判断任务是否执行成功。
而 shutdown() 和 shutdownNow() 方法用于关闭线程池。基本原理是:通过遍历线程池中的工作线程,逐个调用 interrupt() 方法来中断线程,所以无法响应中断的线程可能会一直执行下去,无法终止。它们的区别在于:
- shutdownNow() 方法首先将线程池中的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表;
- shutdown() 方法只是将线程池的状态设置成 SHUTDOWN 状态,然后中断全部没有正在执行的任务的线程。
线程池实现原理
线程池的状态
首先需要介绍的就是线程池的生命周期,也就是线程池的几种状态,如下所示:
|
|
各状态解释如下:
- 当创建线程池后,也就是初始阶段,线程池处于 RUNNING 状态,表示可接收新任务并且能够处理队列中的任务;
- 当调用了 shutdown() 方法后,则线程池会处于 SHUTDOWN 状态,此时线程池不能接收新的任务,但可以处理队列中的任务;
- 当调用了 shutdownNow() 方法后,线程池会从 RUNNING 或 SHUTDOWN 状态转变为 STOP 状态,此时线程池不能接收新的任务,不能处理队列中的任务以及中断进行中的任务;
- 当队列和线程池都为空时,线程池会从 SHUTDOWN 状态进入 TIDYING 状态,此时所有的任务已经终止运行,有效的线程数量为 0,并且将会调用 terminated() 方法;
- 如果仅有线程池为空,则会从 STOP 状态转变为 TIDYING 状态;
- 当 terminated() 方法执行完以后,线程池将会从 TIDYING 状态转变为 TERMINATED 状态。
任务的执行
有一些比较重要的参数需要说明,如下所示:
|
|
COUNT_BITS
参数与线程池状态有关,对于线程池的每个状态,都是通过将 -1、0、1、2、3 向左移 COUNT_BITS 个位置,也就是说使用的是高位的信息。其中Integer.SIZE
为 32,因此COUNT_BITS
的值为 29,CAPACITY
的值为 536870911。
注意这里的 corePoolSize、maximumPoolSize 以及 largestPoolSize 三个参数。这里的corePoolSize
就是线程池的基本大小,默认为 0。maximumPoolSize
表示池中允许的最大线程数,如果池中的线程数量大于corePoolSize
,则会将任务添加到队列 workQueue 中,如果 workQueue 队列满了,但线程数小于maximumPoolSize
,则会添加新的线程来处理被添加的任务。largestPoolSize
用于记录线程池中曾经有过的最大线程数目。
为了弄清楚 corePoolSize 和 maximumPoolSize 之间的关系,以下给出几种场景,这同时也是线程池执行的流程:
- 池中线程数小于 corePoolSize,则新任务不排队,而是直接创建新线程;
- 池中线程数大于等于 corePoolSize,workQueue 没满,则将新任务加入到 workQueue 而不是创建新线程;
- 池中线程数大于等于 corePoolSize,workQueue 满了,但线程数量小于 maximumPoolSize,则添加新的线程来处理被添加的任务;
- 池中线程数大于等于 corePoolSize,workQueue 满了,并且线程数量大于等于 maximumPoolSize,则新任务会被拒绝,使用 handler 处理被拒绝的任务。
如下图所示:
注意:以上的池中线程数
具体指的就是workerCount
,详细的解释可以通过 execute 方法了解到。而以上四种场景就是对 execute 方法的小结,如下所示:
|
|
Executors
虽然上面介绍了 ThreadPoolExecutor 类,但 Java doc 中推荐使用的是 Executors 类中的三个静态方法,这三个不同的方法根据不同的使用场景已经预定义了一些设置,如下所示:
1.newSingleThreadExecutor():创建单线程的线程池
|
|
可以看到,这种创建线程池的方式已经将 corePoolSize 和 maximumPoolSize 都设置为 1,并且使用的队列也是无界的 LinkedBlockingQueue,即不管有多少任务都需要排队,等到前面一个任务执行完毕,才能执行队列中的线程。
newSingleThreadExecutor 主要适用于需要保证顺序执行(FIFO)各个任务,并且在任意时间点,不会有多个线程是活动的应用场景。
2.newFixedThreadPool(int nThreads):创建固定大小的线程池
|
|
固定大小的线程池,当有新的任务提交时,线程池中如果有空闲线程,则立即执行。否则新的任务会被缓存在一个任务队列中,等待线程池释放空闲线程。
这里的 corePoolSize 和 maximumPoolSize 两个参数需要手动指定,并且队列类型适用的也是 LinkedBlockingQueue。由于 keepAliveTime 表示当线程数量大于 corePoolSize 时,多余的空闲线程在终止前等待新任务的最大时间,由于设置为 0,这意味着多余的空闲线程会被立即执行。newFixedThreadPool 主要适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场景,它适用于负载比重的服务器。
需要注意的是,由于使用了无界队列 LinkedBlockingQueue(队列的容量为 Integer.MAX_VALUE),则会对线程池带来如下的影响:
- 1.当线程池中的线程数量达到 corePoolSize 后,新任务将会在无界队列中等待,因此线程池中的线程数量不会超过 corePoolSize;
- 2.由于 1 的原因,使用无界队列时,参数 maximumPoolSize 将是无效的;
- 3.由于 1 和 2 的原因,使用无界队列时,参数 keepAliveTime 将是无效的;
- 4.由于使用无界队列,运行中的 FixedThreadPool 不会拒绝任务。
3.newCachedThreadPool():无界线程池
|
|
线程池的大小不固定,可灵活回收空闲线程,若无可回收,则新建线程。
无界线程池的 corePoolSize 被设置为 0,maximumPoolSize 被近似地设置为整数的最大值,keepAliveTime 被设置为 60 秒。这种方式就是说,不管提交了多少任务,都直接运行。
无界线程池采用了没有容量的 SynchronousQueue,这意味着如果主线程提交任务的速度高于 maximumPoolSize 中的线程处理速度时,CachedThreadPool 会不断的创建线程,即只要添加进去的线程就会被拿去用。极端情况下,CachedThreadPool 会因为创建过多的线程而耗尽 CPU 和内存资源。
需要注意的是:单线程线程池和固定大小的线程池都不会进行自动回收,而无界线程池设置了回收时间,由于 corePoolSize 为 0,所以只要 60 秒内没有被使用到的线程都会被直接移除。
4.newScheduledThreadPool():定时线程池
表示定时线程池,支持定时以及周期性任务的执行。注意:与其他方法不同的是,newScheduledThreadPool() 使用 ScheduledThreadPoolExecutor() 进行实现。而其他的方法使用 ThreadPoolExecutor() 进行实现。
|
|
最后,在《阿里巴巴 Java 开发手册》中不推荐不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式可以让程序员更加明确线程池的运行规则,避免资源耗尽的风险。也就是说,如果选择使用 Executors 提供的工厂类,将会忽略很多线程池的参数设置,工厂类一旦选择设置默认的参数,就不容易调优参数设置,从而产生性能问题或者浪费资源。
Executors 返回的线程池对象的弊端如下:
- FixedThreadPool 和 SingleThreadPool:
- 允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
- CachedThreadPool 和 ScheduledThreadPool:
- 允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
如何设置线程池参数?
总的来说,具体问题具体分析。由于环境是多变的,因此不可能设置一个绝对精确的线程数,但可以通过一些实际操作因素来计算出一个合理的线程数,避免由于线程池设置不合理导致的性能问题。
下面分为CPU 密集型
和IO 密集型
这两种类型来考虑:
对于CPU 密集型
来说,可以将线程数设置为CPU 核心数 + 1
。比 CPU 核心数多一个线程的目的是:为了防止线程偶尔发生的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,在这种情况下多出的一个线程就可以充分利用 CPU 的空闲时间。
对于IO密集型
来说,系统的大部分时间用来进行 IO 交互,而线程在处理 IO 的时间段内不会占用 CPU 来处理,这时可以将 CPU 交出给其它线程使用。因此在 IO 密集型任务中,可以多配置一些线程,即2 * CPU 核心数
。
而对于平常的应用场景,可以通过以下方法来计算线程数:
$$ 线程数= N * ((1 + WT) / ST) $$
其中,N 表示 CPU 核心数,WT 表示线程等待时间,ST 表示线程运行时间。
因此,从整体上看,我们可以根据不同的业务场景,从N+1
和2*N
中选择一个合适的计算方式,计算大概的线程数量,然后通过实际的压测,逐渐往“增大线程数量”和“减少线程数量”两个方面进行调整,从而最终确定一个比较理想的线程数。
顺便一提的是,通过查看《Java线程池实现原理及其在美团业务中的实践》一文发现,还可以将线程池的参数进行动态化。并且给出了三种设计方案:
- 简化线程池的配置:线程池的核心参数有 3 个,即 corePoolSize、maximumPoolSize 以及 workQueue。而考虑到实际的应用场景,可以分为两类:
- 一种是并行执行子任务,提高响应速度的场景。该场景下应该使用同步队列,没有什么任务应该被缓存下来,而是应该立即执行。
- 另一种是执行大批次任务,提升吞吐量的场景。该场景下,应该使用有界队列去缓冲大批量的任务,并且队列的容量必须声明,防止任务无限制的堆积。
- 参数可动态修改:为了解决大多数情况下参数不易配置、参数修改成本高等缺点,在 Java 线程池的高扩展性的基础上,实现对线程池封装。也就是允许线程池监听同步外部的消息,根据消息进行修改配置。
- 对于该方式,可以通过 ThreadPoolExecutor 类中的 setter 方法,对参数进行调整。
- 增加线程池监控:也就是在线程池执行任务的生命周期中添加监控能力,帮助开发人员了解线程状态。
- 对于该方式,可以通过 ThreadPoolExecutor 类中的 getter 方法读取到当前线程池的运行状态以及参数。
参考
- https://www.cnblogs.com/xrq730/p/4856453.html
- https://blog.csdn.net/u011974987/article/details/51027795
- https://crossoverjie.top/2018/07/29/java-senior/ThreadPool/
- https://www.cnblogs.com/dolphin0520/p/3932921.html
- https://blog.csdn.net/fuyuwei2015/article/details/72758179
- https://github.com/crossoverJie/JCSprout/blob/master/docs/thread/thread-gone2.md
- https://juejin.im/entry/58fada5d570c350058d3aaad
- https://cloud.tencent.com/developer/article/1632213