多线程面试八连问

一、 线程池

线程池就是采用池化思想来管理线程的工具。

JUC提供了ThreadPoolExecutor体系来帮助我们更方便的管理线程。

线程池继承体系:

二、ThreadPoolExecutor的核心参数

青铜回答:

        包含核心线程数(corePoolSize)、最大线程数(maximumPoolSize)、超时时间(keepAliveTime)、时间单位(unit)、阻塞队列(workQueue)、拒绝策略(handler)、线程工厂(ThreadFactory)。

钻石回答:

        execute()方法执行逻辑:

1.判断线程池的状态,如果不是RUNNING状态,直接执行拒绝策略。

2.如果当前线程数 < 核心线程数,则新建一个线程来处理未提交的任务。

3.如果当前线程数 > 核心线程数且任务队列没有满,则将任务放入阻塞队列等待执行 。

4.如果核心线程数 < 当前线程数 < 最大线程数,且任务队列已满,则创建新的线程执行提交的任务。

5.如果当前线程数 > 最大线程数,且队列已满,则执行拒绝策略拒绝该任务。

王者回答:

        以上执行流程是JUC标准线程提供的执行流程,主要用在CPU密集型场景下。

        像Tomcat、Dubbo这类框架,他们内部的线程池主要是用来处理网络IO任务,所以他们都对          JUC线程池的执行流程进行了调整来支持IO密集型场景使用。

        他们提供了阻塞队列TaskQueue,该队列继承LinkedBlokingQueue,重写了offer()方法来实现

        执行流程的调整。

parent就是所属的线程池对象

1.如果parent为null,直接调用父类的offer()方法入队。

2.如果当前线程数等于最大的线程数,则直接调用父类的offer()方法入队 。

3.如果当前未执行的任务数量小于等于当前线程数量,则直接调用父类的offer()方法入队。

4.如果当前线程数量小于最大线程数量,则直接返回false,然后回到JUC线程池的执行流程。

5.其他情况直接入队。

可以看出当当前线程数量大于核心线程数量时,JUC原生线程池首先是把任务提交至队列等待执行而不是先创建线程执行。

如果Tomcat接收的请求数量大于核心线程数,请求会被放到队列中,等待核心线程处理,这样会降低请求的总体响应速度。

所以Tomcat并没有使用JUC原生线程池,利用TaskQueue的offer()方法巧妙的修改了JUC线程池的执行流程,改写后Tomcat线程池执行流程如下:

1.判断如果当前线程数小于核心线程数,则新建一个线程来处理提交的任务。

2.如果当前线程数大于核心线程数,小于最大线程数,则创建新的线程执行提交的任务。

3.如果当前线程数等于最大线程数,则将任务放入任务队列等待执行。

4.如果队列已满,则执行拒绝策略。

三、什么是阻塞队列?常用的阻塞队列有哪些?

阻塞队列BolckingQueue集成Queue,是我们熟悉的基本数据结构队列的一种特殊类型。

生产:

        add、offer、put这三个方法都是往队列尾部添加元素,区别如下:

        add:不会阻塞,添加成功时返回true,不响应中断,当队列已满导致添加失败时抛出

        IllegalStateException。

        offer:不会阻塞,添加成功时返回true,因队列已满导致添加失败时返回false,不响应中断。

        put:会阻塞会响应中断。

消费:

        take、poll能获取队列头部的第一个元素,区别如下:

        take:会响应中断,会一直阻塞知道取得元素或者当前线程中断。

        poll:会响应中断,会阻塞,阻塞时间参照方法里参数timeout.timeUnit,当阻塞时间到了还没

        取得元素会返回null。

JDK提供的阻塞队列的实现:

1.ArrayBlockingQueue:由数组实现的有界阻塞队列,该队列按照FIFO对元素进行排序。

2.LinkedBlockingQueue:由链表组成的有界阻塞队列,如果不指定大小,默认使用Integer.MAX_VALUE作为队列的大小,该队列按照FIFO对元素进行排序。

