3.实战java高并发程序设计--JDK并发包---3.2

3.2 线程复用:线程池

首先,虽然与进程相比,线程是一种轻量级的工具,但其创建和关闭依然需要花费时间,如果为每一个小的任务都创建一个线程,则很有可能出现创建和销毁线程所占用的时间大于该线程真实工作所消耗的时间的情况,反而会得不偿失。

其次,线程本身也是要占用内存空间的,大量的线程会抢占宝贵的内存资源,如果处理不当,可能会导致Out of Memory异常。即便没有,大量的线程回收也会给GC带来很大的压力,延长GC的停顿时间。

3.2.1 什么是线程池

为了避免系统频繁地创建和销毁线程,我们可以让创建的线程复用。如果大家进行过数据库开发,那么对数据库连接池应该不会陌生。为了避免每次数据库查询都重新建立和销毁数据库连接,我们可以使用数据库连接池维护一些数据库连接,让它们长期保持在一个激活状态

线程池也是类似的概念。在线程池中,总有那么几个活跃线程。当你需要使用线程时,可以从池子中随便拿一个空闲线程,当完成工作时,并不急着关闭线程,而是将这个线程退回到线程池中,方便其他人使用。简而言之,在使用线程池后,创建线程变成了从线程池获得空闲线程,关闭线程变成了向线程池归还线程,如图3.6所示。

3.2.2 不要重复发明轮子:JDK对线程池的支持Executor

为了能够更好地控制多线程,JDK提供了一套Executor框架,帮助开发人员有效地进行线程控制,其本质就是一个线程池,它的核心成员如图3.7所示。

以上成员均在java.util.concurrent包中,是JDK并发包的核心类。其中,ThreadPoolExecutor表示一个线程池。Executors类则扮演着线程池工厂的角色,通过Executors可以取得一个拥有特定功能的线程池。从UML图中亦可知,ThreadPoolExecutor类实现了Executor接口,因此通过这个接口,任何Runnable的对象都可以被ThreadPoolExecutor线程池调度。

● newFixedThreadPool()方法:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理任务队列中的任务。固定数量线程池

● newSingleThreadExecutor()方法:该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。单个线程线程池

● newCachedThreadPool()方法:该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。可自动扩充数量线程池

● newSingleThreadScheduledExecutor()方法:该方法返回一个ScheduledExecutorService对象,线程池大小为1。ScheduledExecutorService接口在ExecutorService接口之上扩展了在给定时间执行某任务的功能,如在某个固定的延时之后执行,或者周期性执行某个任务。可指定时间的单个线程线程池

● newScheduledThreadPool()方法:该方法也返回一个ScheduledExecutorService对象,但该线程池可以指定线程数量。可指定数量线程池

1.固定大小的线程池

2.计划任务

另外一个值得注意的方法是newScheduledThreadPool()。它返回一个ScheduledExecutorService对象,可以根据时间需要对线程进行调度。它的一些主要方法如下:

与其他几个线程池不同,ScheduledExecutorService并不一定会立即安排执行任务。它其实是起到了计划任务的作用。它会在指定的时间,对任务进行调度。

作为说明,这里给出了三个方法。方法schedule()会在给定时间,对任务进行一次调度。方法scheduleAtFixedRate()和方法scheduleWithFixedDelay()会对任务进行周期性的调度,但是两者有一点小小的区别,如图3.8所示。

对于FixedRate方式来说,任务调度的频率是一定的。它是以上一个任务开始执行时间为起点,在之后的period时间调度下一次任务。而FixDelay方式则是在上一个任务结束后,再经过delay时间进行任务调度。

注意:这里还想说一个有意思的事情,如果任务的执行时间超过调度时间会发生什么情况呢?比如,这里调度周期是2秒,如果任务的执行时间是8秒,是不是会出现多个任务堆叠在一起呢?实际上,ScheduledExecutorService不会让任务堆叠出现。

周期如果太短,那么任务就会在上一个任务结束后立即被调用

注意:如果任务遇到异常,那么后续的所有子任务都会停止调度,因此,必须保证异常被及时处理,为周期性任务的稳定调度提供条件。

