深入理解ThreadLocal
source link: https://chenshinan.github.io/2019/10/17/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3ThreadLocal/
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.
什么是ThreadLocal
ThreadLocal
的实例代表了一个线程局部的变量,每条线程都只能看到自己的值,并不会意识到其它的线程中也存在该变量。它采用空间来换取时间的方式,解决多线程中相同变量的访问冲突问题
官方demo
public class ThreadId { |
输出结果:03214
ThreadLocal实现原理
创建一个ThreadLocal实例后,每当某个线程调用ThreadLocal.get()/set()
时,会去访问当前线程内部的threadLocals,threadLocals是一个ThreadLocalMap对象,内部存储了Entry数组,Entry是一个弱引用的key-value对象,key是ThreadLocal,value是当前线程存的值
ThreadLocal类内部结构
public class ThreadLocal<T> { |
Thread类内部结构
public class Thread implements Runnable { |
ThreadLocal及Thread之间的关系
由此可见,在ThreadLocal中并没有对于ThreadLocalMap的引用,ThreadLocalMap的引用是在Thread类中,因此每个线程在向ThreadLocal里设置值时,其实都是向自己所持有的ThreadLocalMap里设置值;读的时候同理
ThreadLocal源码解析与设计思路
ThreadLocalMap提供了一种为ThreadLocal定制的高效实现,并且自带一种基于弱引用的垃圾清理机制。
下面从基本结构开始一点点解读。
说ThreadLocalMap是Map,是因为它内部的Entry是包含key和value,我们来看下ThreadLocalMap的内部结构:
static class ThreadLocalMap { |
ThreadLocalMap内部是Entry的数组,Entry中key是ThreadLocal实例,value是存放塞到ThreadLocal里的值
关于WeakReference与Java的四种引用
Java的四种引用
- 强引用:最普遍的引用,如果一个对象具有强引用,那垃圾回收器绝不会回收它
Object o = new Object(); // 强引用 |
- 软引用:如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用
SoftReference<String> softRef=new SoftReference<String>("hello"); // 软引用 |
- 弱引用:弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存
WeakReference<String> weakRef = new WeakReference<String>("hello"); // 弱引用 |
- 虚引用:“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。 |
关于WeakReference的使用
我们关注到ThreadLocalMap内部的Entry是继承WeakReference实现的,来看看WeakReference弱引用的用法
Apple apple = new Apple("苹果"); |
/** |
为什么使用弱引用
因为如果这里使用普通的key-value形式来定义存储结构,实质上就会造成节点的生命周期与线程强绑定,Entry强引用ThreadLocal,只要线程没有销毁,那么节点在GC分析中一直处于可达状态,没办法被回收,这就造成了内存泄漏,当然也可以等到线程被回收销毁,但是在使用线程池去维护线程时,可能线程会被反复利用,导致脏数据一直存在。
这里使用弱引用,弱引用是Java中四档引用的第三档,比软引用更加弱一些,如果一个对象没有强引用链可达,那么一般活不过下一次GC。当某个ThreadLocal已经没有强引用可达,则随着它被垃圾回收,在ThreadLocalMap里对应的Entry的键值会失效,这为ThreadLocalMap本身的垃圾清理提供了便利。
ThreadLocal是如何解决hash冲突的
首先我们来看一下ThreadLocal的构造函数
public class ThreadLocal<T> { |
我们注意到,每个ThreadLocal对象都有一个hash值threadLocalHashCode,每初始化一个ThreadLocal对象,hash值就增加一个固定大小0x61c88647。nextHashCode是静态的AtomicInteger,所有ThreadLocal对象共享getAndAdd(HASH_INCREMENT)
为什么采用0x61c88647这个数字呢?
16进制 | 10进制 | 2进制 |
---|---|---|
0x61c88647 | 1640531527 | 01100001110010001000011001000111 |
2654435769(取反后的10进制) | 10011110001101110111100110111001(取反+1) |
斐波那契散列法中讲到2654435769是一个斐波那契散列乘数,它的优点是通过它与2的幂取模,得到的结果分布很均匀,可以很大程度上避免hash冲突
再来看一下ThreadLocalMap的构造函数:
/** |
我们重点关注一下threadLocalHashCode & (INITIAL_CAPACITY - 1)
,相信有过算法竞赛经验或是阅读源码较多的程序员,一看就明白,对于2的幂作为模数取模,可以用&(2^n-1)
来替代%2^n
,位运算比取模效率高很多
根据如下代码我们会发现用魔数0x61c88647,斐波那契散列取值发布得更均匀
public class HashTest { |
运行结果如下:
0,1,1,3,4,6,8,9,9,10,11,14,14,15,15,15 |
可以看出斐波散列分布很均匀,没有冲突。其他hashcode、murmurHash、consistentHash都会有或多或少的冲突。不过斐波散列需要AtomicInteger共享变量
是否可以直接用AtomicInteger递增取模,而不用递增0x61c88647来取模?
当插入新的Entry且出现Entry冲突,而进行线性探测时,后续的Entry坑也极大可能被占了(因为之前是连续存储),使得线性探测性能差。而斐波散列的nextIndex()很大可能是有坑且可以插入的。Netty的FastThreadLocal是AtomicInteger递增的
ThreadLocalMap使用线性探测法来解决散列冲突
ThreadLocal有两个方法用于得到上一个/下一个索引,注意这里实际上是环形意义下的上一个与下一个。由于ThreadLocalMap使用线性探测法来解决散列冲突,所以实际上Entry[]数组在程序逻辑上是作为一个环形存在的。
至此,我们已经可以大致勾勒出ThreadLocalMap的内部存储结构。下面是我绘制的示意图。虚线表示弱引用,实线表示强引用。
ThreadLocalMap维护了Entry环形数组,数组中元素Entry的逻辑上的key为某个ThreadLocal对象(实际上是指向该ThreadLocal对象的弱引用),value为代码中该线程往该ThreadLoacl变量实际塞入的值。
线性探测法
线性探测法就是从冲突的数组slot开始,依次往后探测空slot,如果到数组尾部,再从头开始探测(环形查找)
set()解析
/** |
/** |
我们来回顾一下ThreadLocal的set方法可能会有的情况
- 探测过程中slot都不无效,并且顺利找到key所在的slot,直接替换即可
探测过程中发现有无效slot,调用replaceStaleEntry,效果是最终一定会把key和value放在这个slot,并且会尽可能清理无效slot
在replaceStaleEntry过程中,如果找到了key,则做一个swap把它放到那个无效slot中,value置为新值 在replaceStaleEntry过程中,没有找到key,直接在无效slot原地放entry
探测没有发现key,则在连续段末尾的后一个空位置放上entry,这也是线性探测法的一部分。放完后,做一次启发式清理,如果没清理出去key,并且当前table大小已经超过阈值了,则做一次rehash,rehash函数会调用一次全量清理slot方法也即expungeStaleEntries,如果完了之后table大小超过了threshold - threshold / 4,则进行扩容2倍
get()解析
/** |
/** |
我们来回顾一下从ThreadLocal读一个值可能遇到的情况:
根据入参threadLocal的threadLocalHashCode对表容量取模得到index
- 如果index对应的slot就是要读的threadLocal,则直接返回结果
- 调用getEntryAfterMiss线性探测,过程中每碰到无效slot,调用expungeStaleEntry进行段清理;如果找到了key,则返回结果entry
- 没有找到key,返回null
remove()解析
/** |
/** |
remove方法相对于getEntry和set方法比较简单,直接在table中找key,如果找到了,把弱引用断了做一次段清理。
关于内存泄露
关于ThreadLocal是否会引起内存泄漏也是一个比较有争议性的问题,其实就是要看对内存泄漏的准确定义是什么。
认为ThreadLocal会引起内存泄漏的说法是因为如果一个ThreadLocal对象被回收了,我们往里面放的value对于【当前线程->当前线程的threadLocals(ThreadLocal.ThreadLocalMap对象)->Entry数组->某个entry.value】这样一条强引用链是可达的,因此value不会被回收。
认为ThreadLocal不会引起内存泄漏的说法是因为ThreadLocal.ThreadLocalMap源码实现中自带一套自我清理的机制。
之所以有关于内存泄露的讨论是因为在有线程复用如线程池的场景中,一个线程的寿命很长,大对象长期不被回收影响系统运行效率与安全。如果线程不会复用,用完即销毁了也不会有ThreadLocal引发内存泄露的问题。
当我们仔细读过ThreadLocalMap的源码,我们可以推断,如果在使用的ThreadLocal的过程中,显式地进行remove是个很好的编码习惯,这样是不会引起内存泄漏。
那么如果没有显式地进行remove呢?只能说如果对应线程之后调用ThreadLocal的get和set方法都有很高的概率会顺便清理掉无效对象,断开value强引用,从而大对象被收集器回收。
总结下使用ThreadLocal时会发生内存泄漏的前提条件:
- ThreadLocal引用被设置为null,且后面没有set,get,remove操作
- 线程一直运行,不停止(线程池场景)
- 触发了垃圾回收(Minor GC或Full GC)
由此可见我们可以总结以下两个原则来避免内存泄露:
ThreadLocal申明为
private static final
Private与final 尽可能不让他人修改变更引用, Static 表示为类属性,只有在程序结束才会被回收。
ThreadLocal使用后务必调用
remove()
方法最简单有效的方法是使用后将其移除
阿里巴巴开发手册中提到
注意:每个线程往threadlocal中读取数据都是线程隔离,互相之间不影响,所以threadlocal无法解决共享对象的更新问题。由于不需要共享信息,自然不存在竞争问题了,从而保证了某些情况下线程安全问题,以及避免了某些情况必须要考虑线程安全必须同步带来的性能损失
从ThreadLocal的内部结构入手来了解ThreadLocal的运行原理,利用斐波那契散列法结合线性探测法来做数据的存储,以及解释了ThreadLocal内存泄露的问题。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK