乐趣区

关于java:线程池面试那些事儿

大家好,这篇文章次要跟大家聊下 Java 线程池面试中可能会问到的一些问题。

全程干货,急躁看完,你能轻松应答各种线程池面试。

置信各位 Javaer 在面试中或多或少必定被问到过线程池相干问题吧,线程池是一个绝对比较复杂的体系,基于此能够问出各种各样、形形色色的问题。

若你很相熟线程池,如果能够,齐全能够滔滔不绝跟面试官扯一个小时线程池,个别面试也就一个小时左右,那么这样留给面试官问其余问题的工夫就很少了,或者其余问题可能问的也就不深刻了,那你通过面试的几率是不就更大点了呢。

上面咱们开始列下线程池面试可能会被问到的问题以及该怎么答复,以下只是参考答案,你也能够退出本人的了解。

1. 面试官:日常工作中有用到线程池吗?什么是线程池?为什么要应用线程池?

个别面试官考查你线程池相干常识前,大概率会先问这个问题,如果你说没用过,不理解,ok,那就没以下问题啥事了,预计你的面试后果必定也凶多吉少了。

作为 JUC 包下的门面担当,线程池是货真价实的 JUC 一哥,不理解线程池,那阐明你对 JUC 包其余工具也理解的不咋样吧,对 JUC 没深入研究过,那就是没把握到 Java 的精华,给面试官这样一个印象,那后果可想而知了。

所以说,这一分肯定要吃下,那咱们应该怎么答复好这问题呢?

能够这样说:

计算机倒退到当初,摩尔定律在现有工艺水平下曾经遇到难易冲破的物理瓶颈,通过多核 CPU 并行计算来晋升服务器的性能曾经成为支流,随之呈现了多线程技术。

线程作为操作系统贵重的资源,对它的应用须要进行管制治理,线程池就是采纳池化思维(相似连接池、常量池、对象池等)治理线程的工具。

JUC 给咱们提供了 ThreadPoolExecutor 体系类来帮忙咱们更不便的治理线程、并行执行工作。

下图是 Java 线程池继承体系:

应用线程池能够带来以下益处:

  1. 升高资源耗费。升高频繁创立、销毁线程带来的额定开销,复用已创立线程
  2. 升高应用复杂度。将工作的提交和执行进行解耦,咱们只须要创立一个线程池,而后往里面提交工作就行,具体执行流程由线程池本人治理,升高应用复杂度
  3. 进步线程可管理性。能平安无效的治理线程资源,防止不加限度有限申请造成资源耗尽危险
  4. 进步响应速度。工作达到后,间接复用已创立好的线程执行

线程池的应用场景简略来说能够有:

  1. 疾速响应用户申请,响应速度优先 。比方一个用户申请,须要通过 RPC 调用好几个服务去获取数据而后聚合返回,此场景就能够用线程池并行调用,响应工夫取决于响应最慢的那个 RPC 接口的耗时;又或者一个注册申请,注册完之后要发送短信、邮件告诉,为了疾速返回给用户,能够将该告诉操作丢到线程池里异步去执行,而后间接返回客户端胜利,进步用户体验。
  2. 单位工夫解决更多申请,吞吐量优先 。比方承受 MQ 音讯,而后去调用第三方接口查问数据,此场景并不谋求疾速响应,次要利用无限的资源在单位工夫内尽可能多的解决工作,能够利用队列进行工作的缓冲

2. 面试官:ThreadPoolExecutor 都有哪些外围参数?

其实个别面试官问你这个问题并不是简略听你说那几个参数,而是想要你形容下线程池执行流程。

青铜答复:

蕴含外围线程数(corePoolSize)、最大线程数(maximumPoolSize),闲暇线程超时工夫(keepAliveTime)、工夫单位(unit)、阻塞队列(workQueue)、回绝策略(handler)、线程工厂(ThreadFactory)这 7 个参数。

钻石答复:

