线程池 面试题

参考 https://mp.weixin.qq.com/s/F0on3-cf1VtSAlzLfKAQBw

1.平时有用过线程池吗,什么是线程池,为什么要用线程池?

什么是线程,什么是线程池

线程是计算机操作系统能够进行运算调度的最小单位,被包含在进程里,是应用程序执行进程的实际运行单位;是操作系统宝贵的资源;使用池化技术,对线程进行统一控制管理,就是线程池;

JUC 提供了ThreadPoolExecutor 线程池继承体系,可以借此创建线程池;

线程池的好处

  1. 降低资源消耗。降低频繁创建、销毁线程带来的额外开销,复用已创建线程
  2. 降低使用复杂度。将任务的提交和执行进行解耦,我们只需要创建一个线程池,然后往里面提交任务就行,具体执行流程由线程池自己管理,降低使用复杂度
  3. 提高线程可管理性。能安全有效的管理线程资源,避免不加限制无限申请造成资源耗尽风险
  4. 提高响应速度。任务到达后,直接复用已创建好的线程执行

使用线程池的场景

  1. 快速响应用户请求

    注册或提交什么信息,需要修改调用的服务组件很多,串行执行的话,要等一个服务调用成功后再进行下一个调用,这样会导致处理时间过长,用户等待时间久;

    可以使用线程池异步调用服务,直接先返回用户响应,提高用户体验;

  2. 单位时间内处理更多的请求,提高吞吐量

    MQ队列的消息过多,消费时间久,容易造成消息积压,或批量插入数据库时间久;

    可以使用线程池,多线程执行,执行速度更快;

2. JUC提供的 ThreadPoolExecutor 体系线程池有哪些重要的参数?
  • 青铜回答:

    包含核心线程数(corePoolSize)、最大线程数(maximumPoolSize),

    空闲线程超时时间(keepAliveTime)、时间单位(unit)、阻塞队列(workQueue)、

    拒绝策略(handler)、线程工厂(ThreadFactory)这7个参数。

  • 钻石回答:

    回答完包含这几个参数之后,会再主动描述下线程池的执行流程,也就是 execute() 方法执行流程。

    public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        else if (!addWorker(command, false))
            reject(command);
    }
    
    1. 判断线程池的状态,如果不是RUNNING状态,直接执行拒绝策略
    2. 如果当前线程数 < 核心线程池,则新建一个线程来处理提交的任务
    3. 如果当前线程数 > 核心线程数且任务队列没满,则将任务放入阻塞队列等待执行
    4. 如果 核心线程池 < 当前线程池数 < 最大线程数,且任务队列已满,则创建新的线程执行提交的任务
    5. 如果当前线程数 > 最大线程数,且队列已满,则执行拒绝策略拒绝该任务
  • 王者回答:

    在回答完包含哪些参数及 execute 方法的执行流程后。然后可以说下这个执行流程是JUC 标准线程池提供的执行流程,主要用在 CPU 密集型场景下,认为CPU的线程资源更重要。

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

    提供了阻塞队列 TaskQueue,该队列继承 LinkedBlockingQueue,重写了 offer() 方法来实现执行流程的调整

    @Override
        public boolean offer(Runnable o) {
            //we can't do any checks
            if (parent==null) 
                return super.offer(o);
            //we are maxed out on threads, simply queue the object
            if (parent.getPoolSize() == parent.getMaximumPoolSize()) 
                return super.offer(o);
            //we have idle threads, just add it to the queue
            if (parent.getSubmittedCount()<=(parent.getPoolSize())) 
                return super.offer(o);
            //if we have less threads than maximum force creation of a new thread
            if (parent.getPoolSize()<parent.getMaximumPoolSize())
                return false;
            //if we reached here, we need to add it to the queue
            return super.offer(o);
        }
    

    队列在入队时有判断,parent 就是当前线程池对象

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

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

    3.如果当前未执行的任务数量小于等于当前线程数,仔细思考下,是不是说明有空闲的线程呢,那么直接调用父类 offer() 入队后就马上有线程去执行它

    4.如果当前线程数小于最大线程数量,则直接返回 false,然后回到 JUC 线程池的执行流程回想下,是不是就去添加新线程去执行任务了呢

    5.其他情况都直接入队

    和JUC的区别

    当前线程数大于核心线程数时,JUC 原生线程池首先是把任务放到队列里等待执行,而不是先创建线程执行。

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

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

    1. 判断如果当前线程数小于核心线程池,则新建一个线程来处理提交的任务
    2. 如果当前线程数大于核心线程池,小于最大线程数,则创建新的线程执行提交的任务
    3. 如果当前线程数等于最大线程数,则将任务放入任务队列等待执行
    4. 如果队列已满,则执行拒绝策略

    个人总结

    就是对创建线程和任务放进阻塞队列的顺序不同;

    • 当前线程数大于核心线程数,应用于CPU使用密集场景的JUC原生线程池不会轻易的创建线程执行任务,CPU的线程资源很宝贵,线程资源更重要,而是放入一个队列里存着,如果队列满了压不住了,才继续创建线程,直到线程数达到最大线程数;
    • 而应用于网络IO请求密集场景的Tomcat,网络IO请求的速度更重要,如果也是这样放进队列里存着,呐响应速度就会很受影响,于是做出修改,如果线程数大于核心线程数,就继续创建线程,直到最大线程数,然后再放进队列里,可以理解为,能用线程尽快处理的就创建线程处理,而不是放进队列里等着,用户请求等不起

    还可以再说下线程池的 Worker 线程模型,继承 AQS 实现了锁机制。线程启动后执行 runWorker() 方法,runWorker() 方法中调用 getTask() 方法从阻塞队列中获取任务,获取到任务后先执行 beforeExecute() 钩子函数,再执行任务,然后再执行 afterExecute() 钩子函数。若超时获取不到任务会调用 processWorkerExit() 方法执行 Worker 线程的清理工作。

