翻译过来有类同的含义:
所以 KeyAffinityExecutor 翻译过来就是 key 类同的线程池,当你明白它的功能和作用范围后会觉得这个名字取的是针不戳。
接着是调用了 KeyAffinityExecutor 对象的 executeEx 方法,可以多传入一个参数,这个参数就是区分某一类相同任务的维度,比如我这里就给的是 name 字段。
从使用案例上看来,可以说封装的非常好,开箱即用。
KeyAffinityExecutor用法
先说说这个类的用法吧。
其对应的开源项目地址是这个:
如果你想把它用起来,得引入下面这个 maven 地址:
com.github.phantomthief
more-lambdas
0.1.55
复制代码
其核心代码是这个接口:
com.github.phantomthief.pool.KeyAffinityExecutor
这个接口里面有大量的注释,大家可以拉下来看一下。
我这里主要给大家看一下接口上面,作者写的注释,他是这样介绍自己的这个工具的。
这是一个按指定的 Key 亲和顺序消费的线程池。
KeyAffinityExecutor 是一个特殊的任务线程池。
它可以确保投递进来的任务按 Key 相同的任务依照提交顺序依次执行。在既要通过并行处理来提高吞吐量、又要保证一定范围内的任务按照严格的先后顺序来运行的场景下非常适用。
KeyAffinityExecutor 的内建实现方式,是将指定的 Key 映射到固定的单线程线程池上,它内部会维护多个(数量可配)这样的单线程线程池,来保持一定的任务并行度。
需要注意的是,此接口定义的 KeyAffinityExecutor,并不要求 Key 相同的任务在相同的线程上运行,尽管实现类可以按照这种方式来实现,但它并非一个强制性的要求,因此在使用时也请不要依赖这样的假定。
很多人问,这和自己使用一个线程池的数组,并通过简单取模的方式来实现有什么区别?
事实上,大多数场景的确差异不大,但是当数据倾斜发生时,被散列到相同位置的数据可能会因为热点倾斜数据被延误。
本实现在并发度较低时(阈值可设置),会挑选最闲置的线程池投递,尽最大可能隔离倾斜数据,减少对其它数据带来的影响。
在作者的这段介绍里面,简单的说明了该项目的应用场景和内部原理,和我们前面分析的差不多。
除此之外,还有两个需要特别注意的地方。
第一个地方是这里:
作为区分的任务维度的对象,如果是自定义对象,那么一定要重写其 hashCode、equals,以确保可以起到标识作用。
这一处的提醒就和 HashMap 的 key 如果是对象的话,应该要重写 hashCode、equals 方法的原因是一样一样的。
编程基础,只提一下,不多赘述。
第二个地方得好好说一下,属于他的核心思想。
他没有采用简单取模的方式,因为在简单取模的场景上,数据是有可能发生倾斜的。
我个人是这样理解作者的思路的。
首先说明一下取模的数据倾斜是咋回事,举个简单的例子:
上面的代码片段中,我加入了一个新角色“摸鱼大师”。同时给对象新增了一个 id 字段。
假设,我们对 id 字段用 2 取余:
那么会出现的情况就是大师和富贵对应的 id 取余结果都是 1,它们将同用一个线程池。
很明显,由于大师的频繁操作,导致“摸鱼”变成了热点数据,从而导致编号为 0 的连接池发了倾斜,进而影响到了富贵的正常工作。
而 KeyAffinityExecutor 的策略是什么样的呢?
它会挑选最闲置的线程池进行投递。
怎么理解呢?
还是上面的例子,如果我们构建这样的线程池:
KeyAffinityExecutor executorService =
KeyAffinityExecutor.newSerializingExecutor(3, 200, “MY-POOL-%d”);
复制代码
第一个参数 3,代表它会在这里线程池里面构建 3 个只有一个线程的线程池。
那么当用它来提交任务的时候,由于维度是 id 维度,我们刚好三个 id,所以刚好把这个线程池占满:
这个时候是不存在数据倾斜的。
但是,如果我把前面构建线程池的参数从 3 变成 2 呢?
KeyAffinityExecutor executorService =
KeyAffinityExecutor.newSerializingExecutor(2, 200, “MY-POOL-%d”);
复制代码
提交方式不变,里面加上对 id 为 1 和 2 的任务延迟的逻辑,目的是观察 id 为 3 的数据怎么处理:
毋庸置疑,当提交执行大师的摸鱼操作的时候线程池肯定不够用了,怎么办?
这个时候,根据作者描述“会挑选最闲置的线程池投递”。
我用这样的数据来说明:
所以,当执行大师摸鱼操作的时候,会去从仅有的两个选项中选一个出来。
怎么选?
谁的并发度低,就选谁。
由于有延迟时间在任务里面,所以我们可以观察到执行富贵的线程的并发度是 5,而执行旺财的线程的并发度是 6。
因此执行大师的摸鱼操作的时候,会选择并发度为 5 的线程进行处理。
这个场景下就出现了数据倾斜。但是倾斜的前提发生了变化,变成了当前已经没有可用线程了。
所以,作者说“尽最大可能隔离倾斜数据”。
这两个方案最大的差异就是对线程资源的利用程度,如果是单纯的取模,那么有可能出现发生数据倾斜的时候,还有可用线程。
如果是 KeyAffinityExecutor 的方式,它可以保证发生数据倾斜的时候,线程池里面的线程一定是已经用完了。
然后,你再品一品这两个方案之间的细微差异。
KeyAffinityExecutor源码
源码不算多,一共就这几个类:
但是他的源码里面绝大部分都是 lambdas 的写法,基本上都是函数式编程,如果你对这方面比较薄弱的话那么看起来会比较吃力一点。
如果你想掌握其源码的话,我建议是把项目拉到本地,然后从他的测试用例入手:
我给大家汇报一下我看到的一些关键的地方,方便大家自己去看的时候梳理思路。
首先肯定是从它的构造方法入手,每一个入参的含义作者都标注的非常清楚了:
假设我们的构造函数是这样的,含义是构建 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
关键的地方就是这个对象里面的 concurrency 变量。
还记得最前面说的“挑选最闲置的执行器(线程池)”这句话吗?
怎么判断是否闲置?
靠的就是 concurrency 变量。
其对应的代码在这:
com.github.phantomthief.pool.impl.KeyAffinityImpl#select
能走到断点的地方,说明当前这个 key 是之前没有被映射过的,所以需要为其指定一个线程池。
而指定这个线程池的操作,就是循环这个 all 集合,集合里面装的就是 ValueRef 对象:
所以,comparingInt(ValueRef::concurrency) 方法就是在选当前所有的线程池,并发度最小的一个。
如果这个线程池从来没有用过或者目前没有任务在使用,那么并发度必然是 0 ,所有会被选出来。
如果所有线程池正在被使用,就会选 concurrency 这个值最低的线程池。
我这里只是给大家说一个大概的思路,如果要深入了解的话,自己去翻源码去。
如果你非常了解 lambdas 的用法的话,你会觉得写的真的很优雅,看起来很舒服。
如果你不了解 lambdas 的话…
那你还不赶紧去学?
另外我还发现了两个熟悉的东西。
朋友们,请看这是什么:
这难道不就是线程池参数的动态调整吗?
第二个是这样的:
RabbitMQ 里面的动态调整我也写过啊,也是强调过这三处地方:
-
增加 {@link #setCapacity(int)} 和 {@link #getCapacity()}
-
{@link #capacity} 判断边界从 == 改为 >=
-
部分 signal() 信号触发改为 signalAll()
另外作者还提到了 RabbitMQ 的版本里面会有导致 NPE 的 BUG 的问题。
这个就没细研究了,有兴趣的可以去对比一下代码,就应该能知道问题出在哪里。
说说 Dubbo
为什么要说一下 Dubbo 呢?
因为我似乎在 Dubbo 里面也发现了 KeyAffinityExecutor 的踪迹。
为什么说是似乎呢?
因为最终没有被合并到代码库里面去。
其对应的链接是这里:
这一次提交一共提交了这么多文件:
里面是可以找到我们熟悉的东西:
其实思路都是一样的,但是你会发现即使是思路一样,但是两个不同的人写出来的代码结构还是很不一样的。
Dubbo 这里把代码的层次分的更加明显一点,比如定义了一个抽象的 AbstractKeyAffinity 对象,然后在去实现了随机和最小并发两种方案。
在这些细节处上是有不同的。
但是这个代码的提供者最终没有用这些代码,而是拿出了一个替代方案:
在这一次提交里面,他主要提交了这个类:
org.apache.dubbo.common.threadpool.serial.SerializingExecutor
这个类从名字上你就知道了,它强调的是串行化。
带大家看看它的测试用例,你就知道它是怎么用的了:
首先是它的构造方法入参是另外一个线程池。
然后提交任务的时候用 SerializingExecutor 的 execute 方法进行提交。
在任务内部,干的事就是从 map 里面取出 val 对应的 key ,然后进行加 1 操作再放回去。
大家都知道上面的这个操作在多线程的情况是线程不安全的,最终加出来的结果一定是小于循环次数的。
但是,如果是单线程的情况下,那肯定是没问题的。
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip1024b (备注Java)
分享
这次面试我也做了一些总结,确实还有很多要学的东西。相关面试题也做了整理,可以分享给大家,了解一下面试真题,想进大厂的或者想跳槽的小伙伴不妨好好利用时间来学习。学习的脚步一定不能停止!
Spring Cloud实战
Spring Boot实战
面试题整理(性能优化+微服务+并发编程+开源框架+分布式)
一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
1312828)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip1024b (备注Java)
[外链图片转存中…(img-H3fSiLGf-1713001312828)]
分享
这次面试我也做了一些总结,确实还有很多要学的东西。相关面试题也做了整理,可以分享给大家,了解一下面试真题,想进大厂的或者想跳槽的小伙伴不妨好好利用时间来学习。学习的脚步一定不能停止!
[外链图片转存中…(img-LCVPGVm2-1713001312829)]
Spring Cloud实战
[外链图片转存中…(img-cI1TeWiu-1713001312829)]
Spring Boot实战
[外链图片转存中…(img-Tnxbtb9w-1713001312829)]
面试题整理(性能优化+微服务+并发编程+开源框架+分布式)
一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
[外链图片转存中…(img-8zVLc94s-1713001312830)]