JAVA并发体系-1.4-线程池
在任何线程池中,现有线程在可能的情况下,都会被自动复用。
引言
合理利用线程池能够带来三个好处
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。但是要做到合理的利用线程池,必须对其原理了如指掌。
继承体系
线程池的继承体系:
todo: 更改这个图(即不使用截图)
执行器Executor
Executor是启动任务的首选方法,Executor将管理Thread对象,与命令设计模式一样(todo: ??还没有体会到??),他暴露了要执行的单一方法。 Executor提供了一种将“任务提交”与“任务执行”分离开来的机制(解耦)。
public class CachedThreadPool {
public static void main(String[] args){
ExecutorService exec = Executors.newCachedThreadPool(); // 创建
for(int i=0;i<5;i++);
exec.execute(new Liftoff); // 执行
exec.shutdown();// 终止全部
}
}
对shutdown的调用可以防止新任务被提交给这个executor,当前线程(本例main线程)将继续运行在shutdown被调用之前提交的所有任务,这个程序将在executor的所有任务完成之后尽快退出。
线程池
构造方法/核心参数
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
TimeUnit unit, BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory, RejectedExecutionHandler handler)
-
corePoolSize
(线程池的基本大小)(核心线程数量):当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建。
如果调用了线程池的
prestartAllCoreThreads
方法,线程池会提前创建并启动所有基本线程。 -
maximumPoolSize
(线程池最大大小):线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。
值得注意的是如果使用了无界的任务队列这个参数就没什么效果。
-
keepAliveTime
(线程活动保持时间):-
线程池的工作线程空闲后,保持存活的时间。所以如果任务很多,并且每个任务执行的时间比较短,可以调大这个时间,提高线程的利用率。
-
当前线程数大于核心线程数,如果空闲时间已经超过了
keepAliveTime
,那该线程会销毁。todo: ??
-
-
TimeUnit
(线程活动保持时间的单位):可选的单位有天(DAYS),小时(HOURS),分钟(MINUTES),毫秒(MILLISECONDS),微秒(MICROSECONDS, 千分之一毫秒)和毫微秒(NANOSECONDS, 千分之一微秒)。
-
workQueue
(任务队列):用于保存等待执行的任务的阻塞队列。当达到
corePoolSize
的时候,就向该等待队列放入线程信息(默认为一个LinkedBlockingQueue
),运行中的线程属性为:workers,为一个HashSet
;我们的Runnable内部被包装了一层;这个队列默认是一个无界队列(你也可以设定一个有界队列),所以在生产者疯狂生产的时候,需要考虑如何控制的问题。 -
threadFactory
(线程工厂):用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程设置更有意义的名字,Debug和定位问题时非常有帮助。
-
handler
(饱和策略)(拒绝策略):当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务。
关于
workQueue
可以选择以下几个阻塞队列
ArrayBlockingQueue
:是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。LinkedBlockingQueue
:一个基于链表结构的阻塞队列,此队列按FIFO (先进先出) 排序元素,吞吐量通常要高于ArrayBlockingQueue
。静态工厂方法Executors.newFixedThreadPool()
使用了这个队列。SynchronousQueue
:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue
,静态工厂方法Executors.newCachedThreadPool
使用了这个队列。PriorityBlockingQueue
:一个具有优先级得无限阻塞队列。
即:todo: 和上面的区别??
- 同步移交:不会放到队列中,而是等待线程执行它。如果当前线程没有执行,很可能会新开一个线程执行。
- 无界限策略:如果核心线程都在工作,该线程会放到队列中。所以线程数不会超过核心线程数
- 有界限策略:可以避免资源耗尽,但是一定程度上减低了吞吐量
四种
handler
(饱和策略)(拒绝策略)
CallerRunsPolicy
:只用调用者所在线程来运行任务。DiscardOldestPolicy
:丢弃队列里最近的一个任务,并执行当前任务。DiscardPolicy
:不处理,丢弃掉。- 当然也可以根据应用场景需要来实现
RejectedExecutionHandler
接口自定义策略。如记录日志或持久化不能处理的任务。
即:todo: ???和上面4个的区别
- 直接抛出异常
- 使用调用者的线程来处理
- 直接丢掉这个任务
- 丢掉最老的任务
提交任务
使用execute方法、submit 方法,分别对应的是Runnable、Callable,对应的陈述请移步文章线程和任务。
execute工作流程
线程池创建线程时,会将线程封装成工作线程Worker,Worker在执行完任务后,还会无限循环获取工作队列里的任务来执行。
当提交一个新任务到线程池时,线程池的处理流程如下:
- 首先线程池判断基本/核心线程池是否已满?没满,创建一个工作线程来执行任务。满了,则进入下个流程。
- 其次线程池判断工作队列是否已满?没满,则将新提交的任务存储在工作队列里。满了,则进入下个流程。
- 最后线程池判断整个线程池是否已满?没满,则创建一个新的工作线程来执行任务,满了,则交给饱和策略来处理这个任
即:
- 如果运行线程的数量少于核心线程数量,则创建新的线程处理请求
- 如果运行线程的数量大于核心线程数量,小于最大线程数量,则当队列满的时候才创建新的线程
- 如果核心线程数量等于最大线程数量,那么将创建固定大小的连接池
- 如果设置了最大线程数量为无穷,那么允许线程池适合任意的并发数量
todo: 修改这两张图为合适的图
关闭线程池
调用线程池的shutdown
或shutdownNow
方法来关闭线程池,调用这两个方法后均会立即从方法中返回而不会阻塞等待线程池关闭再返回。
-
shutdown
- 实现原理:只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。
- 只会告诉执行者服务它不能接受新任务,但是已经提交的任务将继续运行
- 再向线程池中提交任务,将会抛
RejectedExecutionException
异常
- 再向线程池中提交任务,将会抛
- 无返回值
- 如果线程池的shutdown()方法已经调用过,重复调用没有额外效应
-
shutdownNow
- 实现原理:首先将线程池的状态设置成STOP,然后遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表。
- 由于调用interrupt方法,所以无法响应interrupt中断的任务可能永远无法终止。
- 方法的返回值:对于那些在堵塞队列中等待执行的任务,线程池并不会再去执行这些任务,而是直接返回这些等待执行的任务
shutdownNow
调用时:- 如果线程处于被阻塞状态,那么线程立即退出被阻塞状态,并抛出一个
InterruptedException
异常。 - 如果线程处于正常的工作状态,则interrupt标志位置为true,线程会继续执行不受影响
- 如果线程处于被阻塞状态,那么线程立即退出被阻塞状态,并抛出一个
- 关于interrupt中断,请移步文章终结任务或线程
isShutdown
和isTerminaed
-
只要调用了这两个关闭方法的其中一个,
isShutdown
方法就会返回true。 -
当所有的任务都已关闭后,才表示线程池关闭成功,这时调用
isTerminaed
方法会返回true。
至于我们应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown
来关闭线程池,如果任务不一定要执行完(可能在没有堵塞的时候被突然中断),则可以调用shutdownNow
。
线程池监控
通过线程池提供的参数进行监控。线程池里有一些属性在监控线程池的时候可以使用
taskCount
:线程池需要执行的任务数量。completedTaskCount
:线程池在运行过程中已完成的任务数量。小于或等于taskCount。largestPoolSize
:线程池曾经创建过的最大线程数量。通过这个数据可以知道线程池是否满过。如等于线程池的最大大小,则表示线程池曾经满了。getPoolSize
:线程池的线程数量。如果线程池不销毁的话,池里的线程不会自动销毁,所以这个大小只增不减。getActiveCount
:获取活动的线程数。
通过扩展线程池进行监控。通过继承线程池并重写线程池的beforeExecute
,afterExecute
和terminated
方法,我们可以在任务执行前,执行后和线程池关闭前干一些事情。如监控任务的平均执行时间,最大执行时间和最小执行时间等。这几个方法在线程池里是空方法,如:
protected void beforeExecute(Thread t, Runnable r) { }
常用线程池
newFixedThreadPool(int nThreads)
- 创建有限的线程集,一次性预先执行代价高昂的线程分配,不必为每个任务都固定的付出创建线程的开销。
newCachedThreadPool()
- 在程序执行过程中通常会创建与所需数量相同的线程,然后在他回收旧线程时停止创建新线程。
newSingleThreadExecutor()
- 就像是线程数量为1的
FixedThreadPool
- 对于希望在另一个线程中连续运行的任何事物(长期存活的任务)来说,都是很有用的(例如:策略:监听进入的套接字连接的任务即
serversocket
、运行短任务如更新本地或远程日志的小任务或事件分发线程) - 向
SignalThreadExecutor
提交多个任务会排队;并且SignalThreadExecutor
提供了一种重要的并发保证,其他线程不会被并发调用,这会改变任务的加锁需求,例如:策略:使用SignalThreadExecutor
来运行需要访问文件系统的大量线程,在这种方式下, 不需要再共享资源上处理同步(不会过度使用文件系统)
- 就像是线程数量为1的
Executor创建每一个类型的线程池,都额外含有一个构造方法,在常用构造方法的基础上添加了一个额外参数,即ThreadFactory threadFactory
,例如newSingleThreadExecutor(ThreadFactory threadFactory)
。
此外还有一些有趣的线程池:newWorkStealingPool()
、newSingleThreadScheduledExecutor()
、newScheduledThreadPool(int corePoolSize)
⭐合理配置线程池
要想合理的配置线程池,就必须首先分析任务特性吗,根据不同类型的任务来选择不同的线程池。另外为了避免撑满系统内存,提高系统稳定性和预警能力,应当使用有界队列(有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点,比如几千)。
通过采取任务拆分、不同规模的线程池处理不同类型的任务、有界队列来提高效率。
可以从以下几个角度来进行分析任务特性:
- 任务的性质:CPU密集型任务,IO密集型任务和混合型任务。
- 任务的优先级:高,中和低。
- 任务的执行时间:长,中和短。
- 任务的依赖性:是否依赖其他系统资源,如数据库连接。
针对不同特性的任务,可以采取的措施有:
-
CPU密集型任务配置尽可能少的线程数量,如配置Ncpu+1个线程的线程池(Ncpu:cpu个数)。
-
IO密集型任务则由于需要等待IO操作,线程并不是一直在执行任务,则配置尽可能多的线程,如2*Ncpu。
-
混合型的任务,如果可以拆分,则将其拆分成一个CPU密集型任务和一个IO密集型任务
- 两个任务执行的时间相差不是太大,那么分解后执行的吞吐率要高于串行执行的吞吐率,
- 两个任务执行时间相差太大,则没必要进行分解。
-
优先级不同的任务可以使用优先级队列
PriorityBlockingQueue
来处理。它可以让优先级高的任务先得到执行,需要注意的是如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行。 -
执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。
-
依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,如果等待的时间越长CPU空闲时间就越长,那么线程数应该设置越大,这样才能更好的利用CPU。
参考
- Java编程思想 第四版 中文版 倒数第二章
- 聊聊并发(三)Java线程池的分析和使用