3.SynchronousQueue:不存储元素的阻塞队列,无容量,可以设置公平或非公平模式,插入操作必须等待获取操作移除元素,反之亦然。

4.PriorityBlockingQueue:支持优先级排序的无界阻塞队列,创建元素时可以指定多久之后才能从队列中获取元素,常用于缓存系统或定时任务调度系统。

5.DelayQueue:支持延时获取元素的无界阻塞队列,默认情况下根据自然序排序,也可以指定Comparator。

6.LinkedTransferQueue:一个由链表结构组成的无界阻塞队列,与LinkedBlockingQueue相比多了transfer和tryTransfer方法,该方法在有消费者接收元素时会立即将元素传递给消费者。

7.LinkedBlockingDeque:一个由链表结构组成的双端阻塞队列,可以从队列的两端插入或删除元素。

四、Worker继承了AQS实现了锁机制,那ThreadPoolExecutor都用到了哪些锁?为什么要用锁?

1.mainLock锁

ThreadPoolExecutor内部维护了ReentrantLock类型锁mainlock,在访问workers成员变量以及进行相关数据统计记账时需要获取该重入锁。

为什么要有mainLock?

可以看到workers变量用的HashSet 是线程不安全的,是不能用于多线程环境的。

largestPoolSize、completedtaskCount也是没有volatile修饰,所以需要在锁的保护下进行访问。

为什么不直接用个线程安全容器呢?

mainLock变量上的注释已经解释了,意思就是说事实证明,相比于线程安全容器,此处更适合用lock,主要原因之一就是串行化interruptIdleWorkers()方法,避免了不必要的中断风暴。

怎么理解这个中断风暴呢?

其实简单理解就是如果不加锁,interruptIdleWorkers()方法会在多线程访问下就会发生这种情况。

一个线程调用interruptIdleWorkers()方法对Worker进行中断,此时刻Worker处于中断状态,此时又来一个线程去中断正在中断中的Worker线程,这就是所谓的中断风暴。

那 largestPoolSize、completedTaskCount 变量加个 volatile 关键字修饰是不是就可以不用 mainLock 了?

其他的一些内部变量能用volatile的都加volatile修饰了,这两个没有加主要就是为了保证这两个参数的准确性,在获取这两个值时,能保证获取到的一定是修改方法执行完成后的值。如果不加锁,可能在修改方法还没执行完成时,此时来获取值,获取到的就是修改前的值。

2.Worker线程锁

Worker线程继承了AQS,实现了Runnable接口,内部持有一个Thread变量,一个firstTask,及completedTasks三个成员变量。

基于AQS的acquire(),tryAcquire()实现了lock(),tryLock()方法,类上也有注释,该锁主要是用来维护

运行中线程的中断状态。在runWorker()方法中以及刚说的interruptIdleWorkers()方法中用到了。

这个维护运行中线程的中断状态怎么理解呢?

 在runWorker()方法中获取任务开始执行前,需要先调用w.lock()方法,lock()方法会调用tryAcquire()方法,tryAcquire()实现了一把非重入锁,通过CAS实现加锁。

 interruptIdleWorkers() 方法会中断那些等待获取任务的线程,会调用w.tryLock()方法来加锁,如果一个线程已经在执行任务中,那么tryLock()就获取锁失败,就不能中断运行中的线程了。

所以Worker继承AQS主要是为了实现一把非重入锁,维护线程的中断状态,保证不能中断运行中的线程。

五、你在项目中是怎样使用线程池的?Executors 了解吗? 

现在大多数公司都在遵循阿里巴巴Java开发规范,该规范里明确说明不允许使用Executors创建线程池,而是通过ThreadPoolExecutor显示指定参数去创建。

因为:

1.Executor.newFixedThreadPool和Executor.SingleThreadPool创建的线程池内部使用的是无界(Integer.MAX_VALUE)的LinkedBlockingQueue队列,可能会堆积大量请求,导致OOM。

