Java线程池初探
1. 为什么要用线程池?
多核CPU时代,为更好利用资源以获取更高的性能,多线程编程早已普通应用。手工创建和销毁线程有以下弊端。
- 线程上下文的切换需要JVM和操作系统的参与,若频繁操作势必造成CPU和内存资源的浪费。
举个栗子。若为每个请求都创建线程来处理,当有大量恶意请求到来时,内存轻意就被攻占了。此时你是否会向运维同学央求...
- 程序员需要处理线程的异常状态,如线程因为出错导致异常等。否则,不知哪天用户就会微笑着对你说...
- 良好的软件设计不建议手工创建和销毁线程。
幸运的是,线程池可以提供一条龙服务,帮助我们管理线程的创建、执行和销毁。还能根据系统承受能力,合理利用已有线程,减轻负担。一名话概括:集中管理线程,以期达到收益最大化,风险最小化。
2. 线程池如何使用?
2.1 类结构
- 线程池从JDK1.5版本开始引入,在JDK1.7中线程池框架的核心类是
ThreadPoolExecutor
,其关键类结构如下图:
2.2 ThreadPoolExecutor
构造方法
-
JDK源码中该类的构造方法有4个,但最终都是调用以下构造方法来创建一个线程池。
ThreadPoolExecutor ( int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler )
-
corePoolSize
:核心线程数量。达到该数量后新提交的线程会进入任务队列中排队。 -
maximumPoolSize
:池中允许的最大线程数量。需要与任务队列的类型结合使用。 -
keepAliveTime
:池中线程数量超过corePoolSize
时,空闲线程会在多长时间内被回收。 -
unit
:keepAliveTime
的时间单位。 -
workQueue
:任务队列。它负责暂时保存未被执行的任务。 -
threadFactory
:创建线程的工厂。 -
handler
:拒绝策略,即当提交到池中的线程数量超过其最大容量时,如何拒收。
-
-
看了上面这一本正经的定义,是不是有点懵圈?! 下面用一个春运购票的栗子来说明下。
- 假定春运期间你想要购买K6124次列车。通常情况下该列车有座位1000个(
corePoolSize
)。 - 但春运期间车票异常抢手,初始的1000张票秒光,可怜的你没有抢到。不过12306推出一项“高端服务”:候补。即没买到票的人可以进入一个队列等待,这个队列就是
workQueue
。你选择候补,随即进入该队列等待。考虑到实际情况队列不应过大,假定其大小设为300。 - 随着候补的人起来越多,没多久候补队列也满了。12306充分考虑到了大家回家的迫切需求,决定多加2个车厢来提供额外的200张票。这样就能提总计1200张票
maximumPoolSize
。 - 没过多久,额外提供的票也全部卖出。此时候补列队仍然是满的,售票系统则不再接受新的候补请求(拒绝策略
handler
)。 - 春运结束后票也不太紧张了。若连续5天该车卖出的票数不超过1000张,则当初额外追加的2个车厢就被收回了。这里的5天就相当于
keepAliveTime
,天就是它的单位unit
。
- 假定春运期间你想要购买K6124次列车。通常情况下该列车有座位1000个(
2.3 线程池工厂Executors
如果不想用上面ThreadPoolExecutor
看似复杂的构造方法去创建线程池,可以使用JDK提供的线程池工厂Executors
。它提供了几种常用线程池的创建方法,主要有以下几种。
-
newFixedThreadPool(n)
: 创建固定长度的线程池,任务队列大小为Integer.MAX_VALUE
,即相当于是无界队列。 -
newSingleThreadExecutor
: 创建只有一个线程的线程池,任务队列大小也为Integer.MAX_VALUE
。 -
newScheduledThreadPool
: 创建一个支持定时及周期性任务执行的线程池。它的任务队列采用数组来存储元素,其初始大小为16,但会动态扩容。其maximumPoolSize
为Integer.MAX_VALUE
。 -
newCachedThreadPool
: 创建一个可根据实际情况动态调整线程数量的线程池,任务处理采用不排队直接提交的方式。其maximumPoolSize
为Integer.MAX_VALUE
,空闲线程在60秒内会被自动回收。
3. 线程池内部的原理如何?
3.1 工作流程
当主线程向线程池提交新任务时,工作流程概括如下所示:
- 若池中线程数量小于
corePoolSize
,则创建新的线程来执行此新添加的任务。 - 若池中线程数量大于等于
corePoolSize
且workQueue
还有空间,则将该任务放入队列。 - 若池中线程数量大于等于
corePoolSize
且workQueue
已满,但池中线程数量小于maximumPoolSize
,则会创建新的线程来执行此新添加的任务。 - 若池中线程数量达到了
maximumPoolSize
,则不再授受新任务,并调用拒绝策略来处理。
3.2 任务队列
线程池使用阻塞式的BlockingQueue
来缓存暂时未处理的任务,以生产-消费者模式进行任务的存取,目的是在保证安全并发的前提下提高效率。BlockingQueue
是一种特殊的集合,其主要类图如下:
存取数据操作有3组
-
add/remove
: 添加未能成功抛出异常。 -
offer/poll
: 添加或获取未成功时即刻返回;
此外还提供带超时时间的版本,在指定时间内有限阻塞,若超时仍未成功则返回。 -
put/take
: 添加或获取未成功时,会无限阻塞,直到条件满足。
几种阻塞式队列
-
ArrayBlockingQueue
:内部结构基于数组,生产端和消费端共用一个锁,创建时必须指明队列长度。 -
LinkedBlockingQueue
:内部基于链表,生产端和消费端使用各自独立的锁控制存取。创建时若未指明长度,则默认为Integer.MAX_VALUE
。线程池工厂中的newFixedThreadPool
和newSingleThreadExecutor
就是使用的该队列。 -
SynchronousQueue
: 无缓冲队列,内部只能容纳一个元素。添加元素时后会阻塞,直到元素被取走后才能继续添加。newCachedThreadPool
使用该队列。
3.3 拒绝策略
当线程池不能再授受新任务的提交时,会采用以下几种策略进行处理。
-
AbortPolicy
: 丢弃任务并抛出异常,这也是JDK线程池中的默认策略。 -
CallerRunsPolicy
: 交由调用线程处理该任务。 -
DiscardOldestPolicy
: 丢弃队列头部的任务,重新提交被拒绝的任务。 -
DiscardPolity
: 丢弃任务但不抛出异常,相当于什么也没发生。
4. 灵魂拷问?
- 并发情况下的共享资源访问
多线程应用程序的复杂性会有所提升,尤其是涉及到共享资源的访问时,如若处理不当则系统出BUG的概率将上升。竞态下的共享资源访问需要用到锁机制来确保程序运行的正确性,这又是一个可以探讨的话题。
- 使用线程池工厂的注意事项
JDK中线程池工厂可以帮助我们快速创建线程池,使用虽然很便利,但我们要清楚地知道每种线程池背后的原理。如newFixedThreadPool
,其线程池容量是有限的,但其任务队列却是无界的。如果提交的任务过多,可能会造成任务列队快速增长,最终导致内存溢出。
又如newCachedThreadPool
,其使用的是无缓冲的任务队列,但其maximumPoolSize
却是Integer.MAX_VALUE
(相当于是无限的)。如果任务处理的速度远远落后于任务提交的速度,同样可能会因为创建过多线程而导致内存溢出。