3.什么是阻塞队列?说说常用的阻塞队列有哪些?

阻塞队列 BlockingQueue 继承 Queue

特点是:

当从阻塞队列中获取数据时,如果队列为空,则等待直到队列有元素存入。

当向阻塞队列中存入元素时,如果队列已满,则等待直到队列中有元素被移除。

提供 offer()、put()、take()、poll() 等常用方法。

JDK提供的阻塞队列:

1)ArrayBlockingQueue:由数组实现的有界阻塞队列,该队列按照 FIFO 对元素进行排序。维护两个整形变量,标识队列头尾在数组中的位置,在生产者放入和消费者获取数据共用一个锁对象,意味着两者无法真正的并行运行,性能较低。

2)LinkedBlockingQueue:由链表组成的有界阻塞队列,如果不指定大小,默认使用 Integer.MAX_VALUE 作为队列大小,该队列按照 FIFO 对元素进行排序,对生产者和消费者分别维护了独立的锁来控制数据同步,意味着该队列有着更高的并发性能。

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

4)PriorityBlockingQueue:支持优先级排序的无界阻塞队列,默认情况下根据自然序排序,也可以指定 Comparator。

5)DelayQueue:支持延时获取元素的无界阻塞队列,创建元素时可以指定多久之后才能从队列中获取元素,常用于缓存系统或定时任务调度系统。

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

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

4.你知道那些线程锁?ThreadPoolExecutor 都用到了哪些锁?为什么要用锁?
什么是重入锁?

就是支持重新进入的锁,表示支持一个线程对资源的重复加锁,方法执行的时候,执行线程在获取了锁后,还能继续对这个资源方法多次获取锁,而不会在下一次获取锁的时候出现阻塞自己的情况,用在递归方法里;

