深入多线程面试连环炮【第二弹】

大鱼仙人

共 5429字,需浏览 11分钟

 ·

2021-12-17 18:14

在这之前的几篇文章:



1、什么是线程池


线程的创建和销毁是一个“重”操作,所以我们需要避免线程频繁地创建与销毁,因此我们需要缓存一批线程,让它们时刻准备着执行任务

 

目标已经很清晰了,弄一个池子,里面存放约定数量的线程,这就是线程池,一种池化技术


如果线程数太少无法充分利用 CPU ,太多的话由于上下文切换的消耗又得不偿失,所以我们需要评估系统所要承载的并发量和所执行任务的特性,得出大致需要多少个线程数才能充分利用 CPU,因此需要控制线程数量


多线程技术主要解决处理器单元内多个线程执行的问题,它可以显著减少处理器单元的闲置时间,增加处理器单元的吞吐能力

   

假设一个服务器完成一项任务所需时间为:T1 创建线程时间,T2 在线程中执行任务的时间,T3 销毁线程时间

 

如果:T1 + T3 远大于 T2,则可以采用线程池,以提高服务器性能

 

线程池技术正是关注如何缩短或调整T1,T3时间的技术,从而提高服务器程序性能的。它把T1T3分别安排在服务器程序的启动和结束的时间段或者一些空闲的时间段,这样在服务器程序处理客户请求时,不会有T1T3的开销了


线程池不仅调整T1,T3产生的时间段,而且它还显著减少了创建线程的数目


2、线程池优点,为什么要使用线程池

new Thread 缺点


每次new Thread新建对象性能差


线程缺乏统一管理,可能无限制新建线程,相互之间竞争,及可能占用过多系统资源导致死机或oom


缺乏更多功能,如定时执行、定期执行、线程中断


为什么要用线程池


减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。


可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内存,而把服务器累趴下(每个线程需要大约1MB内存,线程开的越多,消耗的内存也就越大,最后死机)

 

ThreadPool优点


减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务

 

可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为因为消耗过多的内存,而把服务器累趴下(每个线程需要大约1MB内存,线程开的越多,消耗的内存也就越大,最后死机)

 

减少在创建和销毁线程上所花的时间以及系统资源的开销


如不使用线程池,有可能造成系统创建大量线程而导致消耗完系统内存



3、常用的线程池


第1种是:固定大小线程池,特点是线程数固定,使用无界队列,适用于任务数量不均匀的场景、对内存压力不敏感,但系统负载比较敏感的场景

 

第2种是:Cached线程池,特点是不限制线程数,适用于要求低延迟的短期任务场景

 

第3种是:单线程线程池,也就是一个线程的固定线程池,适用于需要异步执行但需要保证任务顺序的场景

 

第4种是:Scheduled线程池,适用于定期执行任务场景,支持按固定频率定期执行和按固定延时定期执行两种方式

 

第5种是:工作窃取线程池,使用的ForkJoinPool,是固定并行度的多任务队列,适合任务执行时长不均匀的场景


4、听说过Executors吗


Java里面线程池的顶级接口是Executor,但是严格意义上讲Executor并不是一个线程池,而只是一个执行线程的工具。真正的线程池接口是ExecutorService



Executors是一个工具类,类里面提供了一些静态工厂,生成一些常用的线程池

 

Executors提供四种线程池

 

newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程

 

newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待

 

newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行


newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行

 

一般都不用Executors提供的线程创建方式,使用ThreadPoolExecutor创建线程池


5、那你说说为什么阿里巴巴不建议使用Executors静态工厂构建线程池

在阿里巴巴Java开发手册中提到,使用Executors创建线程池可能会导致OOM(OutOfMemory ,内存溢出),真正的导致OOM的其实是LinkedBlockingQueue.offer方法

 

底层是通过LinkedBlockingQueue实现的, LinkedBlockingQueue是一个用链表实现的有界阻塞队列,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE

 

问题就出在:不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。也就是说,如果我们不设置LinkedBlockingQueue的容量的话,其默认容量将会是Integer.MAX_VALUE

 

对于一个无边界队列来说,是可以不断的向队列中加入任务的,这种情况下就有可能因为任务过多而导致内存溢出问题

 

避免使用Executors创建线程池,主要是避免使用其中的默认实现,那么我们可以自己直接调用ThreadPoolExecutor的构造函数来自己创建线程池。在创建的同时,给BlockQueue指定容量就可以了


6、线程池核心参数有哪些

第1个参数:设置核心线程数。默认情况下核心线程会一直存活

 

第2个参数:设置最大线程数。决定线程池最多可以创建的多少线程

 

第3个参数和第4个参数:用来设置线程空闲时间,和空闲时间的单位,当线程闲置超过空闲时间就会被销毁。可以通过AllowCoreThreadTimeOut方法来允许核心线程被回收

 

第5个参数:设置缓冲队列,图中左下方的三个队列是设置线程池时常使用的缓冲队列

 

其中Array Blocking Queue是一个有界队列,就是指队列有最大容量限制。Linked Blocking Queue是无界队列,就是队列不限制容量。最后一个是Synchronous Queue,是一个同步队列,内部没有缓冲区

 

第6个参数:设置线程池工厂方法,线程工厂用来创建新线程,可以用来对线程的一些属性进行定制,例如线程的Group、线程名、优先级等。一般使用默认工厂类即可

 

第7个参数:设置线程池满时的拒绝策略

 