答复完蕴含这几个参数之后,会再被动形容下线程池的执行流程,也就是 execute() 办法执行流程。

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 密集型场景下。

像 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. 其余状况都间接入队

具体能够看之前写过的这篇文章

动静线程池(DynamicTp),动静调整 Tomcat、Jetty、Undertow 线程池参数篇

能够看出当以后线程数大于外围线程数时,JUC 原生线程池首先是把工作放到队列里期待执行,而不是先创立线程执行。

如果 Tomcat 接管的申请数量大于外围线程数,申请就会被放到队列中,期待外围线程解决,这样会升高申请的总体响应速度。

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

  1. 判断如果以后线程数小于外围线程池,则新建一个线程来解决提交的工作
  2. 如果以后以后线程池数大于外围线程池,小于最大线程数,则创立新的线程执行提交的工作
  3. 如果以后线程数等于最大线程数,则将工作放入工作队列期待执行
  4. 如果队列已满,则执行回绝策略

而后还能够再说下线程池的 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. 面试官:你刚说到了 Worker 继承 AQS 实现了锁机制,那 ThreadPoolExecutor 都用到了哪些锁?为什么要用锁?

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;

能够看到 workers 变量用的 HashSet 是线程不平安的,是不能用于多线程环境的。largestPoolSize、completedTaskCount 也是没用 volatile 润饰,所以须要在锁的爱护下进行拜访。

面试官:为什么不间接用个线程平安容器呢?

其实 Doug 老爷子在 mainLock 变量的正文上解释了,意思就是说事实证明,相比于线程平安容器,此处更适宜用 lock,次要起因之一就是串行化 interruptIdleWorkers() 办法,防止了不必要的中断风暴

面试官:怎么了解这个中断风暴呢?

其实简略了解就是如果不加锁,interruptIdleWorkers() 办法在多线程拜访下就会产生这种状况。一个线程调用 interruptIdleWorkers() 办法对 Worker 进行中断,此时该 Worker 出于中断中状态,此时又来一个线程去中断正在中断中的 Worker 线程,这就是所谓的中断风暴。

面试官:那 largestPoolSize、completedTaskCount 变量加个 volatile 关键字润饰是不是就能够不必 mainLock 了?

这个其实 Doug 老爷子也思考到了,其余一些外部变量能用 volatile 的都加了 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 实现加锁。

interruptIdleWorkers() 办法会中断那些期待获取工作的线程,会调用 w.tryLock() 办法来加锁,如果一个线程曾经在执行工作中,那么 tryLock() 就获取锁失败,就保障了不能中断运行中的线程了。

所以 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 来创立线程池,那外围参数设置多少适合呢?

这个问题该怎么答复呢?

可能很多人都看到过《Java 并发编程事件》这本书里介绍的一个线程数计算公式:

Ncpu = CPU 核数

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

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

要程序跑到 CPU 的指标利用率,须要的线程数为:

Nthreads = Ncpu Ucpu (1 + W / C)

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

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

其实没有固定答案,须要通过压测一直的动静调整线程池参数,察看 CPU 利用率、零碎负载、GC、内存、RT、吞吐量 等各种综合指标数据,来找到一个绝对比拟正当的值。

所以不要再问设置多少线程适合了,这个问题没有标准答案,须要联合业务场景,设置一系列数据指标,排除可能的烦扰因素,留神链路依赖(比方连接池限度、三方接口限流),而后通过一直动静调整线程数,测试找到一个绝对适合的值。

7. 面试官:你们线程池是咋监控的?

因为线程池的运行相对而言是个黑盒,它的运行咱们感知不到,该问题次要考查怎么感知线程池的运行状况。

能够这样答复:

咱们本人对线程池 ThreadPoolExecutor 做了一些加强,做了一个线程池治理框架。次要性能有监控告警、动静调参。次要利用了 ThreadPoolExecutor 类提供的一些 set、get 办法以及一些钩子函数。

动静调参是基于配置核心实现的,外围参数配置在配置核心,能够随时调整、实时失效,利用了线程池提供的 set 办法。

