java并发与多线程(五):线程池

1、线程池的好处

线程使应用能够更加充分合理地协调利用CPU、内存、网络、I/O等系统资源。线程的创建需要开辟虚拟机栈、本地方法栈、程序计数器等线程私有的内存空间。在线程销毁时需要回收这些系统资源。频繁地创建和销毁线程会浪费大量的系统资源,增加并发编程风险。另外,在服务器负载过大的时候,如何让新的线程等待或者友好地拒绝服务?这些都是线程自身无法解决的。所以需要通过线程池协调多个线程,并实现类似主次线程隔离、定时执行、周期执行等任务。线程池的作用包括:

(1)利用线程池管理并复用线程、控制最大并发数等。
(2)实现任务线程队列缓存策略和拒绝机制。
(3)实现某些与时间相关的功能,如定时执行、周期执行等。
(4)隔离线程环境。比如,交易服务和搜索服务在同一台服务器上,分别开启两个线程池,交易线程的资源消耗明显要大;因此,通过配置独立的线程池,将较慢的交易服务与搜 索服务 隔离开,避免各服务线程消耗影响。

在连接线程池的基本作用后,我们学习一下线程池是如何创建线程的。首先从ThreadPoolExecutor构造方法讲起,学习如何自定义ThreadFactory和RejectedExecutionHandler,并编写一个最简单的线程池示例。然后,通过分析ThreadPoolExecutor的execute和addWorker两个核心方法,学习如何把任务线程加入到线程池中运行。ThreadPoolExecutor的构造方法如下:


image.png

image.png

第1个参数:corePoolSize表示常驻核心线程数。如果等于0,则任务执行完之后,没有任何请求进入时销毁线程池的线程;如果大于0,即使本地任务执行完毕,核心线程也不会被销毁。这个值的设置非常关键,设置过大会浪费资源,设置过小会导致线程频繁地创建或销毁。

第2个参数:maximumPoolSize表示线程池能够容纳同时执行的最大线程数。从上方实例代码中的第1处来看,必须大于或等于1。如果待执行的线程数大于此值,需要借助第5个参数的帮助,缓存在队列中。如果maximumPoolSize与corePoolSize相等,即是固定大小线程池。

第3个参数:keepAliveTime表示线程池中的线程空间时间,当空闲时间达到keepAliveTime值时,线程就会被销毁,直到只剩下corePoolSize个线程为止,避免浪费内存和句柄资源。在默认情况下,当线程池的线程数大于corePoolSize时,keepAliveTime才会起作用。但是当ThreadPoolExecutor的allowCoreThreadTimeOut变量设置为true时,核心线程超时后也会被收回。

第4个参数:TimeUnit表示时间单位。keepAliveTime的时间单位通常是TimeUnit.SECONDS。

第5个参数:workQueue表示缓存队列。当请求的线程数大于maximumPoolSize时,线程机内BlockingQueue的阻塞队列。后续示例代码中使用的LinkedBlockingQueue是单向链表,使用锁来控制入队和出队的原子性,两个锁分别控制元素的添加和获取,是一个生产消费模型队列。

第6个参数:threadFactory表示线程工厂。它用来生产一组相同任务的线程。线程池的命名是通过给这个factory增加组名前缀来实现的。在虚拟机栈分析时,就可以知道线程任务是由哪个线程工厂产生的。

第7个参数:handler表示执行拒绝策略的对象。当超过第5个参数workQueue的任务缓存区上限的时候,就可以通过该策略处理请求,这是一种简单的限流保护。像某年双十一没有处理好访问流量过载时的拒绝策略,导致内部测试页面被展示出来,使用户手足无措。友好的拒绝策略可以是如下三种:
(1)保存到数据库进行削峰填谷。在空闲时再提取出来执行。
(2)转向某个提示页面。
(3)打印日志。
从代码第2处来看,队列、线程工厂、拒绝处理服务都必须有实例对象,但在实际编程中,很少有程序员对这三者进行实例化,而通过Executors这个线程池静态工厂提供默认实现,那么Executors与ThreadPoolExecutor是什么关系呢?线程池相关类图如图所示:


image.png

image.png

ExecutorService接口继承了Executor接口,定义了管理线程任务的方法。ExecutorService的抽象类AbstractExecutorService提供了submi()、invokeAll()等部分方法的实现,但是核心方法Executor.executor()并没有在这里实现。因为所有的任务都在这个方法里执行,不同实现会带来不同的执行策略,这一点在后续的ThreadPoolExecutor解析时,会一步步地分析。通过Executors的静态工厂方法可以创建三个线程池的包装对象:ForkJoinPool、ThreadPoolExecutor、ScheduledThreadPoolExecutor。Executors核心的方法有五个:

(1)Executors.new.WorkStealingPool: JDK8引入,创建持有足够线程的线程池 支持给定的并行度,并通过使用多个队列减少竞争,此构造方法中把CPU数 量设置为默认的并行度:


image.png

(2)Executors.newCachedThreadPool: maximumPoolSize最大可以至 Integer.MAX_VALUE,是高度可伸缩的线程池,如果达到这个上限,相信没有 任何服务器能够继续工作,肯定会抛出OOM异常。keepAliveTime默认为60 秒,工作线程处于空闲状态,则收回工作线程。如果任务数增加,再次创建出 新线程处理任务。

(3)Executors.newScheduledThreadPool: 线程数最大至Integer.MAX_VALUE, 与上述相同,存在OOM风险。它是ScheduledExecutorService接口家族的实现 类,支持定时及周期性任务执行。相比Timer, ScheduledExecutorService更安 全。功能更强大,与newCacheThreadPool的区别是不回收工作线程。