2.Executors.newCachedThreadPool 和Executors.scheduledThreadPool创建的线程池最大线程数是用的Integer.MAX_VALUE,可能会创建大量线程,导致OOM。

自己在日常工作中也有封装类似的工具类,但是都是内存安全的,参数需要自己指定适当的值,也有基于LinkedBlockingQueue实现了内存安全阻塞队列MemorySafeLinkedBlockingQueue,当系统内存达到设置的剩余阙值时,就不往队列里添加任务了,避免发生OOM。

我们一般是在Spring环境中使用线程池,直接使用JUC原生ThreadPoolExecutor有个问题,Spring容器关闭的时候可能任务队列里的任务还没有处理完,有丢失任务的风险。

我们知道Spring中的Bean是有生命周期的,如果Bean实现了Spring相应的生命周期接口(InitializingBean、DisposableBean接口),在Bean初始化、容器关闭的时候会调用相应的方法来做相应处理。

所以最好不要直接使用ThreadPoolExecutor在Spring环境中,可以使用Spring提供的ThreadPoolTaskExecutor,或者DynamicTp框架提供的DtpExecutor线程池实现。

也会按照业务类型进行线程池隔离,各任务执行互不影响,避免共享一个线程池,任务执行参差不齐,相互影响,高耗时任务会占满线程池资源,导致低耗时任务没机会执行;同时如果任务之间存在父子关系,可能会导致死锁的发生,进而引发OOM。

六、刚你说到了通过 ThreadPoolExecutor 来创建线程池,那核心参数设置多少合适呢?

《Java 并发编程事件》这本书里介绍的一个线程数计算公式:

Ncpu = cpu核数

Ucpu = 目标CPU利用率,0 <= Ucpu <=1

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

要程序跑到CPU的目标利率,需要的线程数为:

Nthreads = Ncpu * Ucpu * (1 + W/C)

这公式太偏理论化了,很难实际落地下来,首先很难获取到准确的等待时间和计算时间。再者一个服务中会运行很多线程,比如Tomcat有自己的线程池、Dubbo有自己的线程池、GC也有自己的后台线程,我们引入的各种框架、中间件都有可能有自己的工作线程,这些线程都会占用CPU资源,所以通过此公式计算出来的误差一定很大。

所以说怎么确定线程池的大小呢?

其实没有固定答案,需要通过压测不断的动态调整线程池参数,观察CPU利用率、系统负载、GC、内存、RT、吞吐量等各种综合指标数据,来找到一个相对比较合理的值。

所以不要问设置多少线程合适,这个问题没有标准答案,需要结合业务场景,设置一系列数据指标,排除可能的干扰因素,注意链路依赖(比如连接池限制,三方接口限流),然后通过不断动态调整线程数,测试找到一个相对合适的值。

七、线程池是怎么监控的?

我们对线程池ThreadPoolExecutor做了一些增强,做了一个线程池管理框架。主要功能有监控警告、动态调参。主要利用了ThreadPoolExecutor类提供的set、get方法以及一些钩子函数。

动态调参是基于配置中心实现的,核心参数配置在配置中心,可以随时调整、实时生效,利用了线程池提供的set方法。

监控,主要就是利用线程池提供的一个get方法来获取一些指标数据,然后采集数据上报到监控系统进行大盘展示。也提供了Endpoint实时查看线程池指标数据。

同时定义了五种告警规则:

  1. 线程池活跃度告警。活跃度 = activeCount / maximumPoolSize,当活跃度达到配置的阈值时,会进行事前告警。

  2. 队列容量告警。容量使用率 = queueSize / queueCapacity,当队列容量达到配置的阈值时,会进行事前告警。

  3. 拒绝策略告警。当触发拒绝策略时,会进行告警。

  4. 任务执行超时告警。重写 ThreadPoolExecutor 的 afterExecute() 和 beforeExecute(),根据当前时间和开始时间的差值算出任务执行时长,超过配置的阈值会触发告警。

  5. 任务排队超时告警。重写 ThreadPoolExecutor 的  beforeExecute(),记录提交任务时时间,根据当前时间和提交时间的差值算出任务排队时长,超过配置的阈值会触发告警

通过监控+告警可以让我们及时感知到我们业务线程池的执行负载情况,第一时间做出调整,防止事故的发生。

 八、你在使用线程池的过程中遇到过哪些坑或者需要注意的地方? 

  1. OOM问题。通过Executor创建线程,可能会堆积大量请求或者创建大量线程而造成OOM

  2. 任务异常丢失问题。可以通过四种方式来解决:2.1在任务代码中增加try、catch异常处理。2.2如果使用的Future方式,则可通过Future对象的get方法接收抛出的异常。2.3为工作线程设置 setUncaughtExceptionHandler,在 uncaughtException 方法中处理异常。2.4可以重写 afterExecute(Runnable r, Throwable t) 方法,拿到异常 t。

  3. 共享线程池问题。整个服务共享一个全局线程池,导致任务互相影响,耗时长的任务沾满资源,短耗时任务得不到执行。同时父子线程间会导致死锁的发生,从而导致OOM。

  4. 跟 ThreadLocal 配合使用,导致脏数据问题。我们知道 Tomcat 利用线程池来处理收到的请求,会复用线程,如果我们代码中用到了 ThreadLocal,在请求处理完后没有去 remove,那每个请求就有可能获取到之前请求遗留的脏值。

  5. ThreadLocal 在线程池场景下会失效,可以考虑用阿里开源的 Ttl 来解决。

以上提到的线程池动态调参、通知告警在开源动态线程池项目 DynamicTp 中已经实现了,可以直接引入到自己项目中使用。

DynamicTp是一个基于配置中心实现的轻量级动态线程管理工具,主要 功能可以总结为动态调参、通知告警、运行监控、三方包线程池管理等几大类。

特性 

  • 代码零侵入:所有配置都放在配置中心,对业务代码零侵入

  • 轻量简单:基于 springboot 实现,引入 starter,接入只需简单4步就可完成,顺利3分钟搞定

  • 高可扩展:框架核心功能都提供 SPI 接口供用户自定义个性化实现(配置中心、配置文件解析、通知告警、监控数据采集、任务包装等等)

  • 线上大规模应用:参考美团线程池实践(https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html),美团内部已经有该理论成熟的应用经验

  • 多平台通知报警:提供多种报警维度(配置变更通知、活性报警、容量阈值报警、拒绝触发报警、任务执行或等待超时报警),已支持企业微信、钉钉、飞书报警,同时提供 SPI 接口可自定义扩展实现

  • 监控:定时采集线程池指标数据,支持通过 MicroMeter、JsonLog 日志输出、Endpoint 三种方式,可通过 SPI 接口自定义扩展实现

  • 任务增强:提供任务包装功能,实现TaskWrapper接口即可,如 MdcTaskWrapper、TtlTaskWrapper、SwTraceTaskWrapper,可以支持线程池上下文信息传递

  • 兼容性:JUC 普通线程池和 Spring 中的 ThreadPoolTaskExecutor 也可以被框架监控,@Bean 定义时加 @DynamicTp 注解即可

  • 可靠性:框架提供的线程池实现 Spring 生命周期方法,可以在 Spring 容器关闭前尽可能多的处理队列中的任务

  • 多模式:参考Tomcat线程池提供了 IO 密集型场景使用的 EagerDtpExecutor 线程池

  • 支持多配置中心:基于主流配置中心实现线程池参数动态调整,实时生效,已支持 Nacos、Apollo、Zookeeper、Consul、Etcd,同时也提供 SPI 接口可自定义扩展实现

  • 中间件线程池管理:集成管理常用第三方组件的线程池,已集成Tomcat、Jetty、Undertow、Dubbo、RocketMq、Hystrix等组件的线程池管理(调参、监控报警)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值