大家好,看到一个神奇的修改线程池,面试材料增加了一个相信很多的网友都不是很明白,包括也是一样,不过没有关系,接下来就来为大家分享关于看到一个神奇的修改线程池,面试材料增加了一个和的一些知识点,大家可以关注收藏,免得下次来找不到哦,下面我们开始吧!
为了介绍这个线程池,我先给大家举一个场景,方便理解。
以下面的表情为例。
假设我们有两个程序员,我们称他们为福贵和旺财。
上面的表情包就是这两个程序员一天工作的写照,用程序来表达。
首先,我们创建一个对象来表示程序员当时正在做什么:
公共类CoderDoSomeThing { 私有字符串名称;私有字符串doSomeThing;公共CoderDoSomeThing(String name, String doSomeThing) { this.name=name; this.doSomeThing=doSomeThing;然后用代码来描述一下Fugui和Wangcai的作用:
public class NbThreadPoolTest { public static void main(String[] args) { CoderDoSomeThing rich1=new CoderDoSomeThing(‘rich’, ‘Start Idea’); } CoderDoSomeThing rich2=new CoderDoSomeThing(‘rich’, ‘获取数据库,连接tomcat,crud突然输出’); CoderDoSomeThing rich3=new CoderDoSomeThing(‘富gui’, ‘嘴角疯狂上扬’); CoderDoSomeThing rich4=new CoderDoSomeThing(‘富gui’, ‘接口访问错误’); CoderDoSomeThing rich5=new CoderDoSomeThing(‘富gui’, ‘心态崩了,卸载Idea’); CoderDoSomeThing www1=new CoderDoSomeThing(‘旺才’, ‘开始创意’); CoderDoSomeThing www2=new CoderDoSomeThing(‘旺才’, ‘建数据库,连tomcat,增删改查输出’) ; CoderDoSomeThing www3=new CoderDoSomeThing(‘旺财’, ‘嘴角疯狂上扬’); CoderDoSomeThing www4=new CoderDoSomeThing(‘旺才’, ‘接口访问错误’); CoderDoSomeThing www5=new CoderDoSomeThing(‘旺才’, ‘Mindset崩溃了,卸载Idea’); }} 对变量名称的简单解释表明我已经仔细考虑过。
财富就是有钱的意思,所以变量名是rich。
繁荣的意思是汪汪汪,所以变量名为www。
如果你看一下我的类的名称,NbThreadPoolTest,你就会知道我要使用线程池。
实际情况中,有钱人和有钱人可以各做各的事,互不干涉,也就是各奔东西。
每个人都做自己的事,互不干涉。听起来可以使用线程池。
因此,我将程序修改为如下所示并使用了线程池:
公共类NbThreadPoolTest { 公共静态void main(String[] args) { ExecutorService executorService=Executors.newFixedThreadPool(5); } ListCoderDoSomeThing coderDoSomeThingList=new ArrayList(); coderDoSomeThingList.add(new CoderDoSomeThing(‘rich’, ‘Start Idea’)) ; coderDoSomeThingList.add(new CoderDoSomeThing(‘Rich’, ‘正在处理数据库,甚至tomcat,增删改查输出’)); coderDoSomeThingList.add(new CoderDoSomeThing(‘Rich’, ‘嘴角疯狂上扬’)); coderDoSomeThingList.add( new CoderDoSomeThing(‘Fugui’, ‘接口访问错误’)); coderDoSomeThingList.add(new CoderDoSomeThing(‘Fugui’, ‘心态崩了,卸载Idea’)); coderDoSomeThingList.add(new CoderDoSomeThing(‘旺才’, ‘开始创意’)); coderDoSomeThingList.add(new CoderDoSomeThing(‘旺财’, ‘建数据库,连tomcat,增删改查输出’)); coderDoSomeThingList.add(new CoderDoSomeThing(‘旺才’, ‘嘴角疯狂上扬’ ));编码coderDoSomeThing – { executorService.execute(() – { System.out.println(coderDoSomeThing.toString()); }); }); }}上面的程序将Fugui和Wangcai所做的所有事情封装到一个列表中。然后遍历列表并将内容,即“要做的事情”扔到线程池中。
那么上面的程序执行后,可能的输出如下:
乍一看没有问题。财富和财富都是同时做事的。
但如果你仔细观察,就会发现大家做事的顺序是错误的。
比如旺财就显得有点“精神分裂”。他一启动Idea,嘴角就开始疯狂上翘。
所以,在这里我可以引出我想要的东西。
我想要什么样的东西?
就是保证福贵和旺财同时做事,而且保证他们做事是按照一定的顺序的,就是按照我放入线程池的顺序执行。
用更正式的术语来描述它:
我需要这样一个线程池,它可以保证交付的任务按照一定的维度划分为任务,然后按照任务提交的顺序依次执行。这个线程池可以通过并行处理(多线程)来提高吞吐量,并保证一定范围内的任务按照严格的顺序运行。
用我之前的例子来说,“按照某个维度”就是一个人的名字,就是财富、财富的维度。
你做什么工作?
一顿分析
我该怎么办?
首先,我确信JDK线程池不能做到这一点。
因为从线程池原理来看,并行性和顺序性是无法同时满足的。
你明白我的意思吗?
例如,如果我想使用线程池来保证顺序,那么它看起来像这样:
线程池只有一个,可以保证顺序。
但这东西有意义吗?
有一定道理,因为它不占用主线程,但也没有多大意义,毕竟它阉割了重要的“多线程”能力。
那么在这种场景下我们如何提高并行能力呢?
等等,看来我们已经有了一个可以保证顺序的线程池了。
那如果我们横向扩展,多建几个,不就具备并行能力了吗?
那么前面提到的“按照某个维度”,如果有多个线程池只有一个线程,那么我也可以根据这个维度来映射“维度”和“每个线程池”。
用程序术语来说,它是这样的:
标注的地方有多个线程池,只有一个线程,是为了保证消费的顺序。
标注的地方通过一个map来映射人名和线程池的关系。这只是一个提示。例如,我们还可以使用用户编号取模的方法来定位对应的线程池。例如,如果用户数为奇数,则使用一个线程池,如果用户数为偶数,则使用另一个线程。
因此,没有必要像“某个维度”有多少数据就定义多少个只有一个线程的线程池。它们也可以重复使用。这里有一个轻微的扭曲。
标记的地方就是根据名字去map中找到对应的线程池。
从输出结果来看,没有任何错误:
看到这里,有的朋友会说:你这不是作弊吗?
你们不同意线程池吗?您已经创建了多个。
如果想从这个角度看问题,路就会窄。
您必须考虑拥有一个大型线程池,其中包含许多线程池,但其中只有一个线程。
这就打开了格局。
我上面写的方式是一个很简单的Demo,主要是介绍这个方案的思路。
我想介绍的就是一个基于这个想法的开源项目。
这是一家大公司的老板写的。我看了源代码,感到很惊讶:它写得太好了。
让我先给你一个用例和输出:
从案例来看,使用方法也非常简单。
与JDK原生用法的区别是我框定的部分。
首先,创建一个KeyAffinityExecutor对象来替换原生线程池。
KeyAffinityExecutor涉及到一个词,Affinity。
翻译过来有类似的意思:
所以KeyAffinityExecutor翻译过来就是一个类似于key的线程池。当你了解了它的作用和范围后,你就会觉得这个名字就是一根针。
接下来,调用KeyAffinityExecutor对象的executeEx方法。您可以再传入一个参数。该参数是区分同一任务的某种类型的维度。例如,我这里给出的是名称字段。
从使用案例来看,可以说包装非常好,开箱即用。
KeyAffinityExecutor用法
先说一下这个类的用法。
对应的开源项目地址是这样的:
https://github.com/PhantomThief/more-lambdas-java
如果你想使用它,你必须引入以下maven地址:
依赖groupIdcom.github.phantomthief/groupId artifactIdmore-lambdas/artifactId version0.1.55/version/dependency 核心代码是这个接口:
com.github.phantomthief.pool.KeyAffinityExecutor
这个界面有很多注释,你可以拉下来看一下。
这是一个按照指定的Key亲和性顺序进行消费的线程池。
KeyAffinityExecutor是一个特殊的任务线程池。
它保证使用相同Key提交的任务按照提交的顺序依次执行。非常适合需要通过并行处理来提高吞吐量并保证一定范围内的任务严格顺序运行的场景。
KeyAffinityExecutor 的内置实现将指定的Key 映射到固定的单线程线程池。它内部会维护多个(可配置数量)这样的单线程线程池,以保持一定程度的任务并行性。
需要注意的是,该接口定义的KeyAffinityExecutor并不要求与Key相同的任务运行在同一个线程上。实现类虽然可以这样实现,但是并不是强制要求,所以使用时不需要。请不要依赖这样的假设。
很多人问,这和使用线程池数组,通过简单的取模方法实现有什么区别?
其实大多数场景差别不大,但是当出现数据倾斜时,哈希到同一位置的数据可能会因为热点倾斜数据而出现延迟。
当并发量较低时(可以设置阈值),该实现会选择最空闲的线程池进行投递,尽可能隔离倾斜数据,减少对其他数据的影响。
此外,还有两个方面需要特别注意。
第一名在这里:
作为不同任务维度的对象,如果是自定义对象,则必须重写其hashCode和equals,以保证能够起到标识作用。
这里的提醒和HashMap的key是对象时要重写hashCode和equals方法的道理是一样的。
我只会提及编程的基础知识,而不会详细介绍。
第二部分需要仔细讨论,属于他的核心思想。
他没有使用简单模数的方法,因为在简单模数场景下,数据可能会出现偏差。
首先,让我们解释一下当模数据倾斜时会发生什么。我们举一个简单的例子:
在上面的代码片段中,我添加了一个新角色“鱼大师”。同时,对象中添加了一个id字段。
假设我们对id 字段使用模2:
那么会发生的情况是Master和Fugui对应的ID的余数结果都是1,并且他们会使用同一个线程池。
显然,由于master的频繁操作,“钓鱼”成为了热点数据,导致编号为0的连接池出现倾斜,从而影响了Fugui的正常工作。
而KeyAffinityExecutor的策略是什么?
它会选择最空闲的线程池进行交付。
怎么理解呢?
还是用上面的例子,如果我们构建一个这样的线程池:
KeyAffinityExecutor executorService=KeyAffinityExecutor.newSerializingExecutor(3, 200, ‘MY-POOL-%d’);第一个参数3表示将构建3个线程池,这个线程池中只有一个线程。
然后用它来提交任务时,由于维度是id维度,我们正好有3个id,所以线程池刚好满了:
此时不存在数据倾斜。
但是如果我将用于构建线程池的参数从3 更改为2 会怎样?
KeyAffinityExecutor executorService=KeyAffinityExecutor.newSerializingExecutor(2, 200, ‘MY-POOL-%d’);提交方式不变,增加了id为1、2的延迟任务逻辑。目的是观察id 为3 的数据处理后会发生什么情况:
不用说,当master的钓鱼操作提交并执行时,线程池肯定会不够用。我应该怎么办?
我用这个数据来说明:
因此,在执行主钓鱼操作时,您将选择仅有的两个选项之一。
如何选择?
选择并发数最低的人。
由于任务存在延迟时间,我们可以观察到执行Fugui的线程并发度为5,执行Wangcai的线程并发度为6。
因此,在执行master的钓鱼操作时,会选择并发数为5的线程进行处理。
在这种情况下就会出现数据倾斜。但倾斜的前提已经改变,目前没有可用的线程。
两种方案最大的区别在于线程资源的利用。如果是简单的取模,当出现数据倾斜时,可能还有可用的线程。
如果是KeyAffinityExecutor的话,可以保证当数据发生倾斜时,线程池中的线程一定已经被用完。
然后,您会品尝到这两个选项之间的细微差别。
KeyAffinityExecutor源码
源码不多,只有这几类:
但他的大部分源代码都是用lambdas编写的,基本上是函数式编程。如果你这方面比较薄弱,那就显得比较困难。
如果你想掌握它的源代码,我建议你将项目拉到本地并从它的测试用例开始:
https://github.com/PhantomThief/more-lambdas-java
我把我看到的一些要点给大家汇报一下,方便大家去看的时候整理一下思路。
假设我们的构造函数是这样的,也就是说构建3个线程池,只有一个线程,每个线程池的队列大小为200:
KeyAffinityExecutor executorService=KeyAffinityExecutor.newSerializingExecutor(3, 200, ‘WHY-POOL-%d’);首先我们需要找到构建“只有一个线程的线程池”的逻辑。
只是这个方法隐藏在构造函数中:
com.github.phantomthief.pool.KeyAffinityExecutorUtils#executor(java.lang.String, int)
这里可以看到我们一直提到的“只有一个线程的线程池”,并且还可以指定队列的长度:
该方法返回一个Supplier接口,稍后会用到。
接下来我们需要找到数字“3”体现在哪里?
它隐藏在构造函数的build方法中,最终会调用这个方法:
com.github.phantomthief.pool.impl.KeyAffinityImpl#KeyAffinityImpl
到时候可以在这个地方打个断点,然后用Debug看一下,就很清楚了:
关于这个框架部分的关键参数,我解释一下:
第一个是count参数,也就是我们定义的3。那么range(0,3) 就是0,1,2。
然后是supplier,就是我们前面提到的executor方法返回的supplier接口。可以看到它被封装在一个线程池中。
然后里面有一个非常关键的操作:map(ValueRef:new)。
这个操作中的ValueRef对象非常关键:
com.github.phantomthief.pool.impl.KeyAffinityImpl.ValueRef
关键点是这个对象中的并发变量。
还记得前面提到的那句话“挑选最空闲的执行器(线程池)”吗?
如何判断是否空闲?
这取决于并发变量。
对应的代码在这里:
com.github.phantomthief.pool.impl.KeyAffinityImpl#select
能够到达断点意味着当前的key之前没有被映射过,所以需要为其指定一个线程池。
指定这个线程池的操作是循环遍历all集合,其中包含ValueRef对象:
因此,compareInt(ValueRef:concurrency)方法就是在当前所有线程池中选择并发数最小的一个。
如果这个线程池从来没有被使用过或者当前没有任务在使用,那么并发度一定是0,全部都会被选择。
如果所有线程池都被使用,则将选择并发值最低的线程池。
我在这里只是给你一个总体的想法。如果想更深入的了解,可以自己去看源码。
如果你非常了解lambda的用法,你会觉得写得真的很优雅,看着很舒服。
如果您不了解lambda.
那你为什么不赶紧学起来呢?
此外,我还发现了两件熟悉的事情。
朋友们,请看一下这是什么:
这不就是线程池参数的动态调整吗?
第二个是这样的:
我也写过RabbitMQ的动态调整,也强调了这三点:
新增{@link #setCapacity(int)}和{@link #getCapacity()}{@link #capacity},将判断边界从==改为=部分signal(),将信号触发改为signalAll()。我不会详细研究这个。有兴趣的话可以对比一下代码,应该就能知道问题出在哪里了。
说说 Dubbo
为什么要讲Dubbo?
因为我似乎在Dubbo中发现了KeyAffinityExecutor的踪迹。
为什么说好像呢?
因为最终没有合并到代码库中。
对应的链接在这里:
https://github.com/apache/dubbo/pull/8975
本次提交一共提交了这么多文件:
里面我们可以找到我们熟悉的东西:
其实思路是一样的,但是你会发现即使思路一样,两个不同的人写出来的代码结构还是有很大的不同。
Dubbo这里把代码层次划分的比较清晰。例如,它定义了一个抽象的AbstractKeyAffinity对象,然后实现了两种方案:随机和最小并发。
这些细节上是有差异的。
但这段代码的提供者最终并没有使用这些代码,而是想出了一个替代方案:
https://github.com/apache/dubbo/pull/8999
在这次提交中,他主要提交了这个类:
org.apache.dubbo.common.threadpool.serial.SerializingExecutor
从这个类的名字就可以看出,它强调序列化。
让我给你展示它的测试用例,你就会知道它是如何使用的:
首先是它的构造函数参数是另一个线程池。
然后使用SerializingExecutor的execute方法提交任务。
在任务内部,我们所做的就是从map中取出val对应的key,然后加1放回去。
大家都知道,上面的操作在多线程环境下是线程不安全的,最终的结果一定小于循环次数。
不过,如果是单线程的情况,那肯定是没有问题的。
那么如何将线程池映射到单个线程呢?
SerializingExecutor 就是这样做的。
而且它的原理非常简单,只有几行核心代码。
首先它创建自己的队列:
提交的任务被放入队列中。
接下来,一一执行。
如何保证一一执行?
方法有很多种。这里它使用一个AtomicBoolean 对象来控制:
这样就实现了串行化多线程任务的场景。
让我疑惑的是,SerializingExecutor类目前在Dubbo中还没有使用场景。
但是,如果你要实现这样一个奇怪的功能,比如别人给了你一个线程池,但是你的流程中有一些考虑,需要序列化任务,这个时候你一定不要去动别人的线程池。那么你可以想到Dubbo,它有现成的、更优雅、高质量的解决方案。
最后说一句
这是给我所有读者的提示:
原创文章,作者:小su,如若转载,请注明出处:https://www.sudun.com/ask/112526.html
用户评论
丢了爱情i
这确实是一道很棒的面试题!能考察一个人的理解能力和实践经验。 这种魔改线程池的考点很多,比如如何控制并发数量、如何在不同任务之间进行调度等,真让人印象深刻
有9位网友表示赞同!
青衫故人
说实在的,这种魔改线程池代码看着就头大了😅 我还是更喜欢用的现成的线程池工具包! 太复杂了。
有12位网友表示赞同!
情字何解ヘ
面试官要是我肯定不会直接丢出这种题 😅 太难了!不过这种问题确实能考察理解能力。你说的对,应该引导面试者慢慢分析和思考这个问题
有16位网友表示赞同!
冷落了♂自己·
我觉得这是一个很有趣的题目! 通过魔改线程池可以更深入了解其内部机制,也能够锻炼动手能力。 分享一下你的代码实现?
有9位网友表示赞同!
烬陌袅
看到这个标题就觉得刺激了! 魔改线程池确实是一个很巧妙的面试題材,能考察一个人的编程技巧和解决问题的能力! 虽然有点复杂,但想想做成功的可能性就很让人兴奋啊
有7位网友表示赞同!
开心的笨小孩
真的看不明白你说的 "魔改线程池" 是什么? 我目前只听过基本的线程池。能不能简单解释一下你的意思?
有10位网友表示赞同!
眷恋
我觉得这个问题太过于注重细节了,面试官应该考察的是解决问题的思路和逻辑而不是死记硬背具体代码实现。 这个题目真的很有挑战性啊!
有11位网友表示赞同!
来瓶年的冰泉
面试题可以这样设计,但要注意引导性。 面试官应该先让应聘者了解线程池的基本结构,然后再逐渐引向“魔改”的过程。这样才能更好的考察应聘者的理解能力和学习能力!
有13位网友表示赞同!
汐颜兮梦ヘ
哇,这个题目太酷了! 我本来对线程池不太熟悉,现在感觉非常想深入学习一下! 分享一下你总结的魔改线程池的常见思路吗?
有10位网友表示赞同!
愁杀
面试题就该这么刁钻才行! 能让应聘者在紧张的情况下思考深度的问题。 这种"魔改线程池"确实是一个挑战,但我越来越喜欢这个新的面试方向了!
有11位网友表示赞同!
沐晴つ
我同意你的观点,这类面试题能够有效地考察一个人的实际能力和解决问题的能力。 就好像我们平时开发时遇到不合理的需求,也是需要灵活应用知识并创新性的进行调整的!
有5位网友表示赞同!
站上冰箱当高冷
我觉得这种魔改类型的题目比较适合一些经验较为丰富的程序员了,对于新手来说还是太考验逻辑思维能力了。
有8位网友表示赞同!
桃洛憬
看了你的分享,我越发觉得面试官确实应该注重引导和考察应聘者的思考过程而非单纯地评判代码的正确性。这样才能找到真正有潜质的优秀人才!
有18位网友表示赞同!
莫飞霜
这个 "魔改线程池" 听起来很有意思啊! 我想学习一下, 能够让我对 Java 的底层实现机制有了更深入的理解 !
有13位网友表示赞同!
巴黎盛开的樱花
面试题越来越套路了,这种“魔改”的思路让人感觉有点过于局限于某个特定的应用场景。 还是希望可以考察一类更加通用的问题,比如算法设计和数据结构等等吧!
有18位网友表示赞同!
哭着哭着就萌了°
我觉得这道题的设计很有意思,能够把一些看似简单的基础知识用到更复杂的问题中去,让人感受到线程池的深度。 但我觉得这种魔改思路可能需要一定的背景知识准备才能理解, 对于初学者来说确实比较难。
有10位网友表示赞同!
矜暮
“魔改”这个词让我感到好奇,其实我也有个关于线程池的小疑问: 当多个任务同时执行时,线程之间如何进行同步协调,避免数据冲突? 是不是这种“魔改线程池”会更加注重这些细节呢?
有8位网友表示赞同!