前言
上一章我们介绍了Java
中的Thread类
里一些常用的方法。本节我们就来聊一聊线程池
。
说到“池”,大家或许都不陌生,在java
中,我们有见过数据库连接池,Java常量池,对象池等等,将实体进行“池化”,这种“池化”思想,有助于我们对实体进行统一的管理,监控和调用
。
本章的主要内容有:
- 创建线程池
- 构造方法的参数解读
- 四种功能性线程池
- 关闭线程池
作为经常被面试的一个模块,线程池的概念不是那么通俗易懂,但在实际开发应用中,线程池对程序性能优化有着不可磨灭的贡献。
线程池
线程池效率对比
首先,我们来对比下面两个程序运行的效率。程序一,使用前面我们学过的一般线程:
1 | import java.util.ArrayList; |
简单理解就是创建100000个线程来对list
添加数据,最后输出添加所需的总时间和list
的大小。我们来看运行结果:
程序二,使用线程池(不理解的小伙伴们可以先跳过往下看):
1 | import java.util.ArrayList; |
运行结果:
从耗时来看,使用
线程池
所需的时间比使用一般线程所需的时间足足减少了100多倍!由此看来,线程池
对于程序的优化有着重大的意义。
实际上,从原理上理解,使用一般的线程,都需要经历创建,使用,销毁
三个步骤,当创建的线程数非常大的时候,这种操作对内存的消耗比较大,导致效率低下。因此我们希望创建好的线程能够在指定时间内继续执行其他的任务
,通过减少创建和销毁线程的消耗,以此来提高效率。
类比“银行办理业务排队”
其实,线程池概念的提出与我们的生活有密切关系(许多算法,概念的提出都能够在生活中找到对应的例子)。
如果要理解线程池
,咱就必须提到“银行办理业务排队”的场景逻辑,这是一个非常非常非常
典型的例子,请大家务必看懂,对线程池
的理解非常有帮助:
去过银行办理业务的朋友们都知道,银行里有多个窗口来办理业务,此外还有等候区供人们休息。我们现在假设有这样一个场景 :
如上图,假设现在某银行有3个窗口(1,2,3号),2个备用窗口(4,5号),和可供3人休息的等候区。
现在有1人来办理业务, 这1个人带着“任务1”去了1号窗口办理业务:
接着,第2,3个人也来办理业务,他们带着任务2,3分别去了2,3号窗口:
这时,如果银行来了第4个人,他只能去等候区等候,第5,6个人亦是如此:
此时,等候区人数已满,如果再来第7个人,银行行长只能开启备用窗口-----4号窗口,并让还在等候区等候的第4个人到4号窗口办理业务,等候区(队列)往前“挪一个”使得第7个人能够进入等候区:
同理,如果再来第8个人,那就只能开启第二个备用窗口-----5号窗口,并让第5个人到5号窗口办理业务,等候区(队列)往前“挪一个”使得第8个人能够进入等候区:
这时,不论是窗口数,还是等待区容量,都已经满了,如果来了第9个人,怎么办呢?
对于第9个人,银行只能采取拒绝的方式,因为就当前情况,不管是窗口,还是等候区,都容不下第9个人了。
如何理解线程池?
上述的这个简单的银行排队流程,我们把它搬到线程里,描述如下:
线程池
(银行)里有最多5个线程数,有3个是核心线程
(1,2,3号窗口),另外2个是备用线程
(4,5号窗口,或者叫非核心线程),当核心线程全被使用后,就将多余的任务以队列的形式放入“任务队列
”(等候区)中,如果任务队列也满了,就开启备用线程
(非核心线程),如果备用线程也全部被使用了,那么剩下多余的任务,就只能拒绝执行了。
理解完上述过程,理解线程池,就轻松多了。在Java中,线程池的真正实现类是ThreadPoolExecutor
,翻阅源码,它有如下几种构造方法:
线程池的七个参数
上面四种构造方法中,最多的构造方法有七个参数,我们来看看这七个参数的具体含义:
corePoolSize (必需) :
核心线程数
(类比银行的1,2,3号窗口)。默认情况下,核心线程会一直存活,除非将allowCoreThreadTimeout
设置为true
,这样超时后,核心线程也会被回收。
maxmumPoolSize
(必需) :最大线程数
(类比银行的所有窗口)。对于非核心线程(4,5号窗口),在下面的keepAliveTime
设定的时间超过之后,会被回收。同样的,将allowCoreThreadTimeout
设置为true
的话,这样超时后,核心线程也会被回收。
keepAliveTime
(必需) : 如上,设定非核心线程的闲置时间,超时后,非核心线程会被回收。
unit
(必需) : 指定上面keepAliveTime
参数的单位,常用的有:
TimeUnit.MILLISECONDS(毫秒)
TimeUnit.SECONDS(秒)
TimeUnit.MINUTES(分)
workQueue
(必需) : 任务队列(类比银行的等待区)。通过线程池中的execute()
方法提交的Runnable对象将存储在该对象中。一般使用阻塞队列
。
threadFactory
(可选) : 线程工厂-----指定新线程创建的方式,自定义ThreadFactory
的话可以修改线程名,线程组,优先级,是否为守护线程等等,如果不想自定义,使用默认的**Executors.defaultThreadFactory()
**即可。**
handler
(可选) : ** 当线程池创建的线程数达到最大值时,需要执行的拒绝策略
。拒绝策略
需要实现RejectedExecutionHandler
接口,并重写rejectedExecution(Runnable r , ThreadPoolExecutor executor)
方法。
Executors框架为我们提供了四种常见的拒绝策略:
- 1.AbortPolicy (默认) :丢弃任务并抛出 RejectedExecutionException 异常
- 2.CallerRunsPolicy :丢给调度线程处理该任务
- 3.DiscardPolicy :丢弃任务但不抛出异常。一般用于自定义处理模式。
- 4.DiscardOldestPolicy :丢弃队列最早的未处理完的任务,然后尝试执行新任务。
请注意: 虽然指定了核心线程数和最大线程数,但是当线程池被创建后,线程不会立即创建,其会根据任务队列中是否有新任务要执行来实时地创建
。
ThreadPoolExecutor
下面我们来认识一下 ThreadPoolExecutor 这个类,来看源码:
首先,最底层是一个函数式接口(只有一个抽象方法
) Executor :
接着有一个叫 ExecutorService 的接口继承了 Executor ,其扩展了一些方法,如 isShutdown() , shutdown() , awaitTermination()
等等:
然后,有一个叫 AbstractExecutorService 的类实现了 ExecutorService ,提供了一些方法的实现:
最后, 咱 ThreadPoolExecutor 类继承了 AbstractExecutorService ,并实现了 execute() 等重要的方法:
现在,我们来看一个简单的程序:
1 | import java.util.concurrent.*; |
上述程序中,通过for循环
产生了100个任务,请大家细细体会 ThreadPoolExecutor()
构造方法中的7个参数如何取定。
如果已有任务数超过了线程池的最大线程数与任务队列容量之和,线程池就会执行拒绝策略
,默认为上述第一种拒绝策略。比如将上述代码中的线程池创建参数—任务队列修改如下:
1 | new ArrayBlockingQueue<>(49) |
则会抛出 RejectedExecutionException 异常:
任务队列
七个参数中,大家比较模糊的是 workQueue ---- 任务队列
。
下面我们来看看 workQueue
如何取定。任务队列是基于阻塞队列
实现的,采用的是生产者-消费者模式
,在Java中需要实现 BlockingQueue 接口,当然我们可以自定义实现类,但JDK
已经为我们提供了7种阻塞队列的实现类,我们简单的介绍其中最常用的三种:
ArrayBlockingQueue :一个由顺序表结构组成的有界(需指明容量)阻塞队列,
LinkedBlockingQueue :一个由链表结构组成的阻塞队列。可以指明容量,未指明容量时,默认为无界(
Integer.MAX_VALUE
).SynchronousQueue :一个不存储任何元素的同步阻塞队列。
请注意有界队列
与无界队列
的区别:如果使用有界队列,当已有任务数超过了线程池的最大线程数
与该队列容量
之和后就会执行拒绝策略;而如果使用无界队列,队列容量无限大,已有任务数不可能超过该队列容量,所以设置 maxmunPoolSize
没有意义。
封装的线程池
基于 ThreadPoolExecutor 的七个参数值的不同设定,Executors类 (Executor接口
的工具类)给我们封装了几个常用的创建线程池的方法:
可缓存线程池**(**CachedThreadPool)
- 特点:无核心线程,非核心线程无限大,线程闲置60s后被回收,任务队列为不存储任何元素的同步阻塞队列
- 适用场景:
执行大量且耗时的操作
定长线程池**(**FixedThreadPool)
- 特点:只有核心线程,线程一旦闲置立即被回收,任务队列为链表结构的无界阻塞队列
- 适用场景:
需要控制线程最大并发数的地方
定时线程池**(**ScheduledThreadPool)
- 特点:核心线程固定,线程闲置10ms后被回收,任务队列为延时阻塞队列
- 适用场景:
执行定时或周期性的任务
单线程化线程池**(**SingleThreadExecutor)
- 特点:核心线程固定为1个,没有非核心线程,线程一旦闲置立即被回收,任务队列为链表结构的无界阻塞队列
- 适用场景:
串行执行所有任务
建议
总结起来,虽然使用Executors框架
的4个功能线程池非常的方便,但是现在已经不建议使用了,而是采用最原始的方式:通过 ThreadPoolExecutor 来手动创建。
原因有两点:
使用原始的 ThreadPoolExecutor 可以使我们更加明确线程池的运行机制,减少对资源的浪费。
使用上述四种线程池还有自己的弊端:
FixedThreadPool & SingleThreadExecutor :由于任务队列可以为无界队列,当任务过多时,可能会导致
OOM
(内存溢出)。CachedThreadPool & ScheduledThreadPool :由于最大线程数为无限大,当线程创建过多时,可能会导致
CPU
利用率接近100%。
关闭线程池
最后,我们来简单地介绍一下如何关闭线程池
。
在介绍如何关闭线程池之前,我们来看看线程池的五个状态:
我们来简单认识一下这五种状态:
RUNNING
特点:线程池处在 RUNNING 状态时,能够接收新任务,能够执行已添加的任务。
- 线程池一旦被创建,就处于 RUNNING 状态,并且线程池中的任务数为0。
SHUTDOWN
特点:线程池处在 SHUTDOWN 状态时,不接收新任务,但能处理已添加的任务。
- 调用线程池的shutdown()方法时,线程池状态转变:RUNNING --> SHUTDOWN。
STOP
特点:线程池处在 STOP 状态时,不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。
- 调用线程池的 shutdownNow()方法 时,线程池状态转变:( RUNNING or SHUTDOWN ) --> STOP。
TIDYING
特点:当所有的任务都已中止或结束后,ctl记录的“任务数量”为0,线程池会变为 TIDYING 状态。
- 当线程池在 SHUTDOWN 状态下,任务阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN 变为 TIDYING。
- 当线程池在 STOP 状态下,线程池中执行的任务为空时,就会由 STOP 变为 TIDYING。
TERMINATED
特点:线程池彻底终止,就变成TERMINATED状态。
- 线程池处在 TIDYING 状态时,执行完terminated()方法后,就会由 TIDYING 变为 TERMINATED。
接着,查阅源码,我们可以看到,JDK在ThreadPoolExecutor类中提供了几种关闭线程池的方法,源码如下:
状态解读
从上述源码结合前面学的知识可以发现:
- 当线程池创建以后,初始时,线程池处于 RUNNING 状态,此时线程池中的任务为0;
- 如果调用 shutdown() 方法,则线程池变为 SHUTDOWN 状态,此时线程池不能够接受新 的任务,它会等待所有任务执行完毕;
- 如果调用 shutdownNow() 方法,则线程池处于 STOP 状态,此时线程池不能接受新的任务,并且会去尝试终止正在执行的任务;
- 当所有的任务已中止或结束后,“任务数量”为0,线程池会变为 TIDYING 状态。接着会执行 terminated() 函数。
- 线程池处在 TIDYING 状态时,执行完 terminated() 之后,线程池就被设置为TERMINATED状态。
其实,关于多线程,JDK中提供的Thread类和ThreadPoolExecutor类中还有许多我们可以学习的东西,如果小伙伴们感兴趣的话可以去翻阅有关源码和资料。