面试官一个线程池问题把我问懵逼了。

标签: java 面试问题 | 发表时间:2021-04-13 12:31 | 作者:why技术
出处:https://segmentfault.com/blogs

这是why的第 98 篇原创文章

前几天,有个朋友在微信上找我。他问:why哥,在吗?

我说:发生肾么事了?

他啪的一下就提了一个问题啊,很快。

我大意了,随意瞅了一眼,这题不是很简单吗?

结果没想到里面还隐藏着一篇文章。

故事,得从这个问题说起:

上面的图中的线程池配置是这样的:

  ExecutorService executorService = new ThreadPoolExecutor(40, 80, 1, TimeUnit.MINUTES,
                new LinkedBlockingQueue<>(100), 
                new DefaultThreadFactory("test"),
                new ThreadPoolExecutor.DiscardPolicy());

上面这个线程池里面的参数、执行流程啥的我就不再解释了。

毕竟我曾经在 《一人血书,想让why哥讲一下这道面试题。》这篇文章里面发过毒誓的,再说就是小王吧了:

上面的这个问题其实就是一个非常简单的八股文问题:

非核心线程在什么时候被回收?

如果经过 keepAliveTime 时间后,超过核心线程数的线程还没有接受到新的任务,就会被回收。

标准答案,完全没毛病。

那么我现在带入一个简单的场景,为了简单直观,我们把线程池相关的参数调整一下:

  ExecutorService executorService = new ThreadPoolExecutor(2, 3, 30, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(2), 
                new DefaultThreadFactory("test"),
                new ThreadPoolExecutor.DiscardPolicy());

那么问题来了:

  • 这个线程最多能容纳的任务是不是 5 个?
  • 假设任务需要执行 1 秒钟,那么我直接循环里面提交 5 个任务到线程池,肯定是在 1 秒钟之内提交完成,那么当前线程池的活跃线程是不是就是 3 个?
  • 如果接下来的 30 秒,没有任务提交过来。那么 30 秒之后,当前线程池的活跃线程是不是就是 2 个?

上面这三个问题的答案都是肯定的,如果你搞不明白为什么,那么我建议你先赶紧去补充一下线程池相关的知识点,下面的内容你强行看下去肯定是一脸懵逼的。

接下来的问题是这样的:

  • 如果当前线程池的活跃线程是 3 个(2 个核心线程+ 1 个非核心线程),但是它们各自的任务都执行完成了,都处于 waiting 状态。然后我每隔 3 秒往线程池里面扔一个耗时 1 秒的任务。那么 30 秒之后,活跃线程数是多少?

先说答案:还是 3 个。

从我个人正常的思维,是这样的:核心线程是空闲的,每隔 3 秒扔一个耗时 1 秒的任务过来,所以仅需要一个核心线程就完全处理的过来。

那么,30 秒内,超过核心线程的那一个线程一直处于等待状态,所以​ 30 秒之后,就被回收了。
但是上面仅仅是我的主观认为,而实际情况呢?

30 秒之后,超过核心线程​的线程并不会被回收,活跃线程还是 3 个。
到这里,如果你知道是 3 个,且知道为什么是 3 个,即了解为什么非核心线程并没有被回收,那么接下里的内容应该就是你已经掌握的了。

可以不看,拉到最后,点个赞,去忙自己的事情吧。

如果你不知道,可以接着看,了解一下为什么是 3 个。

虽然我相信没有面试官会问这样的问题,但是对于你去理解线程池,是有帮助的。

先上 Demo