ThreadPoolExecutor默认有四个拒绝策略:


ThreadPoolExecutor.AbortPolicy() 直接抛出异常RejectedExecutionException,这个是默认的拒绝策略


ThreadPoolExecutor.CallerRunsPolicy() 直接在提交失败时,由提交任务的线程直接执行提交的任务


ThreadPoolExecutor.DiscardPolicy() 直接丢弃后来的任务


ThreadPoolExecutor.DiscardOldestPolicy() 丢弃在队列中最早提交的任务


7、线程池的工作原理


我们向线程提交任务时可以使用Execute和Submit,区别就是Submit可以返回一个Future对象,通过Future对象可以了解任务执行情况,可以取消任务的执行,还可获取执行结果或执行异常。Submit最终也是通过Execute执行的

 

线程池提交任务时的执行顺序如下:

 

向线程池提交任务时,会首先判断线程池中的线程数是否大于设置的核心线程数,如果不大于,就创建一个核心线程来执行任务

 

如果大于核心线程数,就会判断缓冲队列是否满了,如果没有满,则放入队列,等待线程空闲时执行任务


如果队列已经满了,则判断是否达到了线程池设置的最大线程数,如果没有达到,就创建新线程来执行任务


如果已经达到了最大线程数,则执行指定的拒绝策略。这里需要注意队列的判断与最大线程数判断的顺序,不要搞反

 

如果你提交任务时,线程池队列已满,这时会发生什么?

 

如果你使用的LinkedBlockingQueue,也就是无界队列的话,没关系,继续添加任务到阻塞队列中等待执行,因为LinkedBlockingQueue可以近乎认为是一个无穷大的队列,可以无限存放任务

 

如果你使用的是有界队列比方说ArrayBlockingQueue的话,任务首先会被添加到ArrayBlockingQueue中,ArrayBlockingQueue满了,则会使用拒绝策略RejectedExecutionHandler处理满了的任务,默认是AbortPolicy


8、高并发、任务执行时间短的业务怎样使用线程池?并发不高、任务执行时间长的业务怎样使用线程池?并发高、业务执行时间长的业务怎样使用线程池?

高并发、任务执行时间短的业务,线程池线程数可以设置为CPU核数+1,减少线程上下文的切换

 

并发不高、任务执行时间长的业务要分情况来讨论

 

假如是业务时间长集中在IO操作上,也就是IO密集型的任务,因为IO操作并不占用CPU,所以不要让所有的CPU闲下来,可以加大线程池中的线程数目,让CPU处理更多的业务

 

假如是业务时间长集中在计算操作上,也就是计算密集型任务,这个就没办法了,线程数设置为CPU核数+1,线程池中的线程数设置得少一些,减少线程上下文的切换

 

并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,参考上面的设置即可


最后,业务执行时间长的问题,也可能需要分析一下,看看能不能使用中间件对任务进行拆分和解耦


9、听说过ThreadLocal吗


看我的这一篇介绍


10、简单介绍下阻塞队列吧

阻塞队列是一个在队列基础上又支持了两个附加操作的队列

 

支持阻塞的插入方法:队列满时,队列会阻塞插入元素的线程,直到队列不满。支持阻塞的移除方法:队列空时,获取元素的线程会等待队列变为非空



阻塞队列的应用场景

 

阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。简而言之,阻塞队列是生产者用来存放元素、消费者获取元素的容器

 

1、ArrayBlockingQueue 数组结构组成的有界阻塞队列

 

此队列按照先进先出(FIFO)的原则对元素进行排序,但是默认情况下不保证线程公平的访问队列,即如果队列满了,那么被阻塞在外面的线程对队列访问的顺序是不能保证线程公平(即先阻塞,先插入)的。

 

2、LinkedBlockingQueue一个由链表结构组成的有界阻塞队列,此队列按照先出先进的原则对元素进行排序

 

3、PriorityBlockingQueue支持优先级的无界阻塞队列

 

4、DelayQueue支持延时获取元素的无界阻塞队列,即可以指定多久才能从队列中获取当前元素

 

5、SynchronousQueue不存储元素的阻塞队列,每一个put必须等待一个take操作,否则不能继续添加元素。并且支持公平访问队列。

 

6、LinkedTransferQueue由链表结构组成的无界阻塞TransferQueue队列。相对于其他阻塞队列,多了tryTransfertransfer方法

 

transfer方法

 

如果当前有消费者正在等待接收元素(take或者待时间限制的poll方法),transfer可以把生产者传入的元素立刻传给消费者。如果没有消费者等待接收元素,则将元素放在队列的tail节点,并等到该元素被消费者消费了才返回

 

tryTransfer方法

 

用来试探生产者传入的元素能否直接传给消费者。,如果没有消费者在等待,则返回false。和上述方法的区别是该方法无论消费者是否接收,方法立即返回。而transfer方法是必须等到消费者消费了才返回

 

7、LinkedBlockingDeque链表结构的双向阻塞队列,优势在于多线程入队时,减少一半的竞争

结束语


感谢大家能够做我最初的读者和传播者,请大家相信,只要你给我一份爱,我终究会还你们一页情的。


Captain会持续更新技术文章,和生活中的暴躁文章,欢迎大家关注【Java贼船】,成为船长的学习小伙伴,和船长一起乘千里风、破万里浪


哦对了,后续所有的文章都会更新到这里


https://github.com/DayuMM2021/Java




浏览 50
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报