1

五年前,我写错了一道面试题。 - why技术

 4 months ago
source link: https://www.cnblogs.com/thisiswhy/p/18150414
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

五年前,我写错了一道面试题。

你好呀,我是歪歪。

事情是这样的,上周有个读者找我,给我抛出了这样的一个问题:

1713671456800.png

问题中涉及到的文章分别是这两篇:

我自己写的这篇文章,虽然是五年前,2019 年的文章:

(卧槽,2019 年已经是五年前了)

20240421115741.png

但是毕竟是自己一个字一个字敲出来的,大概内容还是记得。

主要就是讨论了我在面试的时候遇到的这个问题:

一个线程池中的线程异常了,那么线程池会怎么处理这个线程?

当时我的回答是这样的:

20240421120031.png

在文章里面,我把我的回答总结成了三句话:

  • 1.抛出堆栈异常 ---这句话对了一半!
  • 2.不影响其他线程任务 ---这句话全对!
  • 3.这个线程会被放回线程池---这句话全错!

然后我的文章就基于上面这三句话展开了。

过程就不再赘述了,这次只讨论我五年前的文章中说错的一个点:这个(异常的)线程会被放回线程池。

当时我的结论是这句话全错了,正确的描述应该是:

(当一个线程池里面的线程异常后,)线程池会把这个线程移除掉,并创建一个新的线程放到线程池中。

20240421121545.png

对于同样的问题,京东技术的结论是这样的:

1713677067022.png
  • 当执行方式是 execute 时,可以看到堆栈异常的输出,线程池会把这个线程移除掉,并创建一个新的线程放到线程池中。
  • 当执行方式是 submit 时,堆栈异常没有输出。但是调用 Future.get() 方法时,可以捕获到异常,不会把这个线程移除掉,也不会创建新的线程放入到线程池中。

歪师傅的结论是一概而论,京东技术则是分情况讨论。

首先,京东技术的结论是正确的。

其次,歪师傅当年写这个文章的时候,就是技不如人,就是写错了,就是情况没有分析完整。

20240421174954.png

只看了 execute 的情况,导致得出了一个“只对了一半的答案”。

而关于使用 submit 方法时,如果在线程中抛出了异常,为什么不创建新的线程,而是继续复用原线程的原因,京东技术也从源码的角度解析了。

歪师傅这里也赘述一下。

问题的关键就是要抓到关键的问题。

那么在这个问题中,关键的问题是什么?

就是移除线程的方法在哪儿。

对应到源码其实就是这里:

java.util.concurrent.ThreadPoolExecutor#processWorkerExit

20240421134541.png

那么其实关键点就是这个方法在哪儿,在什么情况下会被调用到?

对应的源码在这里:

java.util.concurrent.ThreadPoolExecutor#runWorker

20240421135942.png

通过源码我们可以知道,在抛出异常的情况下,该方法会被调用到。

而 try 部分就只有一行代码:

task.run();

那么能耍花招的地方就只能是 task 这个对象了。

比如这样的代码,当 execute 方法执行的时候,这就是一个原生的 Thread 线程:

20240421141238.png

该方法是否会抛出异常,取决于你代码是否会抛出异常。

比如这样去写,线程执行 sayHi 方法的时候就会抛出异常:

20240421140234.png

而这样去写,则不会抛出异常:

20240421140358.png

所以,你再去看京东技术的结论:

execute 提交到线程池的方式,如果执行中抛出异常,并且没有在执行逻辑中 catch,那么会抛出异常,并且移除抛出异常的线程,创建新的线程放入到线程池中。

特别提到了 catch。

但是 submit 的时候,是怎么回事呢?

task 从一个普通线程变成了 FutureTask 对象:

20240421141028.png

因为源码在这里玩个了个小花招:

java.util.concurrent.AbstractExecutorService#submit(java.lang.Runnable)

20240421141652.png

把 task 包装成了 FutureTask 对象。