监控,次要就是利用线程池提供的一些 get 办法来获取一些指标数据,而后采集数据上报到监控零碎进行大盘展现。也提供了 Endpoint 实时查看线程池指标数据。

同时定义了 5 中告警规定。

  1. 线程池活跃度告警。活跃度 = activeCount / maximumPoolSize,当活跃度达到配置的阈值时,会进行事先告警。
  2. 队列容量告警。容量使用率 = queueSize / queueCapacity,当队列容量达到配置的阈值时,会进行事先告警。
  3. 回绝策略告警。当触发回绝策略时,会进行告警。
  4. 工作执行超时告警。重写 ThreadPoolExecutor 的 afterExecute() 和 beforeExecute(),依据以后工夫和开始工夫的差值算出工作执行时长,超过配置的阈值会触发告警。
  5. 工作排队超时告警。重写 ThreadPoolExecutor 的 beforeExecute(),记录提交工作时工夫,依据以后工夫和提交工夫的差值算出工作排队时长,超过配置的阈值会触发告警

通过监控 + 告警能够让咱们及时感知到咱们业务线程池的执行负载状况,第一工夫做出调整,避免事变的产生。

8. 面试官:你在应用线程池的过程中遇到过哪些坑或者须要留神的中央?

这个问题其实也是在考查你对一些细节的把握水平,就全甩锅给年老刚毕业没教训的本人就行。能够适当多说些,也证实本人对线程池有着丰盛的应用教训。

1)OOM 问题。刚开始应用线程都是通过 Executors 创立的,后面说了,这种形式创立的线程池会有产生 OOM 的危险。

2)工作执行异样失落问题。能够通过下述 4 种形式解决

  1. 在工作代码中减少 try、catch 异样解决
  2. 如果应用的 Future 形式,则可通过 Future 对象的 get 办法接管抛出的异样
  3. 为工作线程设置 setUncaughtExceptionHandler,在 uncaughtException 办法中解决异样
  4. 能够重写 afterExecute(Runnable r, Throwable t) 办法,拿到异样 t

3)共享线程池问题。整个服务共享一个全局线程池,导致工作相互影响,耗时长的工作占满资源,短耗时工作得不到执行。同时父子线程间会导致死锁的产生,今儿导致 OOM

4)跟 ThreadLocal 配合应用,导致脏数据问题。咱们晓得 Tomcat 利用线程池来解决收到的申请,会复用线程,如果咱们代码中用到了 ThreadLocal,在申请解决完后没有去 remove,那每个申请就有可能获取到之前申请遗留的脏值。

5)ThreadLocal 在线程池场景下会生效,能够思考用阿里开源的 Ttl 来解决

以上提到的线程池动静调参、告诉告警在开源动静线程池我的项目 DynamicTp 中曾经实现了,能够间接引入到本人我的项目中应用。

对于 DynamicTp

DynamicTp 是一个基于配置核心实现的轻量级动静线程池管理工具,次要性能能够总结为动静调参、告诉报警、运行监控、三方包线程池治理等几大类。

通过多个版本迭代,目前最新版本 v1.0.8 具备以下个性

个性

  • 代码零侵入 :所有配置都放在配置核心,对业务代码零侵入
  • 轻量简略 :基于 springboot 实现,引入 starter,接入只需简略 4 步就可实现,顺利 3 分钟搞定
  • 高可扩大 :框架外围性能都提供 SPI 接口供用户自定义个性化实现(配置核心、配置文件解析、告诉告警、监控数据采集、工作包装等等)
  • 线上大规模利用 :参考美团线程池实际,美团外部曾经有该实践成熟的利用教训
  • 多平台告诉报警 :提供多种报警维度(配置变更告诉、活性报警、容量阈值报警、回绝触发报警、工作执行或期待超时报警),已反对企业微信、钉钉、飞书报警,同时提供 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 等组件的线程池治理(调参、监控报警)
退出移动版