两者都是同步重入锁,线程独占锁,一个锁在一个时刻只能被一个线程占有,实现方式不同,都是悲观锁

  1. synchronized

    是JVM层面实现的,隐式加锁,不需要用户操作,操作简单,但是不够灵活

    可以放在递归方法里,不需担心是否最后释放锁的操作;

    不可中断,除非抛出异常;

    实现原理:重量级锁,悲观锁

    经过编译,在同步块的前后会有 monitorenter和monitorexit 两个字节码指令,执行前面的指令时,会尝试获取对象锁,如果这个对象没有被锁定,或当前线程已经获取了这个锁,就将锁的计数器加1,然后执行后面这个指令的时候就将计数器减1,当计数器为0,呐锁就被释放了,

    如果当前线程获取锁失败,就进入阻塞状态,直到锁被释放;

    作用在普通方法上就是对象实例加锁

    作用在静态方法就是对类加锁,会锁住所有使用该方法的线程;

  2. ReentrantLock

    是JDK层面实现的,可以直接看到源码,需要手动加锁解锁,操作比较复杂,使用灵活,可以跨越方法;

    重入时需要保证重复获取锁的次数和重复释放锁的次数一致,否则会出问题;

    可以设置中断,长期占用不释放锁,其他等待的线程可以放弃等待,也可以调用interrupt()方法可中断

    有公平锁,构造器传入true就是公平锁,默认是非公平锁;

    多个线程等待,按照等待时间顺序获取锁,等的最久的先获取锁

    有很多方法可以调用监听当前锁的信息;

    实现原理:轻量级锁,悲观锁

    是一种自旋锁,循环调用CAS比较交换来实现加锁,用了AQS( 同步阻塞的框架,实现轻量级线程同步,作用主要是提供加锁、释放锁,并在内部维护一个FIFO等待队列,用于存储由于锁竞争而阻塞的线程)

    获取锁

    ReentrantLock在采用非公平锁构造时,首先检查锁状态,如果锁可用,直接通过CAS设置成持有状态,且把当前线程设置为锁的拥有者。
    如果当前锁已经被持有,那么接下来进行可重入检查,如果可重入,需要为锁状态加上请求数。如果不属于上面两种情况,那么说明锁是被其他线程持有,
    当前线程应该放入等待队列。
    在放入等待队列的过程中,首先要检查队列是否为空队列,如果为空队列,需要创建虚拟的头节点,然后把对当前线程封装的节点加入到队列尾部。由于设置尾部节点采用了CAS,为了保证尾节点能够设置成功,这里采用了无限循环的方式,直到设置成功为止。
    在完成放入等待队列任务后,则需要维护节点的状态,以及及时清除处于Cancel状态的节点,以帮助垃圾收集器及时回收。如果当前节点之前的节点的等待状态小于1,说明当前节点之前的线程处于等待状态(挂起),那么当前节点的线程也应处于等待状态(挂起)。挂起的工作是由LockSupport类支持的,LockSupport通过JNI调用本地操作系统来完成挂起的任务(java中除了废弃的suspend等方法,没有其他的挂起操作)。
    在当前等待的线程,被唤起后,检查中断状态,如果处于中断状态,那么需要中断当前线程。

    释放锁

    (1)首先尝试释放锁,如果要求释放数等于锁状态数,那么将锁状态位清0,清除锁所有者,返回true;否则返回false;
    (2)如果(1)返回的是true,说明锁完全释放。接下来将检查等待队列,并选择一个waitStatus处于等待状态的节点下的线程unpark(恢复),选择的依据是从尾节点开始,选取最靠近头节点的等待节点,同时清理队列中线程被取消的节点;
    (3)如果(1)返回false,说明锁只是部分释放,当前线程仍旧持有该锁;

1)mainLock 锁 重入锁

ThreadPoolExecutor 线程池内部 维护 ReentrantLock(重入锁) 的 mainLock,

在获取一些成员变量 workers时,还有相关的数据统计,访问 largestPoolSize(池的最大容量)、completedTaskCount(已经完成的任务数)就会使用到这个重入锁 mainLock

private final ReentrantLock mainLock = new ReentrantLock();

/**
 * Set containing all worker threads in pool. Accessed only when
 * holding mainLock.
 */
private final HashSet<Worker> workers = new HashSet<Worker>();