而一切的秘密就藏在 FutureTask 对象的 run 方法中:

java.util.concurrent.FutureTask#run

20240421141814.png

异常之后,会调用 setException 方法,仅仅是把异常放在了 outcome 字段中,然后维护了 FutureTask 的状态,不会继续往外抛出异常。

如果需要获取异常,则需要调用 get 方法。

好,现在我要开始闭环了。

因为 submit 提交的时候会把任务封装为 FutureTask 对象,该对象重写了 run 方法,所以当任务异常之后,不会继续往外抛出异常。

因为不会继续往外抛出异常,所以不会走到 processWorkerExit 方法。

因为不会走到 processWorkerExit 方法,所以不涉及移除线程和添加线程的逻辑。

当执行方式是 submit 时,不会把这个线程移除掉,也不会创建新的线程放入到线程池中。

其实整体逻辑还是很清楚的,当年就是分析漏了 submit 的情况,导致最终的结论不对。

五年前我挖了个坑,五年后,我把这个坑填一下。

20240421175144.png

然后再回答一个京东技术那篇文章下留言区的一个问题:

20240421142519.png

execute 执行无论是否抛出异常,finally 块中代码不是都会执行吗?

也就是这段代码:

20240421142718.png

如果你只看这部分 try 和 finally 代码块,我们学习 Java 的时候,如果老师没有骗我们的话,那么不管是正常执行完成 try 里面的代码,还是 try 里面的代码抛出异常, finally 代码块的代码理论上都是会执行的。

是的,这一个知识点没有任何毛病。

但是,你注意我是怎么说的“不管是正常执行完成还是抛出异常”。

抛出异常我们前面已经分析了,提问者的疑问点在于“正常执行完成”为什么不会执行 finally 代码块里面的 processWorkerExit 方法。

我的答案是:会。

但是,try 里面要正常执行完成,也就是 while 循环要正常结束,所以你看看一眼循环条件中的这个部分,要返回 null 才满足条件:

20240421143207.png

getTask 对应的源码是这样的:

java.util.concurrent.ThreadPoolExecutor#getTask

20240421143304.png

在我们讨论的场景下,线程是会阻塞在队列的 poll 或者 take 方法这里的。

如果是 take 方法就不说了,不会返回 null,在这里死等。

如果是 poll 方法返回了 null,则说明该线程到了超时时间还未从队列中获取到任务。

这个时候该怎么办?

翻翻八股文看看,如果线程池设置了 allowCoreThreadTimeOut 为 true,针对核心线程,在指定时间内未获取到任务或者非核心线程在指定时间内未获取到任务的时候,线程池会怎么处理?

是不是说的该销毁了,该从线程池中移走了?

所以,才会走到 processWorkerExit 执行 workers.remove(w) 方法。

是不是感觉自己又能行了,知识点又串起来了。

20240421173726.png

当读者问我“是复用还是移除”这个问题的时候,我当时确实不知道答案。

但是我一点都不慌,因为我知道去哪里找答案。

如果我真的需要想要知道答案的话,在不借助任何搜索工具,仅仅给我源码的情况下,我应该很快就能得到一个准确的答案。

这一点自信的底气是因为我确实较为深入的研究过这部分源码。

20240421175321.png

但是当时我没有去寻找答案,结合我对于线程池的理解,我在思考另外一个问题:这重要吗?

你仔细想一想,如果这个问题抛出来之后你直接就是一头雾水,或者说和我一样知道去哪里找答案,那么这个问题的准确回答对你来说真的重要吗?

不管是那种情况都不重要,一点都不重要。

因为不管是销毁还是复用,它完全不影响你对于线程池的使用。

重要的是,在一头雾水的情况下,自己去寻找问题的答案的这个过程。

你当然可以拿着关键字去网上搜,肯定能搜到答案,这是一个寻找的过程,不过是轻松一点,然后遗忘起来快一点。

你也可以带着问题去翻源码,这也是一个寻找的过程,不过是难一点而已,记忆深刻一点。

