关于后端:这玩意比ThreadLocal叼多了吓得why哥赶紧分享出来

47次阅读

共计 8770 个字符,预计需要花费 22 分钟才能阅读完成。

这是 why 哥的第 70 篇原创文章

从 Dubbo 的一次提交开始

故事得从前段时间翻阅 Dubbo 源码时,看到的一段代码讲起。

这段代码就是这个:

org.apache.dubbo.rpc.RpcContext

应用 InternalThreadLocal 晋升性能。

置信作为一个程序猿,都会被 improve performance(晋升性能)这样的字眼抓住眼球。

心里开始痒痒的,必须要一探到底。

刚看到这段代码的时候,我就想:既然他是要晋升性能,那阐明之前的货色体现的不太好。

那之前的货色是什么?

通过长时间的推理、周密的剖析,我大胆的猜测到之前的货色就是:ThreadLocal。

来,带大家看一下:

果不其然,我真是太厉害了。

2018 年 5 月 15 日的提交:New threadLocal provides more performance. (#1745)

能够看到这次提交的前面跟了一个数字:1745。它对应一个 pr,链接如下:

https://github.com/apache/dubbo/pull/1745

在这个 pr 外面还是有很多乏味的货色的,出场人物一个比一个骚,文章的最初带大家看看。

无能啥用?

在说 ThreadLocal 和 InternalThreadLocal 之前,还是先讲讲它们是干啥用的吧。

InternalThreadLocal 是 ThreadLocal 的增强版,所以他们的用处都是一样的,一言蔽之就是:传递信息。

你设想你有一个场景,调用链路十分的长。当你在其中某个环节中查问到了一个数据后,最初的一个节点须要应用一下。

这个时候你怎么办?你是在每个接口的入参中都加上这个参数,传递进去,而后只有最初一个节点用吗?

能够实现,然而不太优雅。

你再想想一个场景,你有一个和业务没有一毛钱关系的参数,比方 traceId,纯正是为了做日志追踪用。

你加一个和业务无关的参数一路透传干啥玩意?

通常咱们的做法是放在 ThreadLocal 外面,作为一个全局参数,在以后线程中的任何一个中央都能够间接读取。当然,如果你有批改需要也是能够的,视需要而定。

绝大部分的状况下,ThreadLocal 是实用于读多写少的场景中。

举三个框架源码中的例子,大家品一品。

第一个例子:Spring 的事务。

在我的晚期作品《事务没回滚?来,咱们从景象到原理一起剖析一波》外面,我已经写过:

Spring 的事务是基于 AOP 实现的,AOP 是基于动静代理实现的。所以 @Transactional 注解如果想要失效,那么其调用方,须要是被 Spring 动静代理后的类。

因而如果在同一个类外面,应用 this 调用被 @Transactional 注解润饰的办法时,是不会失效的。

为什么?

因为 this 对象是未经动静代理后的对象。

那么咱们怎么获取动静代理后的对象呢?

其中的一个办法就是通过 AopContext 来获取。

其中第三步是这样获取的:AopContext.currentProxy();

而后我还十分高冷的(咦,想想就感觉耻辱)说了句:对于 AopContext 我多说几句。

看一下 AopContext 外面的 ThreadLocal:

调用 currentProxy 办法时,就是从 ThreadLocal 外面获取以后类的代理类。

那他是怎么放进去的呢?

我高冷的第二句是这样说的:

对应的代码地位如下:

能够看到,通过一个 if 判断,如果为 true,则调用 AopContext.setCurrentProxy 办法,把代理对象放到 AopContext 外面去。

而这个 if 判断的配置默认是 false,所以须要通过刚刚说的配置批改为 true,这样 AopContext 才会失效。

附送一个知识点给你,不客气。

第二个例子:mybatis 的分页插件,PageHelper。

应用办法非常简单,从官网上截个图:

这里它为什么说:紧跟着的第一个 select 办法会被分页。

或者说:什么状况下会导致不平安的分页?

来,就当是一个面试题,并且我给你提醒了:从 ThreadLocal 的角度去答复。

其实就是因为 PageHelper 办法应用了动态的 ThreadLocal 参数,分页参数和线程是绑定的:

如果咱们写出上面这样的代码,就是不平安的用法:

这种状况下因为 param1 存在 null 的状况,就会导致 PageHelper 生产了一个分页参数,然而没有被生产,这个参数就会始终保留在这个线程上,也就是放在线程的 ThreadLocal 外面。

当这个线程再次被应用时,就可能导致不该分页的办法去生产这个分页参数,这就产生了莫名其妙的分页。

下面这个代码,应该写成上面这个样子:

这种写法,就能保障平安。

核心思想就一句话:只有你能够保障在 PageHelper 办法调用后紧跟 MyBatis 查询方法,这就是平安的。

因为 PageHelper 在 finally 代码段中主动革除了 ThreadLocal 存储的对象。

就算代码在进入 Executor 前产生异样,导致线程不可用的状况,比方常见的接口办法名称和 XML 中的不匹配,导致找不到 MappedStatement,因为主动革除,也不会导致 ThreadLocal 参数被谬误的应用。

所以,我看有的人为了保险起见这样去写:

怎么说呢,这个代码 ….

第三个例子:Dubbo 的 RpcContext。

RpcContext 这个对象外面保护了两个 InternalThreadLocal,别离是寄存 local 和 server 的上下文。

也就是咱们说的增强版的 ThreadLocal:

作为一个 Dubbo 利用,它既可能是发动申请的消费者,也可能是接管申请的提供者。

每一次发动或者收到 RPC 调用的时候,上下文信息都会发生变化。

比如说:A 调用 B,B 调用 C。这个时候 B 既是消费者也是提供者。

那么当 A 调用 B,B 还是没调用 C 之前,RpcContext 外面保留的是 A 调用 B 的上下文信息。

当 B 开始调用 C 了,阐明 A 到 B 之前的调用曾经实现了,那么之前的上下文信息就应该革除掉。

这时 RpcContext 外面保留的应该是 B 调用 C 的上下文信息。否则会呈现上下文净化的状况。

而这个上下文信息外面的一部分就是通过 InternalThreadLocal 寄存和传递的,是 ContextFilter 这个拦截器保护的。

ThreadLocal 在 Dubbo 外面的一个利用就是这样。

当然,还有很多很多其余的开源框架都应用了 ThreadLocal。

能够说应用频率十分的高。

什么?你说你用的少?

那可不咋的,人家都给你封装好了,你当个黑盒,开箱即用。

其实你用了,只是你不晓得而已。

强在哪里?

后面说了 ThreadLocal 的几个利用场景,那么这个 InternalThreadLocal 到底比 ThreadLocal 强在什么中央呢?

先说论断。

答案其实就写在类的 javadoc 上:

InternalThreadLocal 是 ThreadLocal 的一个变种,当配合 InternalThread 应用时,具备比一般 Thread 更高的拜访性能。

InternalThread 的外部应用的是数组,通过下标定位,十分的快。如果遇得扩容,间接数组扩充一倍,完事。

而 ThreadLocal 的外部应用的是 hashCode 去获取值,多了一步计算的过程,而且用 hashCode 必然会遇到 hash 抵触的场景,ThreadLocal 还得去解决 hash 抵触,如果遇到扩容,扩容之后还得 rehash , 这可不得慢吗?

数据结构都不一样了,这其实就是这两个类的本质区别,也是 InternalThread 的性能在 Dubbo 的这个场景中比 ThreadLocal 好的根本原因。

而 InternalThread 这个设计思维是从 Netty 的 FastThreadLocal 中学来的。

本文次要聊聊 InternalThread,然而我心愿的是大家能学到这个类的思维,而不是用法。

首先,咱们先搞个测试类:

public class InternalThreadLocalTest {private static InternalThreadLocal<Integer> internalThreadLocal_0 = new InternalThreadLocal<>();

    public static void main(String[] args) {new InternalThread(() -> {for (int i = 0; i < 5; i++) {internalThreadLocal_0.set(i);
                Integer value = internalThreadLocal_0.get();
                System.out.println(Thread.currentThread().getName()+":"+value);
            }
        }, "internalThread_have_set").start();

        new InternalThread(() -> {for (int i = 0; i < 5; i++) {Integer value = internalThreadLocal_0.get();
                System.out.println(Thread.currentThread().getName()+":"+value);
            }
        }, "internalThread_no_set").start();}
}

下面代码的运行后果是这样的:

因为 internalThread_no_set 这个线程没有调用 InternalThreadLocal 类的 set 办法,所以调用 get 办法输入为 null。

外面次要用到了 set、get 这一对办法。

上面借助 set 办法,带大家看看外部原理(先说一下,为了不便截图,我有可能会调整一下源码程序):

首先是判断了传进来的 value 是否是 null 或者是 UNSET,如果是则调用 remove 办法。

null 是好了解的。这个 UNSET 是个什么鬼?

依据 UNSET 能很容易的找到这个中央:

原来是 InternalThreadLocalMap 初始化的时候会填充 UNSET 对象。

所以,如果 set 的对象是 UNSET,咱们能够认为是须要把以后地位上的值替换为 UNSET,也就是 remove 掉。

而且,咱们还看到了两个要害的信息:

1.InternalThreadLocalMap 尽管名字叫做 Map,然而它挂羊头卖狗肉,其实外面保护的是一个数组。

2. 数组初始化大小是 32。

接着咱们回去看 else 分支的逻辑:

调用的是 InternalThreadLocalMap 对象的 get 办法。

而这个办法外面的两个 get 就乏味了。

能走到 fastGet 办法的,阐明以后线程是 InternalThread 类,间接能够获取到类外面的 InternalThreadLocalMap。

如果走到 slowGet 了,则回退到原生的 ThreadLocal,只是在原生的外面,我还是放的 InternalThreadLocalMap:

所以,其实线程上绑定的数据都是放到 InternalThreadLocalMap 外面的,不论你操作什么 ThreadLocal,实际上都是操作的 InternalThreadLocalMap。

那问题来了,你感觉一个叫做 fastGet,一个叫做 slowGet。这个快慢,指的是 get 什么货色的快慢?

对咯,就是获取 InternalThreadLocalMap。

InternalThreadLocalMap 在 InternalThread 外面是一个变量保护的,能够间接通过 InternalThread.threadLocalMap() 取得:

标号为 ① 的中央是获取,标号为 ② 的中央是设置。

都是一步到位,操作起来十分的不便。

这是 fastGet。

而 slowGet 是从 ThreadLocal 中获取:

这里的 get,就是原生 ThreadLocal 的 get 办法,一眼望去,就简单多了:

标号为 ① 的中央,首先计算 hash 值,而后拿着 hash 值去数组外面取数据。如果取出来的数据不是咱们想要的数据,则到标号为 ② 的逻辑外面去。

那么我问你,除了这个地位上的值真的为 null 外,还有什么起因会导致我拿着计算出来的 hash 值去数组外面取数据取不到?

就是看你熟不相熟 ThreadLocal 对 hash 抵触的解决形式了。

那么这个问题略微的降级一下就是:你晓得哪些 hash 抵触的解决方案呢?

1. 凋谢定址法。

2. 链式地址法。

3. 再哈希法。

4. 建设公共溢出区。

咱们十分相熟的 HashMap 就是采纳的链式地址法解决 hash 抵触。

而 ThreadLocal 用的就是凋谢定址法中的线性探测。

所谓线性探测就是,如果某个地位的值曾经存在了,那么就在原来的值上往后加一个单位,直至不产生哈希抵触,就像这样的:

下面的动图就是须要在一个长度为 7 的数组外面,再放一个进过 hash 计算后为下标为 2 的数据,然而该地位上有值,也就是产生了 hash 抵触。

于是解决 hash 抵触的办法就是一次次的往后移,直到找到没有抵触的地位。

所以,当咱们取值的时候如果产生了 hash 抵触也须要往后查问,这就是下面标号为 ③ 的 while 循环代码的其中一个目标。

当然还有其余目标,就暗藏在 440 行的 expungeStaleEntry 办法外面。不是本文重点,就不多说了。

然而如果你不晓得这个办法,你肯定要去查阅一下相干的材料,有可能会在肯定水平上扭转你印象中的:用 ThreadLocal 会导致内存透露的危险。

至多,你能够晓得 JDK 为了防止内存透露的问题,是做了本人的最大致力的。

好了,不扯远了,说回来。

从下面咱们晓得了,从 ThreadLocal 中获取 InternalThreadLocalMap 会经验如下步骤:

1. 计算 hash 值。

2. 判断通过 hash 值是否能间接获取到指标对象。

3. 如果没有获取到指标对象则往后遍历,直至获取胜利或者循环完结。

比从 InternalThread 外面获取 InternalThreadLocalMap 简单多了。

当初你晓得了 fastGet/slowGet 这个两个办法中的快慢,指的是从两个不同的 ThreadLocal 中获取 InternalThreadLocalMap 的操作的快慢。而快慢的根本原因是数据结构的差别。

好,当初咱们获取到 InternalThreadLocalMap 了,接着看 set 办法:

标号为 ① 的中央就是往 InternalThreadLocalMap 这个数组中寄存咱们传进来的 value。

存的时候分为两种状况。

标号为 ② 的中央是数组容量还够,能放进去,那么能够间接设置。

标号为 ③ 的中央是数组容量不够用,须要扩容了。

这里抛出两个问题:

扩容是怎么扩的?

数组下标是怎么来的?

先看问题一,怎么扩容的?

看源码:

怎么样,看到的第一眼你想到了什么?

大声的说进去,是不是想到了 HashMap 外面的一段源码?

和 HashMap 外面的位运算殊途同归。

在 InternalThreadLocalMap 中扩容就是变成原来大小的 2 倍。从 32 到 64,从 64 到 128 这样。

扩容实现之后把原数组外面的值拷贝到新的数组外面去。

而后剩下的局部用 UNSET 填充。最初把咱们传进来的 value 放到指定地位上。

再看看问题二,数组下标怎么来的?也就是这个 index:

从上往下看,能够看到最初,这个 index 实质上是一个 AtomicInteger。

次要看一下标号为 ① 的中央。

index 每次都是加一,对应的是 InternalThreadLocalMap 里的数组下标。

第一眼看到的时候,外面的 if 判断 index<0 我是能够了解的,避免溢出嘛。

然而上面在抛出异样之前,还调用了 decrementAndGet 办法,又把值减回去了。

你说这是为什么?

开始我没想明确。然而有天早晨睡觉之前,电光火石一瞬间我想明确了。

如果不把值减回去,加一的代码还在一直的被调用,那么这个 index 实践上讲是有可能又被加到负数的,这一点你能明确吧?

为什么我说实践上呢?

int 的取值范畴是 [-2147483648 到 2147483647]。

如果 int 从 0 减少,始终溢出到 -2147483648,再从 -2147483648 加到 0,两头有 4294967295 个数字。

一个数字对应数组的一个下标,就算外面放的是一个字节的 boolean 型,那么大略也就是 4T 的内存吧。

所以,我感觉这是实践上的。

到这一步,咱们曾经实现了从 Thread 外面取出 InternalThreadLocalMap,并且往里面放数据的操作。

最初,InternalThreadLocal 的 set 办法只剩下最初一行代码,咱们还没说:

就是 setIndexedVariable 办法返回 true 后,会执行 addToVariablesToRemove 办法。

这个办法其实就是在数组的第一个地位保护以后线程外面的所有的 InternalThreadLocalMap。

这里的关键点其实就是这个变量:

static final,能保障 VARIABLE_TO_REMOVE_INDEX 恒等于 0,也就是数组的第一个地位。

用示例程序,给大家演示一下,它第一个地位放的货色:

在第 21 行打上断点,而后看一下执行完 addToVariablesToRemove 办法后,InternalThreadLocalMap 数组的状况:

诚不欺你,第 0 个地位上放的是所有的 InternalThreadLocal 的汇合。

所以,咱们看一下它的 size 办法,就能明确这里为什么要减一了:

那么在第一个地位保护线程外面所有的 InternalThreadLocal 汇合的用途是什么?

看看它的 removeAll 办法:

间接从数组中取出第 0 个地位的数据,而后循环干掉它就行。

set 办法就剖析到这里啦,算是保姆级的一行行手把手教学了吧。

借助这个办法,也带大家看了内部结构。

点到为止。get 办法很简略的,大家记得本人去看一下哦。

咱们再看一下这次 pr 提交的货色:

咱们看看这四个线程池有什么变动:

就是换了工厂类。

换工厂类的目标是什么呢?

newThread 的时候,new 的是 InternalThread 线程。

好一个偷天换日。

后面咱们说了,要用革新版的 ThreadLocal,必须要配合 InternalThread 线程应用,否则就会进化为原生的 ThreadLocal。

其实,Dubbo 这次提交,革新的货色并不多。要害的、外围的代码都是从 Netty 那边 copy 过去的。

我这就是一个引子,大家能够再去看看 Netty 的 FastThreadLocal 类。

对于这次 pr 提交

接下来又是 get 奇怪知识点的时刻了。

后面说了,这个 pr 外面出场人物一个比一个“骚”,这一节我带大家看一下,是怎么个“骚”法。

https://github.com/apache/dubbo/pull/1745·

首先是 pr 的提交者,carryxyh 同学的代码在 2018 年 5 月 15 日的时候被 merge 了:

失常来说,carryxyh 同学对于开源社区的一次奉献就算是完满完结了,简历上又能够浓墨重彩的写上一小笔。

然而 15 天之后产生的事件,可能是他做梦也想不到的。

那一天,一个叫做 normanmaurer 的哥们在这个 pr 上面说了一句话:

先不论他说的啥。

你晓得他是谁吗?他在我之前的文章中其实也呈现过的。

他就是 Netty 的爸爸。

他是这样说的:

他的意思就是说:

哥们,你这个货色我怎么感觉是从 Netty 那边弄过去的呢?本着开源的精力,你间接弄过去是没有问题的,然而你至多得依照规矩办事吧?得遵循 AL2 协定来。而且我甚至看到你在你的 pr 外面提到了 Netty。

至于这个 AL2 到底是什么,我是没有看明确的。

然而不重要,我就把它了解为一个给开源社区奉献代码时须要恪守的一个协定吧。

carryxyh 同学看到 Netty 的爸爸找他了,很快就回复了两条音讯:

carryxyh 同学说道:

老哥,我在 javadoc 外面提到了,我的灵感起源就是 Netty 的 FastThreadLocal 类。我写这个的目标就是通知所有看到这个类的敌人,这里的大部分代码来自 Netty。

那我除了在 javadoc 外面写上起源是 Netty 外,还须要做什么吗?还有你说的 AL2 是什么货色,你能不能通知我?

我肯定会尽快修复的。

这么一来一回,我大略明确这两个人在说什么了。

Netty 的爸爸说你用了我的代码,这齐全没有问题,然而你得遵循一个协定哦。

carryxyh 同学说,我曾经在 javadoc 里说了我这部分代码就是来自 Netty 的,我真不知道还该做什么,请你通知我。

Netty 的爸爸回复了一个链接:

他说:你就看着这个链接,依照它整就行。

他发的这个链接我看了,怎么说呢,十分的哇塞,纯英文,内容十分的多。先不关注是啥吧,反正 carryxyh 同学必定会认真浏览的。

在 carryxyh 同学没有回复之前,一个叫做 justinmclean 的哥们进去对 Netty 的爸爸谈话了:


他说:实际上,ALv2 许可证曾经不实用了,有新的政策进去了,以新的告诉和许可证文件为准。

这个哥们既然这样轻描淡写的说有新政策了。我潜意识就感觉他不是一个个别人,于是我查了一下:

主席、30 年 +、PMC、导师 ……

还愣着干嘛,开始端茶吧。

大佬都进去了,接下来的对话大略也就是围绕着怎么才是一次合乎开源规范的提交。

主席说,到底需不需要申明版权,得看代码的革新点多不多。

Netty 的爸爸说:据我所知,除了包名和类名不一样外,其余的根本没有变动。

最终 carryxyh 同学说把 Netty 的 FastThreadLocal 的文件头弄过去,是不是就完事了,

主席说:没故障,我就是这样想的。

所以,咱们当初在 Dubbo 的 InternalThreadLocal 文件的最开始,还能够看到这样的 Netty 的阐明:

这个货色,就是这样来的,不是轻易写的,是有考究。

好了,这应该是我所有文章中呈现过的第 9 个用不上的傻吊知识点了吧。送给你,不用客气。

好了,这次的文章就到这里啦。

正文完
 0