基于我前面说的这个场景,码出代码如下:

  public class ThreadTest {

    @Test
    public void test() throws InterruptedException {

        ThreadPoolExecutor executorService = new ThreadPoolExecutor(2, 3, 30, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(2), new DefaultThreadFactory("test"),
                new ThreadPoolExecutor.DiscardPolicy());
                
        //每隔两秒打印线程池的信息
        ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
        scheduledExecutorService.scheduleAtFixedRate(() -> {
            System.out.println("=====================================thread-pool-info:" + new Date() + "=====================================");
            System.out.println("CorePoolSize:" + executorService.getCorePoolSize());
            System.out.println("PoolSize:" + executorService.getPoolSize());
            System.out.println("ActiveCount:" + executorService.getActiveCount());
            System.out.println("KeepAliveTime:" + executorService.getKeepAliveTime(TimeUnit.SECONDS));
            System.out.println("QueueSize:" + executorService.getQueue().size());
        }, 0, 2, TimeUnit.SECONDS);

        try {
            //同时提交5个任务,模拟达到最大线程数
            for (int i = 0; i < 5; i++) {
                executorService.execute(new Task());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        //休眠10秒,打印日志,观察线程池状态
        Thread.sleep(10000);

        //每隔3秒提交一个任务
        while (true) {
            Thread.sleep(3000);
            executorService.submit(new Task());
        }
    }

    static class Task implements Runnable {
        @Override
        public void run(){
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread() + "-执行任务");
        }
    }
}

这份代码也是提问的哥们给我的,我做了微调,你直接粘出去就能跑起来。

show me code,no bb。这才是相互探讨的正确姿势。

这个程序的运行结果是这样的:

一共五个任务,线程池的运行情况是什么样的呢?

先看标号为 ① 的地方:

三个线程都在执行任务,然后 2 号线程和 1 号线程率先完成了任务,接着把队列里面的两个任务拿出来执行(标号为 ② 的地方)。

按照程序,接下来,每隔 3 秒就有一个耗时 1 秒的任务过来。而此时线程池里面的三个活跃线程都是空闲状态。

那么问题就来了:

该选择哪个线程来执行这个任务呢?是随机选一个吗?

虽然接下来的程序还没有执行,但是基于前面的截图,我现在就可以告诉你,接下来的任务,线程执行顺序为:

  • Thread[test-1-3,5,main]-执行任务
  • Thread[test-1-2,5,main]-执行任务
  • Thread[test-1-1,5,main]-执行任务
  • Thread[test-1-3,5,main]-执行任务
  • Thread[test-1-2,5,main]-执行任务
  • Thread[test-1-1,5,main]-执行任务
  • ......

即虽然线程都是空闲的,但是当任务来的时候不是随机调用的,而是轮询。

由于是轮询,每三秒执行一次,所以非核心线程的空闲时间最多也就是 9 秒,不会超过 30 秒,所以一直不会被回收。

基于这个 Demo,我们就从表象上回答了,为什么活跃线程数一直为 3。

为什么是轮询?

我们通过 Demo 验证了上面场景中,线程执行顺序为轮询。

那么为什么呢?

这只是通过日志得出的表象呀,内部原理呢?对应的代码呢?

这一小节带大家看一下到底是怎么回事。

首先我看到这个表象的时候我就猜测:这三个线程肯定是在某个地方被某个队列存起来了,基于此,才能实现轮询调用。

所以,我一直在找这个队列,一直没有找到对应的代码,我还有点着急了。想着不会是在操作系统层面控制的吧?

后来我冷静下来,觉得不太可能。于是电光火石之间,我想到了,要不先 Dump 一下线程,看看它们都在干啥:

Dump 之后,这玩意我眼熟啊,AQS 的等待队列啊。

根据堆栈信息,我们可以定位到这里的源码:

java.util.concurrent.locks.AbstractQueuedSynchronizer.ConditionObject#awaitNanos

看到这里的时候,我才一下恍然大悟了起来。

害,是自己想的太多了。

说穿了,这其实就是个生产者-消费者的问题啊。

三个线程就是三个消费者,现在没有任务需要处理,它们就等着生产者生产任务,然后通知它们准备消费。

由于本文只是带着你去找答案在源码的什么地方,不对源码进行解读。

所以我默认你是对 AQS 是有一定的了解的。

可以看到 addConditionWaiter 方法其实就是在操作我们要找的那个队列。学名叫做等待队列。

Debug 一下,看看队列里面的情况:

巧了嘛,这不是。顺序刚好是:

  • Thread[test-1-3,5,main]
  • Thread[test-1-2,5,main]
  • Thread[test-1-1,5,main]

消费者这边我们大概摸清楚了,接着去看看生产者。

  • java.util.concurrent.ThreadPoolExecutor#execute

线程池是在这里把任务放到队列里面去的。

而这个方法里面的源码是这样的:

其中 signalNotEmpty() 最终会走到 doSignal 方法,而该方法里面会调用 transferForSignal 方法。

这个方法里面会调用 LockSupport.unpark(node.thred) 方法,唤醒线程:

而唤醒的顺序,就是等待队列里面的顺序:

所以,现在你知道当一个任务来了之后,这个任务该由线程池里面的哪个线程执行,这个不是随机的,也不是随便来的。

是讲究一个顺序的。

什么顺序呢?

Condition 里面的等待队列里面的顺序。

什么,你不太懂 Condition?

那还不赶紧去学?等着我给你讲呢?

本来我是想写一下的,后来发现《Java并发编程的艺术》一书中的 5.6.2 小节已经写的挺清楚了,图文并茂。这部分内容其实也是面试的时候的高频考点,所以自己去看看就好了。

先欠着,欠着。

非核心线程怎么回收?

还是上面的例子,假设非核心线程就空闲了超过 30 秒,那么它是怎么被回收的呢?

这个也是一个比较热门的面试题。

这题没有什么高深的地方,答案就藏在源码的这个地方:

  • java.util.concurrent.ThreadPoolExecutor#getTask

当 timed 参数为 true 的时候,会执行 workQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS) 方法。