如果觉得直接啃源码啃不动,那就结合网上的资料一起食用,这同样是一个寻找的过程。

等你真的找到这个问题的标准答案的时候、等你进一步理解线程池的时候,你会发现这个问题的答案不重要,但是在寻找的过程中你写的 Demo、接触到的源码、方法之间的调用关系、分支判断逻辑、查阅到的资料、付出的时间和对应的收获、甚至是内心中转瞬即逝的开心...

这些是重要的。

这个题其实是一个陷阱。

就像是我们读书的时候做的数学题,我们都知道参考答案就在练习册的最后几页,照着参考答案抄就能回答正确。

但是我们都知道比起正确答案来说,更重要的是你知道解题的过程。

最可怕的情况是你抄答案的次数多了,对自己产生了错误的认知,让你在抄答案的过程中还产生了这题很简单,自己也会做的错觉。

只有见过了无数千奇百怪的题目,摸熟了无数个解题的套路,当你在这个过程中,在某个瞬间体会到了“万变不离其宗”的时候,在自信心经历过建立、崩塌、再建立的过程后,在把参考答案真的只是当做参考的时候,你就可以淡定的说出:哦,这题啊,我没见过,但是我知道怎么去做。

就像是五年前我拿到这个题的时候,我经过一番研究,还是答错了。

五年后,再次遇到这个题的瞬间,我还是不知道答案,但是我的内心一点都不慌。

在学习编程的路上,这样的“陷阱题”真的太多太多了,难的不是回答出你被背下的标准答案,难的是你知道标准答案是怎么来的。

这就是我从“是复用还是移除”这个问题带给我的思考。

我觉得我其实是在试图给你阐述一种学习的方法,因为我也没有悟透,所以总感觉有点词不达意,但是我想要表述的都说完了,剩下的,我自己接着悟吧。

20240421175434.png

翻了一下,我过往还是写了很多线程相关的文章的。

都放在这里,作为一个合订版吧:

《有的线程它死了,于是它变成一道面试题》

《关于多线程中抛异常的这个面试题我再说最后一次!》

《如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。》

《填个坑!再谈线程池动态调整那点事。》

《每天都在用,但你知道 Tomcat 的线程池有多努力吗?》

《这个队列的思路真的好,现在它是我简历上的亮点了。》

《虽然是我遇到的一个棘手的生产问题,但是我写出来之后,就是你的了。》

《面试官:你给我说一下线程池里面的几把锁。》

《Dubbo 2.7.5在线程模型上的优化》

《面试官问我知不知道异步编程的Future。》

《面试官问我知不知道CompletionService?》

《1000 多个并发线程,10 台机器,每台机器 4 核,设计线程池大小。》

《要我说,多线程事务它必须就是个伪命题!》

《Doug Lea在J.U.C包里面写的BUG又被网友发现了。》

《“借助同步”这个理念在 FutureTask 里面的应用。》

《面试官:Java如何绑定线程到指定CPU上执行?》

《别问了,我真的不喜欢 @Asyn 这个注解!》

《看完JDK并发包源码的这个性能问题,我惊了!》

《什么是高并发下的请求合并?》

《CompletableFuture 的那点事儿》

《看起来是线程池的BUG,但是我认为是源码设计不合理。》

《喜提JDK的BUG一枚!多线程的情况下请谨慎使用这个类的stream遍历。》

《听我一句劝,业务代码中,别用多线程。》

《面试官:一个 SpringBoot 项目能处理多少请求?(小心有坑)》

《线程池参数千万不要这样设置》

《刺激,线程池的一个BUG直接把CPU干到100%了。》

《这里有线程池、局部变量、内部类、静态嵌套类和一个莫得名堂的引用,哦,还有一个坑!》

《看到一个魔改线程池,面试素材加一!》

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

如果里面的某一篇曾经帮助过你,安排一个一键三连就行了。


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK