源码篇:ThreadLocal 的奇思妙想(万字图文)
前言
ThreadLocal 的文章在网上也有不少,但是看了一些后,理解起来总感觉有点绕,而且看了 ThreadLocal 的源码,无论是线程隔离、类环形数组、弱引用结构等等,实在是太有意思了!我必须也要让大家全面感受下其中所蕴含的那些奇思妙想! 所以这里我想写一篇超几儿通俗易懂解析 ThreadLocal 的文章,相关流程会使用大量图示解析,以证明:我是干货,不是水比!
ThreadLocal 这个类加上庞大的注释,总共也才七百多行,而且你把这个类的代码拷贝出来,你会发现,它几乎没有报错!耦合度极低!(唯一的报错是因为 ThreadLocal 类引用了 Thread 类里的一个包内可见变量,所以把代码复制出来,这个变量访问就报错了,仅仅只有此处报错!)
ThreadLocal 的线程数据隔离,替换算法,擦除算法,都是有必要去了解了解,仅仅少量的代码,却能实现如此精妙的功能,让我们来体会下 Josh Bloch 和 Doug Lea 俩位大神,巧夺天工之作吧!
一些说明
这篇文章画了不少图,大概画了十八张图,关于替换算法和擦除算法,这俩个方法所做的事情,如果不画图,光用文字描述的话,十分的抽象且很难理解;希望这些流程图,能让大家更能体会这些精炼代码的魅力!
使用
哔哔原理之前,必须要先来看下使用
使用起来出奇的简单,仅仅使用
set()
和get()
方法即可
打印结果
一般来说,我们在主存(或称工作线程)创建一个变量;在子线程中修改了该变量数据,子线程结束的时候,会将修改的数据同步到主存的该变量上
但是,在此处,可以发现,俩个线程都使用同一个变量,但是在线程一里面设置的数据,完全没影响到线程二
cool!简单易用,还实现了数据隔离与不同的线程
前置知识
在解释 ThreadLocal 整体逻辑前,需要先了解几个前置知识
下面这些前置知识,是在说 set 和 get 前,必须要先了解的知识点,了解下面这些知识点,才能更好去了解整个存取流程
线程隔离
在上面的 ThreadLocal 的使用中,我们发现一个很有趣的事情,ThreadLocal 在不同的线程,好像能够存储不同的数据:就好像ThreadLocal本身具有存储功能,到了不同线程,能够生成不同的'副本'存储数据一样
实际上,ThreadLocal 到底是怎么做到的呢?
来看下 set()方法,看看到底怎么存数据的:此处涉及到 ThreadLocalMap 类型,暂且把他当成 Map,详细的后面栏目分析
其实这地方做了一个很有意思的操作:线程数据隔离的操作,是 Thread 类和 ThreadLocal 类相互配合做到的
在下面的代码中可以看出来,在塞数据的时候,会获取执行该操作的当前线程
拿到当前线程,取到 threadLocals 变量,然后仿佛以当前实例为 key,数据 value 的形式往这个 map 里面塞值(有区别,set 栏目再详细说)
所以使用 ThreadLocal 在不同的线程中进行写操作,实际上数据都是绑定在当前线程的实例上,ThreadLocal 只负责读写操作,并不负责保存数据,这就解释了,为什么 ThreadLocal 的 set 数据,只在操作的线程中有用
大家有没有感觉这种思路有些巧妙!
来看下图示
图上只画了一个 ThreadLocal,想多花几个,然后线交叉了,晕
threadLocals 是可以存储多个 ThreadLocal,多个存取流程同理如下
总结下:通过上面的很简单的代码,就实现了线程的数据隔离,也能得到几点结论
ThreadLocal 对象本身是不储存数据的,它本身的职能是执行相关的 set、get 之类的操作
当前线程的实例,负责存储 ThreadLocal 的 set 操作传入的数据,其数据和当前线程的实例绑定
一个 ThreadLocal 实例,在一个线程中只能储存一类数据,后期的 set 操作,会覆盖之前 set 的数据
线程中 threadLocals 是数组结构,能储存多个不同 ThreadLocal 实例 set 的数据
Entry
说到 Entry,需要先知道下四大引用的基础知识
强引用:不管内存多么紧张,gc 永不回收强引用的对象
软引用:当内存不足,gc 对软引用对象进行回收
弱引用:gc 发现弱引用,就会立刻回收弱引用对象
虚引用:在任何时候都可能被垃圾回收器回收
Entry 就是一个实体类,这个实体类有俩个属性:key、value,key 是就是咱们常说的的弱引用
当我们执行 ThreadLocal 的 set 操作,第一次则新建一个 Entry 或后续 set 则覆盖改 Entry 的 value,塞到当前 Thread 的 ThreadLocals 变量中
来看下 Entry 代码
此处 key 取得是 ThreadLocal 自身的实例,可以看出来 Entry 持有的 key 属性,属于弱引用属性
value 就是我们传入的数据:类型取决于我们定义的泛型
Entry 有个比较巧妙的结构,继承弱引用类,然后自身内部又定义了一个强引用属性,使得该类有一强一弱的属性
结构图
你可能会想,what?我用 ThreadLocal 来 set 一个数据,然后 gc 一下,我 Entry 里面 key 变量引用链就断开了?
来试一下
结果
看来这里 gc 了个寂寞。。。
在这里,必须明确一个道理:gc 回收弱引用对象,是先回收弱引用的对象,弱引用链自然断开;而不是先断开引用链,再回收对象。Entry 里面 key 对 ThreadLocal 的引用是弱引用,但是 threadLocalOne 对 ThreadLocal 的引用是强引用啊,所以 ThreadLocal 这个对象是没法被回收的
来看下上面代码真正的引用关系
此处可以演示下,threadLocalOne 对 ThreadLocal 的引用链断开,Entry 里面 key 引用被 gc 回收的情况
结果
key 为 null 了!上面有行代码:threadLocalOne = null,这个就是断开了对 ThreadLocal 对象的强引用
大家如果有兴趣的话,可以把 threadLocalOne = null 去掉,再运行的话,会发现,key 不会为空
反射代码的功能就是取到 Thread 中 threadLocals 变量,循环取其中的 Entry,打印 Entry 的 key、value 值
总结
大家心里可能会想,这变量一直持有强引用,key 那个弱引用可有可无啊,而且子线程代码执行时间一般也不长
其实不然,我们可以想想 Android app 里面的主线程,就是一个死循环,以事件为驱动,里面可以搞巨多巨难的逻辑,这个强引用的变量被赋其它值就很可能了
如果 key 是强引用,那么这个 Entry 里面的 ThreadLocal 基本就很难被回收
key 为弱引用,当 ThreadLocal 对象强引用链断开后,其很容易被回收了,相关清除算法,也能很容易清理 key 为 null 的 Entry
一个弱引用都能玩出这么多花样
ThreadLocalMap 环形结构
咱们来看下 ThreadLocalMap 代码
先去掉一堆暂时没必要关注的代码
table 就是 ThreadLocalMap 的主要结构了,数据都存在这个数组里面
所以说,ThreadLocalMap 的主体结构就是一个 Entry 类型的数组
在此处你可能又有疑问了,这东西不就是一个数组吗?怎么和环形结构扯上关系了?
数组正常情况下确实是下面的这种结构
但是,ThreadLocalMap 类里面,有个方法做了一个骚操作,看下代码
这个 nextIndex 方法,大家看懂了没?
它的主要作用,就是将传入 index 值加一
但是!当 index 值长度超过数组长度后,会直接返回 0,又回到了数组头部,这就完成了一个环形结构
总结
这样做有个很大的好处,能够大大的节省内存的开销,能够充分的利用数组的空间
取数据的时候会降低一些效率,时间置换空间
set
总流程
塞数据的操作,来看下这个 set 操作的代码:下面的代码,逻辑还是很简单的
获取当前线程实例
获取当前线程中的 threadLocals 实例
threadLocals 不为空执行塞值操作
threadLocals 为空,new 一个 ThreadLocalMap 赋值给 threadLocals,同时塞入一个 Entry
需要注意的是,ThreadLocalMap 生成 Entry 数组,设置了一个默认长度,默认为:16
流程图
map.set
上面说了一些细枝末节,现在来说说最重要的 map.set(this, value) 方法
取哈希值
上面代码有个计算哈希值的操作
从 key.threadLocalHashCode 这行代码上来看,就好像将自身的实例计算 hash 值
其实看了完整的代码,发现传入 key,只不过是为了调用 nextHashCode 方法,用它来计算哈希值,并不是将当前 ThreadLocal 对象转化成 hash 值
这地方用了一个原子类的操作,来看下 getAndAdd() 方法的作用
这就是个相加的功能,相加后返回原来的旧值,保证相加的操作是个原子性不可分割的操作
HASH_INCREMENT = 0x61c88647,为什么偏偏将将 0x61c88647 这个十六进制相加呢,为什么不能是 1,2,3,4,5,6 呢?
该值的相加,符合斐波那契散列法,可以使得的低位的二进制数值分布的更加均匀,这样会减少在数组中产生 hash 冲突的次数
具体分析可查看:从 ThreadLocal 的实现看散列算法
等等大家有没有看到 threadLocalHashCode = nextHashCode(),nextHashCode()是获取下一个节点的方法啊,这是什么鬼?
难道每次使用 key.threadLocalHashCode 的时候,HashCode 都会变?
看下完整的赋值语句
这是在初始化变量的时候,就直接定义赋值的
说明实例化该类的时候,nextHashCode()获取一次 HashCode 之后,就不会再次获取了
加上用的 final 修饰,仅能赋值一次
所以 threadLocalHashCode 变量,在实例化 ThreadLocal 的时候,获取 HashCode 一次,该数值就定下来了,在该实例中就不会再变动了
好像又发现一个问题!threadHashCode 通过 nextHashCode() 获取 HashCode,然后 nextHashCode 是使用 AtomicInteger 类型的 nextHashCode 变量相加,这玩意每次实例化的时候不都会归零吗?
难道我们每次新的 ThreadLocal 实例获取 HashCode 的时候,都要从 0 开始相加?
来看下完整代码
大家看下 AtomicInteger 类型的 nextHashCode 变量,他的修饰关键字是 static
这说明该变量的数值,是和这个类绑定的,和这个类生成的实例无关,而且从始至终,只会实例化一次
当不同的 ThreadLocal 实例调用 nextHashCode,他的数值就会相加一次
而且每个实例只能调用一次 nextHashCode()方法,nextHashCode 数值会很均匀的变化
总结
通过寥寥数行的初始化,几个关键字,就能形成在不同实例中,都能稳步变化的 HashCode 数值
这些基础知识大家或许都知道,又有多少能这样信手拈来呢?
取 index 值
上面代码中,用取得的 hash 值,与 ThreadLocalMap 实例中数组长度减一的与操作,计算出了 index 值
这个很重要的,因为大于长度的高位 hash 值是不需要的
此处会将传入的 ThreadLocal 实例计算出一个 hash 值,怎么计算的后面再说,这地方有个位与的操作,这地方是和长度减一的与操作,这个很重要的,因为大于长度的高位 hash 值是不需要的
假设 hash 值为:010110011101
长度(此处选择默认值:16-1):01111
看下图可知,这个与操作,可去掉高位无用的 hash 值,取到的 index 值可限制在数组长度中
塞值
看下塞值进入 ThreadLocalMap 数组的操作
关于 Key:因为 Entry 是继承的 WeakReference 类,get()方法是获取其内部弱引用对象,所以可以通过 get()拿到当前 ThreadLocal 实例
关于 value:直接 .value 就 OK 了
分析下塞值流程
实际上面的循环还值得去思考,来思考下这循环处理的事情
循环中获取当前 index 值,从 Entry 数组取到当前 index 位置的 Entry 对象
如果获取的这 Entry 是 null,则直接结束这个循环体
在 Entry 数组的 index 塞入一个新生成的节点
如果获取的这 Entry 不为 null
key 值相等,说明 Entry 对象存在,覆盖其 value 值即可
key 为 null,说明该节点可被替换(替换算法后面讲),new 一个 Entry 对象,在此节点存储数据
如果 key 不相等且不为 null,循环获取下一节点的 Entry 对象,并重复上述逻辑
整体的逻辑比较清晰,如果 key 已存在,则覆盖;不存在,index 位置是否可用,可用则使用该节点,不可用,往后寻找可用节点:线性探测法
替换旧节点的逻辑,实在有点绕,下面单独提出来说明
替换算法
在上述 set 方法中,当生成的 index 节点,已被占用,会向后探测可用节点
探测的节点为 null,则会直接使用该节点
探测的节点 key 值相同,则会覆盖 value 值
探测的节点 key 值不相同,继续向后探测
探测的节点 key 值为 null,会执行一个替换旧节点的操作,逻辑有点绕,下面来分析下
来看下 replaceStaleEntry 方法中的逻辑
上面的代码,很明显俩个循环是重点逻辑,这里面有俩个很重要的字段:slotToExpunge 和 staleSlot
staleSlot:记录传进来节点 key 为 null 的位置
slotToExpunge:标定是否需要执行最后的清理方法
第一个循环:很明显是往前列表头结点方向探测,是否还有 key 为 null 的节点,有的话将其下标赋值给 slotToExpunge;这个探测是一个连续的不为 null 的节点链范围,有空节点,立马结束循环
第二个循环:很明显主要是向后探测,探测整个数组,这里有很重要逻辑
这地方已经开始有点绕了,我 giao,大家要好好想想
当探测的 key 和传入的需要设值的 key 相同时,会复写探测到 Entry 的 value,然后将探测到位置和传入位置,俩者相互调换
为什么会出现探测到 Entry 和传入 key 相同?
相同是因为,存在到数组的时候,产生了 hash 冲突,会自动向后探测合适的位置存储
当你第二次用 ThreadLocal 存值的时候,hash 产生的 index,比较俩者 key,肯定是不可能相同,因为产生了 hash 冲突,真正储存 Entry,在往后的位置;所以需要向后探测
假设探测的时候,一直没有遇到 key 为 null 的 Entry:正常循环的话,肯定是能探测到 key 相同的 Entry,然后进行复写 value 的操作
但是在探测的时候,遇到 key 为 null 的 Entry 的呢?此时就进入了替换旧 Entry 算法,所以替换算法就也有了一个向后探测的逻辑
探测到相同 key 值的 Entry,就说明了找到了我们需要复写 value 的 Entry 实例
为什么要调换俩者位置呢?
这个问题,大家可以好好想想,我们时候往后探测,而这 key 为 null 的 Entry 实例,属于较快的探测到 Entry
而这个 Entry 实例的 key 又为 null,说明这个 Entry 可以被回收了,此时正处于占着茅坑不拉屎的位置
此时就可以把我需要复写 Entry 实例和这个 key 为 null 的 Entry 调换位置
可以使得我们需要被操作的 Entry 实例,在下次被操作时候,可以尽快被找到
调换了位置之后,就会执行擦除旧节点算法
上面是探查连续的 Entry 节点,未碰到 null 节点的情况;如果碰到 null 节点,会直接结束探测
请注意,如果数组中,有需要复写 value 的节点;在计算的 hash 值处,向后探测的过程,一定不会碰到 null 节点
毕竟,第一次向后探测可用节点是,碰到第一个 null 节点,就停下来使用了
在第二个循环中,还有一段代码,比较有意思,这判断逻辑的作用是
以 key 为 null 的 Entry,以它为界限
向前探测的时候,未碰到 key 为 null 的 Entry
而向后探测的时候,碰到的 key 为 null 的 Entry
然后改变 slotToExpunge 的值,使其和 staleSlot 不相等
可以看出来这俩个循环的操作,是有关联性,对此,我表示
为什么这俩个循环都这么执着的,想改变 slotToExpunge 的数值呢?
来看下关于 slotToExpunge 的关键代码
明白了吧!都是为了替换方法里面的最后一段逻辑:为了判断是否需要执行擦除算法
总结
双向探测流程
替换算法会以传入的 key 为 null 的 Entry 节点为界限,在一个连续的 Entry 范围往俩边探测
什么是连续的 Entry 范围?这边数组的节点都不能为 null,碰到为 null 节点会结束探测
先向前探测:如果碰到 key 为 null 的 Entry,会将其下标赋值给 slotToExpunge
向后探测使:如果向前探测没有碰到 key 的节点,只要向后探测的时候碰到为 null 的节点,会将其下标赋值给 slotToExpunge
上面向俩边探测的逻辑,是为了:遇到 key 为 null 的节点,能确保 slotToExpunge 不等于 staleSlot
在向后探测的时候,如果遇到 key 值对比相同的 Entry,说明遇到我们需要复写 value 的 Entry
此时复写 value 的 Entry,用我们传入的 value 数值将其原来的 value 数值覆盖
然后将传入 key 为 null 的 Entry(通过传入的下标得知 Entry)和需要复写 value 的 Entry 交换位置
最后执行擦除算法
如果在向后探测的时候,没有遇到遇到 key 值对比相同的 Entry
传入 key 为 null 的 Entry,将其 value 赋值为 null,断开引用
创建一个新节点,放到此位置,key 为传入当前 ThreadLocal 实例,value 为传入的 value 数据
然后根据 lotToExpunge 和 staleSlot 是否相等,来判断是否要执行擦除算法
总结
来总结下
再来看下总流程
上面分析完了替换旧节点方法逻辑,终于可以把 map.set 的那块替换算法操作流程补起来了
不管后续遇到 null,还是遇到需要被复写 value 的 Entry,这个 key 为 null 的 Entry 都将被替换掉
这俩个图示,大概描述了 ThreadLocal 进行 set 操作的整个流程;现在,进入下一个栏目吧,来看看 ThreadLocal 的 get 操作!
get
get 流程,总体要比 set 流程简单很多,可以轻松一下了
总流程
来看下代码
总体流程非常简单,将自身作为 key,传入 map.getEntry 方法,获取符合实例的 Entry,然后拿到 value,返回就行了
如果通过 map.getEntry 获取的 Entry 为 null,会返回 setInitialValue(),来看下这个方法是干嘛的
从这个方法可知,如果我们没有进行 set 操作,直接进行 get 操作,他会给 ThreadLocal 的 threadLocals 方法赋初值
setInitialValue() 方法,返回的是 initialValue() 方法的数据,可知默认为 null
所以通过 key 没查到对应的 Entry,get 方法会返回 null
map.getEntry
从上面的代码可以看出来,getEntry 方法是获取符合条件的节点
这里逻辑很简单,通过当前 ThreadLocal 实例获取 HashCode,然后算出 index 值
直接获取当前 index 下标的 Entry,将其 key 和当前 ThreadLocal 实例对比,看是否一样
相同:说明没有发生 Hash 碰撞,可以直接使用
不相同:说明发生了 Hash 碰撞,需要向后探测寻找,执行 getEntryAfterMiss()方法
此时,就需要来看看 getEntryAfterMiss()方法逻辑了
getEntryAfterMiss
来看下代码
整体逻辑还是很清晰了,通过 while 循环,不断获取 Entry 数组中的下一个节点,循环中有三个逻辑走向
当前节点的 key 等于当前 ThreadLocal 实例:直接返回这个节点的 Entry
当前节点的 key 为 null:执行擦除旧节点算法,继续循环
当前节点的可以不等于当前 ThreadLocal 实例且不为 null:获取下一节点的下标,然后继续上面的逻辑
如果没有获取到符合条件的 Entry 节点,会直接返回 null
总结
ThreadLocal 的流程,总体上比较简单
将当前 ThreadLocal 实例当为 key,查找 Entry 数组当前节点(使用 ThreadLocal 中的魔术值算出的 index)是否符合条件
不符合条件将返回 null
从未进行过 set 操作
未查到符合条件的 key
符合条件就直接返回当前节点
如果遇到哈希冲突,算出的 index 值的 Entry 数组上存在 Entry,但是 key 不相等,就向后查找
如果遇到 key 为 null 的 Entry,就执行擦除算法,然后继续往后寻找
如果遇到 key 相当的 Entry,就直接结束寻找,返回这个 Entry 节点
这里大家一定要明确一个概念:在 set 的流程,发生了 hash 冲突,是在冲突节点向后的连续节点上,找到符合条件的节点存储,所以查询的时候,只需要在连续的节点上查找,如果碰到为 null 的节点,就可以直接结束查找
擦除算法
在 set 流程和 get 流程都使用了这个擦除旧节点的逻辑,它可以及时清除掉 Entry 数组中,那些 key 为 null 的 Entry,如果 key 为 null,说明这些这节点,已经没地方使用了,所以就需要清除掉
来看看这个方法代码
前置操作
从上面的代码,可以发现,再进行主要的循环体,有个前置操作
这地方做了很简单的置空操作,如果 Entry 节点的 key 为空,说明这个节点可以被清除,value 置空,和数组的链接断开
<img src="https://cdn.jsdelivr.net/gh/CNAD666/MyData@master/pic/flutter/blog/20210506095408.png" alt="擦除算法-前置操作" style="zoom: 70%;" />
主体逻辑
很明显,循环体里面的逻辑是最重要,而且循环体里面做了一个相当有趣的操作!
上面的循环体里面,就是不断的获取下一节点的 Entry 实例,然后判断 key 值进行相关处理
key 为 null:中规中矩的,将 value 置空,断开与数组的链接
key 不为 null:这时候就有意思了
首先,会获取当前 ThreadLocal 实例的 hash 值,然后取得 index 值
判断 h(idnex 值)和 i 是否相等,不相等进行下述操作,因为 Entry 数组是环形结构,是完成存在相等的情况
会将当前循环到节点置空,该节点的 Entry 记为 e
从通过 hreadLocal 实例的 hash 值获取到 index 处,开始进行循环
循环到节点 Entry 为 null,则结束循环
将 e 赋值给为 null 的节点
这里面的逻辑就是关键了
大家可能对这个文字的描述,感觉比较抽象,来个图,来体会下这短短几行代码的妙处
总结
代码很少,但是实现的功能却并不少
擦除旧节点的方法,在 Entry 上探测的时候
遇到 key 为空的节点,会将该节点置空
遇到 key 不为空的节点,会将该节点移到靠前位置(具体移动逻辑,请参考上述说明)
交互到靠前节点位置,可以看出,主要的目的,是为了:
ThreadLocal 实例计算出的 index 节点位置往后的位置,能让节点保持连续性
也能让交换的节点,更快的被操作
扩容
在进行 set 操作的时候,会进行相关的扩容操作
来看下扩容代码入口:resize 方法便是扩容方法
来看下扩容代码
触发条件
先来看下扩容的触发条件吧
整体代码
上面主要的代码就是:!cleanSomeSlots(i, sz) && sz >= threshold
来看下 threshold 是什么
只要 Entry 数组含有 Entry 实例大于等于数组的长度的三分之二,便能满足后一段判定
来看看前一段的判定,看下 cleanSomeSlots,只要返回 false,就能触发扩容方法了
n >>>= 1:表达是无符号右移一位,正数高位补 0,负数高位补 1
举例:0011 ---> 0001
在上面的 cleanSomeSlots 方法中,只要在探测节点的时候,没有遇到 Entry 的 key 为 null 的节点,该方法就会返回 false
rehash 方法就非常简单了
执行擦除方法
只要 size(含有 Entry 实例数)长度大于等于 3/4 threshold,就执行扩容操作
总结
满足下面俩个条件即可
Entry 数组中不含 key 为 null 的 Entry 实例
数组中含有是实例数大于等于 threshold 的四分之三(threshold 为数组长度的 三分之二)
扩容逻辑
从上面的逻辑,可以看出来,将旧数组的数据赋值到扩容数组,并不是全盘赋值到扩容数组的对应位置
遍历旧数组,取出其中的 Entry 实例
key 为 null:需要将该节点 value 置空,等待 GC 处理(Help the GC,hhhh)
这里你可能有个疑问,不是说数组的节点 key 不为 null,才会触发扩容机制吗?
在多线程的环境里,执行扩容的时候,key 的强引用断开,导致 key 被回收,从而 key 为 null,这是完全存在的
key 不为 null:算出 index 值,向扩容数组中存储,如果该节点冲突,向后找到为 null 的节点,然后存储
这里的扩容存储和 ArrayList 之类是有区别
总结
可以发现
set,替换,擦除,扩容,基本无时无刻,都是为了使 hash 冲突节点,向冲突的节点靠近
这是为了提高读写节点的效率
remove
remove 方法是非常简单的,ThreadLocal 拥有三个 api:set、get、remove;虽然非常简单,但是还有一些必要,来稍微了解下
remove 代码
逻辑非常的清晰,通过 ThreadLocal 实例,获取当前的 index,然后从此开始查找符合条件 Entry,找到后,会将其 key 值清掉,然后执行擦除算法
e.clear 就是,弱引用的清理弱引用的方法,很简单,将弱引用 referent 变量置空就行了,这个变量就是持有弱引用对象的变量
<img src="https://cdn.jsdelivr.net/gh/CNAD666/MyData@master/pic/flutter/blog/20210506095436.png" alt="remove 流程" style="zoom: 33%;" />
最后
文章写到这里,基本上到了尾声了,写了差不多万余字,希望大家看完后,对 ThreadLocal 能有个更加深入的认识
ThreadLocal 的源码虽然并不多,但是其中有很多奇思妙想,有种萝卜雕花的感觉,这就是高手写的代码吗?
系列文章
版权声明: 本文为 InfoQ 作者【小呆呆666】的原创文章。
原文链接:【http://xie.infoq.cn/article/e5181309c25411ccc27bb3f4e】。文章转载请联系作者。
评论