前言
今天来聊聊面试高频知识点,同时也是工作中经常会使用到的线程池。本文还是作为Disruptor导读。
为什么要使用线程池
只有先理解为什么会出现线程池才能更好地学习线程池,试想在没有线程池之前,我们想要使用多个线程来执行多个程序会怎么做呢?当然是一个个new线程出来,让它们分别执行程序,那么这么做有什么不好吗?
弊端1:如果现在要处理的业务场景非常复杂,也就是说需要new大量的线程去执行,如1000个线程同时执行,我们知道CPU是稀有资源,多个线程同时去争夺CPU都想要先执行自己线程中的程序,CPU频繁进行上下文切换,因此大部分时间都花在了切换上下文上,真正执行的时间并没有多少。
弊端2:多个线程的创建和销毁需要浪费大量时间以及系统资源,与此同时线程单独创建后不方便管理。
线程池
基于以上问题,我们急需一个可以统一管理线程调度,以及线程生命周期的“大管家”出现,线程池应运而生,
类比数据库连接池,因为有了数据库连接池,每次在操作数据库的时候不需要去重新建立连接,而是直接去池中获取一个可用连接,而在关闭连接时,并不是真的把连接关闭,而是将这个连接还给连接池。
线程池亦是如此,当你需要创建线程时不是去一个个new而是从线程池中获取一个可执行的线程,使用完毕后直接归还给线程池即可,至于何时销毁由线程池自己控制。接下来我们来重点看下JAVA中线程池,首先来介绍关于线程池的几个接口以及实现类。
最顶层的接口为Executor,接着是ExecutorService接口它继承了父接口Executor,我们来思考下为什么要这么设计呢?其实很简单,我们来看下源码:
可以看到Executor接口中只有一个方法即execute,我们只需要关注2点第一这个方法不带返回值,第二方法中传入的参数需要为Runnable类型,接着我们来看ExecutorService。
很明显在ExecutorService中提供了更为丰富的方法,我们并没有找到刚才父类的execute方法,execute中需要传入Runnable,我们发现ExecutorService中除了有和execute一样需要传入Runnable的方法外,还有传入Callable类型的submit,最为关键的是他们都有返回类型,什么意思呢?一句话概括就是execute执行完毕后没有返回值,而submit带有返回值。那这个返回值有什么意义呢?如果我同时开始多个线程去执行ForkJoin,假设某个线程执行超时或阻塞,如果你使用execute()将不会返回执行异常的线程,而使用submit()可以显式返回信息,这样对我们排查问题具有极大帮助。
因此,我们在真实使用线程池时一定用的是ExecutorService,接着ExecutorService接口的实现类又分为2大类,可以简单理解为一类是用于ForkJoin处理,一类用于scheduled任务调度。
线程池的分类与使用
现在,我们基本上对线程池中的接口和实现类有了了解,接下来,我们就重点来看看线程池如何使用。在这之前我们还需要认识一个“主角”即Executors,大家可能会感到疑问,之前不是有一个Executor吗?怎么又出来一个s结尾的,其实如果你对集合熟悉的话一定知道Arrays、Collections,它们作为数组、集合的工具类来辅助我们更好的操作集合,同理这里的Executors也可以理解为是Executor工厂类,可以从方法注释中看到这一点。
话不多说,我们马上来看Executors中为我们提供了哪些好玩的方法,别看方法众多,而我们常用的就是我箭头标出来的5种,它们就是用于帮助我们来创建各式各样的线程池,接下来帮大家用代码来实际演示下都如何使用。
newFixedThreadPool:创建固定处理线程池,也就是说在创建的时候就需要制定线程池中存放线程的个数。
从结果中我们可以看出,线程池中的5个线程都执行了处理任务,需要注意的是结果中的顺序不是唯一的,可能线程1执行了2个或3个任务,很好论证这一点,我把sleep时间去掉,我们再来看下结果。为什么会这样呢?很简单,举个例子银行中现在有5个办理业务窗口,现在这10个人就是需要办理业务的顾客,由于一号窗口业务员业务水平比较高,因此它在办理完第一位顾客的业务后,就立马可以办理第二位顾客的业务,因此会出现连续办理了两位顾客业务的情况,而此时其他窗口的业务员才办理完一位顾客的业务,这样就会出现下图的情况。
newSingleThreadExecutor:一个线程池中只有一个处理线程,从它的线程名字Single就可以看出它是单例。因此它在创建的时候肯定不需要指定线程个数。
从结果中我们可以看到不管有多少需要处理的任务,总是由一个线程自己处理。
newCachedThreadPool:线程池中线程的数量不固定,会根据实际情况自己调节线程个数,那它在创建的时候需要制定线程个数吗?当然不需要!因为你都无法确定它的个数我们通过例子来解释:
结果中有5个线程在处理10个任务,我们再来执行一次,又变成了10个线程处理10个任务,这是怎么回事呢?这就是之前提到的会自动调节线程数,如果线程1还没有处理完当前任务,任务2进来后就需要新创建一个线程去执行任务2,如果线程1可用的话就可以继续处理任务2,而不需要创建线程2。
newScheduledThreadPool:根据时间对线程进行调度,我个人觉得该类线程池还是挺好用的,但是大多线程池文章并没有讲,只是说了下上面的三种,本文为大家讲解用法,需要注意的是这个线程池和其他几个线程池不同的是它并不一定会立即执行任务,而是在指定时间内进行任务调度,这也符合它的名字和功能。
四个箭头表示了该线程池的和其他线程池不同的地方,首先它需要指定一个核心线程数(下面会说到这里知道就好),第二它的返回类型和前3个都不一样,它返回的是ScheduledExecutorService类型,这又是什么呢?看下源码,哦原来它实现了ExecutorService接口,然后封装了自己专门做调度的方法。
接着看第三点executorService调用的方法需要实现一个Runnable接口,这里采用匿名内部类的方式,此处调用的是scheduleAtFixedRate,其实它还有一个其他处理调度的方式,我们一会来对比先来看上面例子的执行结果。从175开始每隔2s执行下一次任务,此例可以理解为计时器每隔2s执行。
这里有个问题就是如果任务执行的时间超过我们设置的延迟时间2s(每隔2s执行)。会出现什么异常吗?比如阻塞任务,为了验证我们的想法,来讲任务的执行时间模拟为8s,根据结果我们可以看到不会对结果造成影响,即下一次执行和上一次执行隔8s(上一次任务执行完毕就执行下一条任务)。
同样的,我们再来介绍一下另外一个调度的方法,还是给它设置为8s,看下效果。很明显这次是10s后才执行下一条任务,现在这2个方法的区别就显而易见了。
newWorkStealingPool:创建一个带并行级别的线程池,并行级别决定了同一时刻最多有多少个线程在执行,如不传如并行级别参数,将默认为当前系统的CPU个数。下面用代码来体现这种并行的限制,从结果中可以看到,同一时刻只有两个线程执行。
结果如下可以看到每个CPU线程都并行处理各自任务,因此该类线程池适合处理ForkJoin并行计算类的任务,关于ForkJoin在之前的文章有介绍。
线程池参数设置
介绍完如何使用线程池后我们就看下线程池底层源码,这里就以newFixedThreadPool为例来剖析。
我们发现它的具体实现是ThreadPoolExecutor,然后它需要传入5个参数,先不管参数代表什么,我们接着来瞧瞧ThreadPoolExecutor。
这时我们不难看出底层实现都是通过ThreadPoolExecutor,它里面5大参数?其实不然请看它的this方法显然是七大参数,那么为什么在ThreadPoolExecutor传入参数的时候是五个呢?别急我们先来分别介绍下这些参数都是干嘛的。为了更加形象的理解这七大参数(线程池重点),我这里用一个日常生活的例子来说明。
上图是我画的一个草图,模拟了我们去饭店吃饭的场景,现在我们假设今天是周一,大家都去上班了所以相对来说饭店人会少点,1-4号桌坐满了,5、6号桌都空着,现在来了2波人,我们假设每张桌子可以坐4位顾客,第一波人被安排在了5号桌,第二波人被安排在了6号桌,请注意现在1-6号桌都满了,如果再来顾客的话就只能到等待区等待。如下图所示:
第三波来了2位顾客,这时1-6桌又满了,那怎么办呢?很简单让他两去等待区等一会,假设等待区D1-D4代表四个座位,现在这2位顾客就占据了等待区的D1、D2位置进行等待,由于这家店生意太火爆了,再加上到了中午,前来消费的人越来越多,这时候第四波人来了。
还是一样之前的1-6桌都爆满,所以第四波来的这2人,还是得到等待区等待,由于第三波的2人已经占据了D1、D2这2个座位,因此第四波的2位顾客只能坐在D3、D4等待,现在的情况是1-6号桌都满了,而等待区的4个位置也满了,那再来人咋办呢?首先我们来想一个问题,为什么1-6号桌一直没有消费完,最主要的原因可能是上菜太慢了,这点相信大家都深有体会,这时经理就给4号大厨打电话,让他过来做饭,本来4号大厨今天休息,虽然他很不爽但是没办法,于是4号大厨来了。
由于等待区也爆满了因此用红色表示,4号大厨在拼命炒菜,此时又来了第五波人(这里就不画了)。第五波人来了一看1-6桌都满,本来想着去等待区等等,没想到等待区也满了,正在犹豫徘徊时,经理来了告诉他们不好意思了大家,今天本店爆满,暂时没有位置等待了,喜欢吃我们家的菜下次再来吧(拒绝第五波人等待),于是第五波人离开了。终于1号桌的顾客吃完饭结账离开了,这时等待区的D1、D2(第三波人)被安排去1号桌就餐,D3、D4继续等待,因为第三波等待时间最长所以优先去1号桌就餐。过了一会3号桌的顾客也结账离开了,于是D3、D4这2位顾客就去3号桌就餐,如下图所示:
等待区现在为空, 1-6号桌全满,1-4号大厨在炒菜,接着5、6号桌的顾客陆续结账离开,就剩下1-4号桌有人吃饭,中午的吃饭高峰期已过,1-4号桌的菜都上齐了,4号大厨现在也不需要炒菜了,于是4号大厨等了半个小时看经理接下来有何吩咐,经理一看高峰期过去了,就算来人有1-3号大厨也可以满足,于是告诉4号大厨回家休息,并表示会在月底发加班费???到此为止,例子就说完了,接着我们再来看线程池的7大参数
corePoolSize:核心线程数 -> 对应例子中最初1-3号大厨
maximumPoolSize:线程池中最大线程数 -> 对应例子中1-4号大厨(厨房最多容纳4位大厨)
keepAliveTime:线程最大存活时间 -> 对应例子中4号大厨等待了半个小时
TimeUnit:时间工具类
BlockingQueue:等待任务队列 -> 对应例子中的等待区
defaultThreadFactory:默认生产线程工厂 -> 对应例子中厨房
defaultHandler:默认拒绝策略 -> 对应例子中经理拒绝第五波人
用通俗的例子讲解完之后我们再来看看各种xxx书中描述的线程池工作原理图,大家看是否会好理解一些。
1表示任务进来后首先去核心线程池corePool,如果核心线程池中没有可用的线程,就进入第2步去BlockingQueue队列进行等待,如果队列也满了(黑色圆表示被占用),就进行第3步(叫四号大厨)让maxPool中除corePool外的其他线程处理,如果maximumPoolSize也没有可用线程就执行第4步拒绝策略,其中拒接策略有分为四种。
AbortPolicy:默认拒接策略,队列满了丢任务抛出异常
DiscardPolicy:队列满了丢任务不异常
DiscardOldestPolicy:将最早进入队列的任务删除,之后再尝试加入队列
CallerRunsPolicy:如果添加到线程池失败,那么主线程会自己去执行该任务
在下面我们用代码演示它们之间的区别。
传统线程池存在的问题
相同通过前面的介绍,大家现在对好多中线程池的使用都有了一定理解,但是我要告诉大家的是在真正开发中以上几种线程池一个都不用,这是为什么?因为它可能会导致JVM OOM,这是怎么回事呢?分析下ThreadPoolExecutor源码我们发现问题出现在BlockingQueue这参数上,不管你是有界的阻塞队列ArrayBlockingQueue还是无界的LinkedBlockingQueue(不是真正的无界可存放Integer.MAX_VALUE个),我们知道阻塞队列有个特性就是当队列是空的时,从队列中获取元素的操作将会被阻塞,或者当队列是满时,往队列里添加元素的操作会被阻塞。试图从空的阻塞队列中获取元素的线程将会被阻塞,直到其他的线程往空的队列插入新的元素。同样,试图往已满的阻塞队列中添加新元素的线程同样也会被阻塞,直到其他的线程使队列重新变得空闲起来。
刚开始的时候其实线程池里是空的,就是一个线程都没有的,如下图所示。
接着如果你使用线程池提交一个任务进去,希望由线程池里的一个线程来执行,即向线程池中投递一个任务
这个时候,线程池会先看一下,现在池子里的线程数量有没有有达到corePoolSize指定的数量。现在线程池里的线程数量是0,假设corePoolSize是6,那么肯定没达到了,所以直接会在线程池里创建一个线程出来然后执行这个任务
接着假如说,这个线程处理完一个任务了,那么此时线程是不会被销毁的,他会一直等待下一个提交过来的任务。那么,到底是怎么等待的呢?
很简单,线程池会搭配一个workQueue,比如这里搭配的一个无界的LinkedBlockingQueue,几乎可以无限量放入任务。
然后那个线程处理完一个任务之后,就会用阻塞的方式尝试从任务队列里获取任务,如果队列是空的,他就会阻塞卡在那儿不动,直到有人放一个任务到队列里,他才会获取到一个任务然后继续执行,循环往复,如下图:
接着再次提交任务,线程池一判断发现,诶?好像线程数量才只有1个,完全比corePoolSize(6个)要小,那么继续直接在池子里创建一个线程,然后处理这个任务,处理完了继续尝试从workQueue里阻塞式获取任务。一直重复上面的操作,直到线程池里有6个线程了,达到了corePoolSize指定的数量,如下图:
这个时候你如果再提交任务,他一下子发现,诶?不对啊,线程池里已经有6个线程了,跟corePoolSize指定的线程数量一样了。
那么现在,我就不需要创建任何一个额外的线程了,现在你只要提交任务,全部直接入队到workQueue里就好。
此时线程池里的线程都阻塞式在workQueue上等待获取任务,有一个任务进来就会唤醒一个线程来处理这个任务,处理完了任务再次阻塞在workQueue上尝试获取下一个任务,如下图所示:
我们知道如果还想让线程池为我们执行任务,就需要启动4号厨师。假设此时maximumPoolSize的数量是10呢?那么就会继续创建线程,直到线程数量达到10个,然后用额外创建的4个线程在队列满的情况下,继续处理任务。整个过程,如下图所示:红色部分表示maximumPoolSize-corePoolSize个线程
接着万一队列满了,然后线程池的线程数量达到了maximumPoolSize指定的数量了,你额外创建线程都无法创建了,此时会如何呢?通过上面的介绍我们知道会直接执行拒绝策略,默认会抛出异常。
那么,在上图中额外创建出来的,超出corePoolSize的那些线程呢?
他们一旦创建出来之后,会发现线程池数量已经超过corePoolSize了,此时他们会尝试等待workQueue里的任务。
一旦超过keepAliveTime指定的时间,还获取不到任务,比如keepAliveTime是60秒,那么假如超过30秒获取不到任务,他就会自动释放掉了,这个线程就销毁了。整个过程,如下图所示:
其实这和我们上面的4号厨师例子一样,他等待了一会没有顾客来了就可以回家休息了,是一个意思。
我们以最常用的fixed线程池举例,他的线程池数量是固定的,因为他用的是近乎于无界的LinkedBlockingQueue,几乎可以无限制的放入任务到队列里。
所以只要线程池里的线程数量达到了corePoolSize指定的数量之后,接下来就维持这个固定数量的线程了。
然后,所有任务都会入队到workQueue里去,线程从workQueue获取任务来处理。
只要队列不满,就跟maximumPoolSize、keepAliveTime这些没关系了,因为不会创建超过corePoolSize数量的线程的即不会叫4号厨师。
那么此时万一每个线程获取到一个任务之后,他处理的时间特别特别的长,长到了令人发指的地步。比如处理一个任务要几个小时,此时会如何?即1-6号桌吃饭池了3个小时,等待区的人又爆满,再来顾客的话整个饭店会瘫痪。
当然会出现workQueue里不断的积压越来越多得任务,不停的增加。
这个过程中会导致机器的内存使用不停的飙升,最后也许极端情况下就导致JVM OOM了,系统就挂掉了。明白了这一点我们就知道原始的线程池不可靠需要我们自定义线程池来满足需要。
自定义线程池
提阻塞系数和其他参数如何设置值,我们先来看整体设计,我们知道线程池主要实现是通过ThreadPoolExecutor,因此我们这里通过new的方式直接创建出来,然后将前面提到的7个参数设置进去,需要注意的是LinkedBlockingQueue,我这里设置了容量为3,这里设置之后才不会让大量任务堆积造成OOM,现在我们来看下同时提交5个任务会有什么结果。
很明显没有达到线程池中的maximumPoolSize,我们直接用corePoolSize中的2个线程就可以处理,接着我们改为提交8次任务
可以看到结果用到了5个线程处理这8个任务,也就是说达到了maximumPoolSize,同时阻塞队列我们设置的3也达到了上限,也就是说现在4号厨师也在炒菜,D1-D4等待区也满了,达到了饭店的最大饱和人数。
接着,我们再次提交任务,将8个改为9个,可以看到下图直接报错,因此我们的拒绝策略使用的就是默认的,所以如果超过负荷的话就会直接报错。也即饭店经理告诉第五波人别进来了,满员了!
前面提到的四种阻塞策略大家可以自行通过这个例子试验一下。现在我们来看下阻塞系数这些,在正在放到生产环境中时,我们一定要通过压测和服务器硬件配置以及业务场景设置。
如你的业务场景为CPU密集型即你的业务是进行大量的运行,如云计算、大数据等也就是没有阻塞,CPU一直在高速运转(参考你自己的服务器)比较考验你的服务器运算能力。
这种情况下设置线程池数一般遵循CPU核数+1
如果你的业务是IO密集型,分2种第一种IO密集型不是一直在处理业务数据,这个时候可以适当多配置一些线程,如线程数为:CPU*2
第二种可以简单理解为频繁操作数据库或缓存如Redis,这种操作会需要大量的IO,也就是大量阻塞,因此需要设置它的阻塞参数,参照公式:线程数 = CPU核数 / (1 - 阻塞系数),其中阻塞系数一般设置为0.8~0.9之间。当然具体的设置都需要通过反复测试才能够设置合理参数。
总结
本文通过大量图解以及例子来解释线程池原理以及如何实现自定义线程池,接下来的文章会对线程池、各种锁、阻塞队列、生产消费问题一一介绍,这样在后面介绍使用DIsruptor的时候就会好理解很多。