而 timed 什么时候为 true 呢?

  • boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

allowCoreThreadTimeOut 默认为 false。

所以,就是看 wc > corePoolSize 条件,wc 是活跃线程数。此时活跃线程数为 3 ,大于核心线程数 2。

因此 timed 为 true。

也就是说,当前 workQueue 为空的时候,现在三个线程都阻塞 workQueue.poll 方法中。

而当指定时间后,workQueue 还是为空,则返回为 null。

于是在 1077 行把 timeOut 修改为 true。

进入一下次循环,返回 null。

最终会执行到这个方法:

  • java.util.concurrent.ThreadPoolExecutor#processWorkerExit

而这个方法里面会执行 remove 的操作。

于是线程就被回收了。

所以当超过指定时间后,线程会被回收。

那么被回收的这个线程是核心线程还是非核心线程呢?

不知道。

因为在线程池里面,核心线程和非核心线程仅仅是一个概念而已,其实拿着一个线程,我们并不能知道它是核心线程还是非核心线程。

这个地方就是一个证明,因为当工作线程多余核心线程数之后,所有的线程都在 poll,也就是说所有的线程都有可能被回收:

另外一个强有力的证明就是 addWorker 这里:

core 参数仅仅是控制取 corePoolSize 还是 maximumPoolSize。

所以,这个问题你说怎么回答:

JDK 区分的方式就是不区分。

那么我们可以知道吗?

可以,比如通过观察日志,前面的案例中,我就知道这两个是核心线程,因为它们最先创建:

  • Thread[test-1-1,5,main]-执行任务
  • Thread[test-1-2,5,main]-执行任务

在程序里面怎么知道呢?

目前是不知道的,但是这个需求,加钱就可以实现。

自己扩展一下线程池嘛,给线程池里面的线程打个标还不是一件很简单的事情吗?

只是你想想,你区分这玩意干啥,有没有可落地的需求?

毕竟,脱离需求谈实现。都是耍流氓。

最后说一句

才疏学浅,难免会有纰漏,如果你发现了错误的地方,可以在后台提出来,我对其加以修改。

感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。

相关 [面试 线程池 问题] 推荐:

面试官一个线程池问题把我问懵逼了。

- - SegmentFault 最新的文章
这是why的第 98 篇原创文章. 前几天,有个朋友在微信上找我. 他啪的一下就提了一个问题啊,很快. 我大意了,随意瞅了一眼,这题不是很简单吗. 结果没想到里面还隐藏着一篇文章. 上面的图中的线程池配置是这样的:. 上面这个线程池里面的参数、执行流程啥的我就不再解释了. 毕竟我曾经在 《一人血书,想让why哥讲一下这道面试题.

spring自带线程池使用不当导致的死锁问题