/**
 * Tracks largest attained pool size. Accessed only under
 * mainLock.
 */
private int largestPoolSize;

/**
 * Counter for completed tasks. Updated only on termination of
 * worker threads. Accessed only under mainLock.
 */
private long completedTaskCount;
为什么要用这个 mainLock 锁?

关键是这些成员变量 使用的是线程不安全的HashSet,变量也没有volatile 修饰,所以需要使用这个锁来保证安全

呐为什么用线程安全的map呢?

直接原因是有串行化方法,避免了中断风暴

什么是中断风暴?

就是不加锁的情况下,一个串行化方法在多线程的调用下,线程A调用方法对worker进行中断,此时worker处于正在中断中的状态,还没有成功完全中断,然后多线程又来一个线程B,对线程A的中断操作进行中断,也就是中断了正在中断的worker,然后线程A的中断被破坏了;

为什么不直接用volatile修饰这些成员变量?
  • 其他内部变量能用volatile修饰的都加上了,就这两个 largestPoolSize(池的最大容量)、completedTaskCount(已经完成的任务数)没有加
  • 为了保证数据的准确性,获取这两个值的时候保证一定是修改方法执行完成后的
  • 如果不加锁而是用volatile 修饰,如果在方法还没执行完就来获取值,返回的是修改方法完成前的值
2)Worker 线程锁

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

基于 AQS 的 acquire()、tryAcquire() 实现了 lock()、tryLock() 方法,类上也有注释,该锁主要是用来维护运行中线程的中断状态。在 runWorker() 方法中以及刚说的 interruptIdleWorkers() 方法中用到了。

维护运行中线程的中断状态怎么理解呢
protected boolean tryAcquire(int unused) {
      if (compareAndSetState(0, 1)) {
          setExclusiveOwnerThread(Thread.currentThread());
          return true;
      }
      return false;
  }
  public void lock()        { acquire(1); }
  public boolean tryLock()  { return tryAcquire(1); }

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nyslC7gG-1663923591514)(p\1663645525816.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h5ttqsMe-1663923591515)(p\1663645551088.png)]

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

5.项目中是怎样使用线程池的?Executors 了解吗?

面试官主要想知道你日常工作中使用线程池的姿势,现在大多数公司都在遵循阿里巴巴 Java 开发规范,该规范里明确说明不允许使用 Executors 创建线程池,而是通过 ThreadPoolExecutor 显示指定参数去创建

你可以这样说,知道 Executors 工具类,很久之前有用过,也踩过坑,Executors 创建的线程池有发生 OOM 的风险。

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

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

6.通过 ThreadPoolExecutor 来创建线程池,那核心参数设置多少合适呢?

这个需要根据具体情况,不断的压测修改线程池的参数,观察CPU的使用率等其他数据来找到合适的参数值;

7.线程池是咋监控的?

对线程池 ThreadPoolExecutor 做了一些增强,做了一个线程池管理框架,

利用了 ThreadPoolExecutor 类提供的一些 set、get方法以及一些钩子函数

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

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

hippo4j-server 和 动态线程池DynamicTp 都是动态线程池,修改配置中心的参数就可以对线程池造成影响

8.使用线程池的过程中遇到过哪些坑或者需要注意的地方?
OOM内存溢出问题

springboot里面有 @Async异步的注解,直接在启动类上开启异步,就可以使用异步方法,

这里有个坑,@Async的默认线程池为SimpleAsyncTaskExecutor。不是真的线程池,这个类不重用线程,默认每次调用都会创建一个新的线程,而且配置是最大线程数:Integet.MAX_VALUE,队列使用LinkedBlockingQueue,容量是:Integet.MAX_VALUE,

如果不自定义配置线程池,那就会OOM内存溢出;

一般用 ThreadPoolTaskExecutor来配置自定义参数的线程池,在@Async注解里指定线程池

共享线程池问题

整个服务共享一个全局线程池,导致任务相互影响,耗时长的任务占满资源,短耗时任务得不到执行。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值