面试官一个线程池问题把我问懵逼了。
共 10473字,需浏览 21分钟
·
2021-04-23 18:19
大家好,我是跃哥。想必大家在面试的时候,经常被问到线程池的问题,那今天跃哥就借助于 why 哥的文章,和大家来好好聊聊线程池。
这是why的第 98 篇原创文章
你好呀,我是why哥。
前几天,有个朋友在微信上找我。他问: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 个非核心线程),但是它们各自的任务都执行完成了。然后我每隔 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 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 的等待队列啊。
先说明一下:由于本文只是带着你去找答案在源码的什么地方,不对源码进行解读。所以我默认你是对 AQS 是有一定的了解的。
接着根据堆栈信息,我们可以定位到这里的源码:
java.util.concurrent.locks.AbstractQueuedSynchronizer.ConditionObject#awaitNanos
看到这里的时候,我才一下恍然大悟了起来。
害,是自己想的太多了。
说穿了,这其实就是个生产者-消费者的问题啊。
三个线程就是三个消费者,现在没有任务需要处理,它们就等着生产者生产任务,然后通知它们准备消费。
可以看到 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]-执行任务
在程序里面怎么知道呢?
这个就比较难了,其实我觉得这个信息并不重要吧?
什么,你加钱?
加钱,加钱可以实现。
自己扩展一下线程池嘛,给线程池里面的线程打个标还不是一件很简单的事情吗?
只是你想想,你区分这玩意干啥,有没有可落地的需求?
毕竟,脱离需求谈实现。都是耍流氓。
荒腔走板
周末的时候收到了之前在网上买的一个头戴式蓝牙耳机,放在家里用。
本来我是想多选选、多看看、多纠结一下的。
但是当我刷购物 APP 的时候,突然魅族的这款耳机映入了我的眼睛里面。
在这之前,其实我完全不知道这款耳机的。然后点进去简单看了一下介绍,甚至都没看评论,就直接下单了。
不为别的,只是因为我曾经也是魅族的忠实用户。
但是下单的时候我就在想:这应该是我最后一次为魅族充值了吧。
我用的第一款智能手机就是从同学那里买来的魅族的 M8,快 10 年过去了,我至今都还记得当时第一眼看到它的惊艳。
一款手机,怎么能做的这么漂亮呢?
提前 M8,了解魅族的朋友、了解这款手机的朋友都会由衷的说一句:国货之光。
由于 M8 给我带来的良好体验,后来的 10 年间,我每次换手机都是魅族。
我真的喜欢魅族、喜欢 flyme 系统、喜欢 mBack。
直到后面,止步于 Pro 7,黄章的“出山之作”。我当时刚好换手机,果断的买了,用了。之后,我决定不再为魅族充值了。
在我心里,魅族创始人黄章,才是第一个打造情怀,买情怀的人。我这句话不是贬低,而是赞扬,我曾经就愿意为这个男人的情怀买单。
但是,不管怎么样,我还是希望黄章早日做出自己的“梦想机”,魅族重回巅峰。
毕竟,我看到耳机快递里面魅族科技的卡片时,内心还是有一丝波澜的。
毕竟,我曾经也是魅友。
说到这个耳机,是真不错。
我写文章的时候就带着这个耳机,不愧是 MP3 发家的魅族:
高音准,中音甜,低音劲,总之一句话,就是通透!
最后说一句
好了,看到了这里安排个“一键三连”(转发、在看、点赞)吧,周更很累的,需要一点正反馈。
才疏学浅,难免会有纰漏,如果你发现了错误的地方,可以在后台提出来,我对其加以修改。