3.2.3 刨根究底:核心线程池的内部实现

对于核心的几个线程池,无论是newFixedThreadPool()方法、newSingleThreadExecutor()方法,还是newCachedThreadPool()方法,虽然看起来创建的线程有着完全不同的功能特点,但其内部实现均使用了ThreadPoolExecutor类。下面给出了这三个线程池的实现方式:

注意:使用自定义线程池时,要根据应用的具体情况,选择合适的并发队列作为任务的缓冲。当线程资源紧张时,不同的并发队列对系统行为和性能的影响均不同。

3.2.4 超负载了怎么办:拒绝策略

ThreadPoolExecutor类的最后一个参数指定了拒绝策略。也就是当任务数量超过系统实际承载能力时,就要用到拒绝策略了。拒绝策略可以说是系统超负荷运行时的补救措施,通常由于压力太大而引起的,也就是线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列中也已经排满了,再也放不下新任务了。这时,我们就需要有一套机制合理地处理这个问题。

JDK内置的拒绝策略如下。

● AbortPolicy策略:该策略会直接抛出异常,阻止系统正常工作。

● CallerRunsPolicy策略:只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。

● DiscardOldestPolicy策略:该策略将丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。

● DiscardPolicy策略:该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,我觉得这可能是最好的一种方案了吧!

以上内置的策略均实现了RejectedExecutionHandler接口,若以上策略仍无法满足实际应用的需要,完全可以自己扩展RejectedExecutionHandler接口。RejectedExecutionHandler的定义如下:

下面的代码简单地演示了自定义线程池和拒绝策略的使用。

3.2.5 自定义线程创建:ThreadFactory

之前我们介绍过,线程池的主要作用是为了线程复用,也就是避免了线程的频繁创建。但是,最开始的那些线程从何而来呢?答案就是ThreadFactory。

自定义线程池可以帮助我们做不少事。比如,我们可以跟踪线程池究竟在何时创建了多少线程,也可以自定义线程的名称、组以及优先级等信息,甚至可以任性地将所有的线程设置为守护线程。总之,使用自定义线程池可以让我们更加自由地设置线程池中所有线程的状态。下面的案例使用自定义的ThreadFactory,一方面记录了线程的创建,另一方面将所有的线程都设置为守护线程,这样,当主线程退出后,将会强制销毁线程池。

3.2.6 我的应用我做主:扩展线程池

虽然JDK已经帮我们实现了这个稳定的高性能线程池,但如果我们需要对这个线程池做一些扩展,比如,监控每个任务执行的开始时间和结束时间,或者其他一些自定义的增强功能,这时候应该怎么办呢?一个好消息是:ThreadPoolExecutor是一个可以扩展的线程池。它提供了beforeExecute()、afterExecute()和terminated()三个接口用来对线程池进行控制

ThreadPoolExecutor.Worker是ThreadPoolExecutor的内部类,它是一个实现了Runnable接口的类。ThreadPoolExecutor线程池中的工作线程也正是Worker实例。Worker.run()方法会调用上述ThreadPoolExecutor.runWorker(Worker w)实现每一个工作线程的固有工作。

在默认的ThreadPoolExecutor实现中,提供了空的beforeExecute()和afterExecute()两个接口实现。在实际应用中,可以对其进行扩展来实现对线程池运行状态的跟踪,输出一些有用的调试信息,以帮助系统故障诊断,这对于多线程程序错误排查是很有帮助的。下面演示了对线程池的扩展,在这个扩展中,我们将记录每一个任务的执行日志。

上述代码第23~40行扩展了原有的线程池,实现了beforeExecute()、afterExecute()和terminiated()三个方法。这三个方法分别用于记录一个任务的开始、结束和整个线程池的退出。第42~43行向线程池提交5个任务,为了有更清晰的日志,我们为每个任务都取了名字。第43行使用execute()方法提交任务,细心的读者一定发现,在之前的代码中,我们都使用了submit()方法提交。有关两者的区别,我们将在“5.5 Future模式”中详细介绍。