- - 海思
Spring自带线程池使用很方便,不过在相对复杂的并发编程场景中,使用时还是需要根据使用场景仔细考虑配置,否则可能会遇到本文中提及的坑. 具体的代码参照 示例项目 https://github.com/qihaiyan/springcamp/tree/master/spring-taskexecutor-block.

Java线程池

- - 企业架构 - ITeye博客
线程的使用在java中占有极其重要的地位,在jdk1.4极其之前的jdk版本中,关于线程池的使用是极其简陋的. 在jdk1.5之后这一情况有了很大的改观. Jdk1.5之后加入了java.util.concurrent包,这个包中主要介绍java中线程以及线程池的使用. 为我们在开发中处理线程的问题提供了非常大的帮助.

Java 线程池

- - 编程语言 - ITeye博客
在项目中,系统启动一个新线程的成本是比较高的,因为它涉及与操作系统交互. 在这种情形下,使用线程池可以很好地提高性能,尤其是当程序中需要创建大量生存周期很短的线程时,更应该考虑使用线程池. 使用线程池可以有效地控制系统中并发线程的数量,当系统中包含大量并发线程时,会导致系统性能剧烈下降,甚至导致JVM崩溃,而线程池的最大线程数参数可以控制系统中并发线程数不超过此数.

面试题:火车运煤问题

- Louis - 酷壳 - CoolShell.cn
这个可能是一个比较经典的智力题了,和以前的那个《赛马问题》很相似,其题目如下:. 你是山西的一个煤老板,你在矿区开采了有3000吨煤需要运送到市场上去卖,从你的矿区到市场有1000公里,你手里有一列烧煤的火车,这个火车最多只能装1000吨煤,且其能耗比较大——每一公里需要耗一吨煤. 请问,作为一个懂编程的煤老板的你,你会怎么运送才能运最多的煤到集市.

技术面试中,什么样的问题才是好问题?

- - 四火的唠叨
其实很久以前就想谈一谈这个话题了,但是最近才有了足够的动机. 因为从最近参加的很多 debrief 来看,我认为身边大多数的软件工程师面试中,在通过技术问题来考察候选人这方面,很多都做得不够好. 比方说,我看到对于一些经验丰富的软件工程师候选人的面试,一些面试官依然是草率地扔出一道算法题让做了事,并且认为能不能够比较清晰完整地将代码写出来,是工程师级别裁定的最重要的标准.

面试时,你会问面试官哪些问题?

- Walter8529 - 博客园-首页原创精华区
每次面试的时候,面试官都会在最后给面试者一些时间,来问问题. 这是个非常好的机会,能按照自己的思路,来了解职位、技术、企业文化、福利待遇、企业状况和前景等情况,以弥补前面面试过程中没有了解到的情况. 但较早以前面试准备不太充分,虽然也能地问上一些问题,但挂一漏万,每次回来后,总觉得对企业、对职位没有完全了解清楚,不能依此作出很理性的决断.

机器学习及大数据相关面试的职责和面试问题

- - IT瘾-bigdata
· 机器学习、大数据相关岗位的职责. 各个企业对这类岗位的命名可能有所不同,比如推荐算法/数据挖掘/自然语言处理/机器学习算法工程师,或简称算法工程师,还有的称为搜索/推荐算法工程师,甚至有的并入后台工程师的范畴,视岗位具体要求而定. 机器学习、大数据相关岗位的职责. 根据业务的不同,岗位职责大概分为:.

java线程池分析

- - BlogJava-首页技术区
    在Java 5.0之前启动一个任务是通过调用Thread类的start()方法来实现的,任务的提于交和执行是同时进行的,如果你想对任务的执行进行调度或是控制 同时执行的线程数量就需要额外编写代码来完成. 5.0里提供了一个新的任务执行架构使你可以轻松地调度和控制任务的执行,并且可以建立一个类似数据库连接 池的线程池来执行任务.

Java线程池应用

- - CSDN博客架构设计推荐文章
1.减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务. 2.可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内存,而把服务器累趴下(每个线程需要大约1MB内存,线程开的越多,消耗的内存也就越大,最后死机). Java里面线程池的顶级接口是Executor,但是严格意义上讲Executor并不是一个线程池,而只是一个执行线程的工具.