(4)Executors.newSingleThreadExecutor: 创建一个单线程的线程池,相当于 线程串行执行所有任务,保证按任务的提交顺序依次执行。

(5)Executors.newFixedThreadPool: 输入的参数即是固定线程数,既是核心线 程数也是最大线程数,不存在空闲线程,所以keepAliveTime等于0:


image.png

这里,输入的队列没有指明长度,下面介绍LinkedBlockingQueue的构造方法:


image.png

使用这样的无界队列,如果瞬间请求非常大,会有OOM的风险。除newWorkStealingPool外,其他四个创建方式都存在资源耗尽的风险。

Executors中默认的线程工厂和拒绝策略过于简单,通常对用户不够友好。线程工厂需要做创建前的准备工作,对线程池创建的线程必须明确标识,就像药品的生产批号一样,为线程本身指定有意义的名称和相应的序列号。拒绝策略应该考虑到业务场景,返回相应的提示或者友好地跳转。以下为简单的ThreadFactory示例:


image.png

上述示例包括线程工厂和任务执行体的定义,通过newThread方法快速、统一地创建线程任务,强调线程一定要有特定意义的名称,方便出错时回溯。

如图所示为排查底层公共缓存调用出错时的截图,绿色框采用自定义的线程工厂,明显比蓝色框默认的线程名称拥有更多的额外信息:如调用来源、线程的业务含义,有助于快速定位到死锁、StackOverflowError等问题。


image.png

下面再简单地实现一下RejectdExecutionHandler,实现了接口的rejectedExecution方法,打印出当前线程池状态,源码如下:


image.png

在ThreadPoolExecutor中提供了四个公开的内部静态类:
(1)AbortPolicy(默认):丢弃任务抛出RejectedExecutionException异常。
(2)DiscardPolicy:丢弃任务但是不抛出异常,这是不推荐的做法。
(3)DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中。
(4)CallerRunsPolicy:调用任务的run()方法绕过其线程池直接执行。
根据之前实现的线程工厂和拒绝策略,线程池的相关代码实现如下:


image.png

image.png

image.png

当任务被拒绝的时候,拒绝策略会打印出当前线程池的大小已经达到了maximumPoolSize=2,且队列已满,完成的任务提示已经有1个(最后一行)。

2、线程池源码详解

在ThreadPoolExecutor的属性定义中频繁地用位移运算来表示线程池状态,位移运算是改变当前值的一种高效手段,包括左移与右移,下面从属性定义开始阅读ThreadPoolExecutor的源码:


image.png

image.png

第1处说明,线程池的状态用高3位表示,其中包括了符号位。五中状态的十进制值按从小到大依次排序为:RUNNING < SHUTDOWN < STOP < TIDYING < TERMINATED,这样设计的好吃是可以通过比较值的大小来确定线程池的状态。例如程序中经常会出现isRunning的判断:


image.png

我们都知道Executor接口有且只有一个方法execute,通过参数传入待执行线程的对象。下面分析ThreadPoolExecutor关于execute方法的实现:


image.png

第1处:execute方法在不同的阶段有三次addWorker的尝试动作。
第2处:发生拒绝的理由有两个:(1)线程池为非RUNNING状态(2)等待队列已满。
下面继续分析addWorker方法的源码:


image.png

image.png

image.png

这段代码晦涩难懂,部分地方甚至违反了代码规约,但其中蕴含的丰富的编码知识点值得我们去学习,下面按序号来依次讲解。

第1处,配合循环语句出现的label,类似于goto作用。Label定义时,表虚把标签和冒号的组合语句紧紧相邻定义在循环体之前,否则会编译出错。目的是在实现多重循环时能够快速退出到任何一层。这种做法的出发点似乎非常贴心,但是在大型软件项目中,滥用标签跳转的后果将是灾难性的。示例代码中,在retry下方有两个无线循环,在workerCount加1成功后,直接退出两层循环。

第2处,这样的表达式不利于代码阅读,应该改成:


image.png

第3处,与第1处的标签呼应,AtomicInteger对象的加1操作时原子性的。Break retry表示直接跳出与retry相邻的这个循环体。

第4处,此continue跳转至标签处,继续执行循环。如果条件为假,则说明线程池还处于运行状态,即继续在for(;;)循环内执行。

第5处,compareAndIncrementWorkerCount方法执行失败的概率非常低。即使失败,再次执行时成功的概率也是极高的,类似于自旋锁原理。这里的处理逻辑是先加1,创建失败在减1,这是轻量处理并发线程的方式。如果先创建线程,成功再加1,当发现超出限制后再销毁线程,那么这样的处理方式明显比前者代价要大。

第6处,Worker对象是工作线程的核心类实现,部分源码如下:


image.png

image.png

线程池的相关源码比较精炼,还包括线程池的销毁、任务提取和消费等,与线程状态图一样,线程池也有自己独立的状态转化流程,本节不再展开。总结一下,使用线程池要注意如下几点:
(1)合理设置各类参数,应根据实际业务场景来设置合理的工作线程数。
(2)线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
(3)创建线程或线程池时请指定有意义的线程名称,方便出错时回溯。
线程池不允许使用Executors,而是通过ThreadPoolExecutor的方式创建,这样的处理方式能更加明确线程池的运行规则,规避资源耗尽的风险。

你可能感兴趣的:(java并发与多线程(五):线程池)