在提交完成后,调用shutdown()方法关闭线程池。这是一个比较安全的方法,如果当前正有线程在执行,shutdown()方法并不会立即暴力地终止所有任务,它会等待所有任务执行完成后,再关闭线程池,但它并不会等待所有线程执行完成后再返回,因此,可以简单地理解成shutdown()方法只是发送了一个关闭信号而已。但在shutdown()方法执行后,这个线程池就不能再接受其他新的任务了。

3.2.7 合理的选择:优化线程池线程数量

线程池的大小对系统的性能有一定的影响。过大或者过小的线程数量都无法发挥最优的系统性能,但是线程池大小的确定也不需要做得非常精确,因为只要避免极大和极小两种情况,线程池的大小对系统的性能并不会影响太大。确定线程池的大小需要考虑CPU数量、内存大小等因素。在Java Concurrency in Practice一书中给出了估算线程池大小的公式:

Ncpu = CPU的数量

Ucpu =目标CPU的使用率,0≤Ucpu≤1

W/C = 等待时间与计算时间的比率

W/C = 等待时间与计算时间的比率为保持处理器达到期望的使用率,最优的线程池的大小等于:在Java中,可以通过如下代码取得可用的CPU数量。

3.2.8 堆栈去哪里了:在线程池中寻找堆栈

大家一定还记得在上一章中,我们详解介绍了一些幽灵般的错误。我想,码农的痛苦也莫过于此了。多线程本身就非常容易引起这类错误。如果你使用了线程池,那么这种幽灵错误可能会变得更加常见。下面来看一个简单的案例,首先,我们有一个Runnable接口,它用来计算两个数的商。

因此,使用线程池虽然是件好事,但是还是得处处留意这些“坑”。线程池很有可能会“吃”掉程序抛出的异常,导致我们对程序的错误一无所知

我的一个领导曾经说过:“最鄙视那些出错不打印异常堆栈的行为!”我相信,任何一个得益于异常堆栈而快速定位问题的程序员,一定都对这句话深有体会。这里我们将和大家讨论向线程池讨回异常堆栈的方法。

一种最简单的方法就是放弃submit()方法,改用execute()方法。将上述的任务提交代码改成

注意了,我这里说的是部分。这是因为从这两个异常堆栈中我们只能知道异常是在哪里抛出的(这里是DivTask的第11行)。但是我们还希望得到另外一个更重要的信息,那就是这个任务到底是在哪里提交的?而任务的具体提交位置已经被线程池完全淹没了。顺着堆栈,我们最多只能找到线程池中的调度流程,而这对于我们几乎是没有价值的。

既然这样,我们只能自己动手,丰衣足食啦!为了今后少加几天班,非常有必要将堆栈的信息彻底挖出来!扩展我们的ThreadPoolExecutor线程池,让它在调度任务之前,先保存一下提交任务线程的堆栈信息:

在第23行代码中,wrap()方法的第2个参数为一个异常,里面保存着提交任务的线程的堆栈信息。该方法将我们传入的Runnable任务进行一层包装,使之能处理异常信息。当任务发生异常时,这个异常会被打印。

3.2.9 分而治之:Fork/Join框架

Fork一词的原始含义是吃饭用的叉子,也有分叉的意思。在Linux平台中,方法fork()用来创建子进程,使得系统进程可以多一个执行分支。在Java中也沿用了类似的命名方式。

而join()方法的含义在之前的章节中已经解释过,这里表示等待。也就是使用fork()方法后系统多了一个执行分支(线程),所以需要等待这个执行分支执行完毕,才有可能得到最终的结果,因此join()方法就表示等待。

在实际使用中,如果毫无顾忌地使用fork()方法开启线程进行处理,那么很有可能导致系统开启过多的线程而严重影响性能。所以,在JDK中,给出了一个ForkJoinPool线程池,对于fork()方法并不急着开启线程,而是提交给ForkJoinPool线程池进行处理,以节省系统资源。使用Fork/Join框架进行数据处理时的总体结构如图3.11所示。帮忙的线程从底部拿,这样就避免了数据竞争

 

发布了24 篇原创文章 · 获赞 1 · 访问量 3414

猜你喜欢

转载自blog.csdn.net/ashylya/article/details/104332476