标签:ThreadLocalMap ThreadLocal 详解 线程 key Entry null 节点
文章目录
1.ThreadLocal的实现
接上一篇《线程安全的代码及ThreadLocal的使用》
现在我们知道了ThreadLocal
会可以通过线程本地变量来实现线程隔离,那随之而来了两个问题:
- ThreadLocal被定义为成员变量时是线程共享的,如何做到线程隔离?
- 线程本地变量到底是保存到哪里的?
- 各方法间的参数传递是怎么实现的?
两个问题都有同一个答案,在Thread
类中定义了一个成员变量threadLocals
,使用的是ThreadLocal
中的一个内部类ThreadLocalMap
,在我们使用到的时候就会初始化这个Map对象用来存放线程本地变量,并且每个线程只能访问到自己的ThreadLocalMap
,同时在使用Thread.currentThread
获取到当前线程就可以从当前线程的ThreadLocalMap
中获取参数,以此来实现线程隔离和参数传递。
下面是Thread
源码中定义的成员变量。
1.1.创建
ThreadLocalMap
的初始值为null,是通过ThreadLocal
中的get()
和set()
方法来初始化的。那就需要先实例化ThreadLocal
对象,下面是创建ThreadLocal
对象的三种方式:
public class ThreadLocalCreate {
// 直接创建,不初始化值。
ThreadLocal<Object> t1 = new ThreadLocal<>();
// 匿名内部类创建,重写初始化值方法
ThreadLocal<String> t2 = new ThreadLocal() {
@Override
protected String initialValue() {
return "张三";
}
};
// 直接使用初始化值方法创建
ThreadLocal<Integer> t3 = ThreadLocal.withInitial(() -> 0);
}
- t1是创建一个初始值为null的对象。
- t2是使用了一个匿名内部类,重写了初始化值的方法,用于初始化时回调。
- t3是通过静态方法,传入一个函数,初始化时会回调这个函数。
1.2.初始化
调用get()
和set()
方法对Thread
对象中的threadLocals
进行初始化,如果threadLocals
为null,则创建一个ThreadLocalMap
。
下面是set()
方法,实现非常的简单:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
上面代码中的this
指的是当前的ThreadLocal
对象。
get()
方法在threadLocals
为null,或使用当前ThreadLocal
对象作为key查询出的Entry
节点为null的时候,会先使用到1.1中的初始化值对ThreadLocalMap
进行初始化。
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
上面的setInitialValue()
方法和set()方法类似,只是将set()
方法中传入的参数值替换为新建初始化值就可以了,在里面会调用initialValue()
获取初始值。
protected T initialValue() {
return null;
}
默认就是返回null,1.1中的t1就是使用这种方式。t2中使用了匿名内部类重写了initialValue()
,这里就会返回我写上的初始化值“张三”。
至于t3是在JDK8之后才有的方法,t3是使用的静态内部类SuppliedThreadLocal
和函数式接口来实现的。
static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {
private final Supplier<? extends T> supplier;
SuppliedThreadLocal(Supplier<? extends T> supplier) {
this.supplier = Objects.requireNonNull(supplier);
}
@Override
protected T initialValue() {
return supplier.get();
}
}
上面的Supplier
是一个函数式接口,我们再1.1中调用withInitial()
传入了一个函数作为参数,然后就会调用SuppliedThreadLocal
的构造方法创建对象,并将传入的函数储存到变量中。然后重写了initialValue()
方法,在get()
方法调用的时候,就会发起这个自定义函数的调用。
t3中写的函数是()->0
,那这里的初始化值就是0。
1.3.移除
为了避免内存泄露,在使用完ThreadLocal
后需要将不再使用的entry
节点从ThreadLocalMap
中移除掉,remove()
方法也非常简单。
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
1.4.存储结构图示
首先,Thread
类与ThreadLocal
的关系如下入所示:
每个Thread
对象中都维护了一个ThreadLocalMap
,这个Map使用ThreadLocal
对象作为key。
线程隔离是如何实现的呢?
可以看到的是,ThreadLocal
对象虽然是同一个,但是每个线程中的ThreadLocalMap
都是单独的,所以各个线程之间是互不影响的。
1.5.小结
ThreadLocal
的实现是非常简单的,就是在通过Thread
对象维护一个ThreadLocalMap
就实现了,因为Thread
对象内的变量只允许当前线程访问,也就天然的实现了线程隔离。同时,在同一个线程中,不管运行到了程序的哪一个位置都可以从自身的变量中获取到已保存好的参数,这样就实现了参数传递。
上面提到的不管是get()
还是set()
还是remove()
都依赖于ThreadLocalMap
作为存储参数的数据结构,ThreadLocal
的实现那么简单,就是因为ThreadLocalMap
为它承担了所有,那下面可以看看是如何实现的。
2.ThreadLocalMap的实现
说到Map这样的哈希表结构,那首先想到的应该就是最熟悉的HashMap
了,我们知道HashMap
是通过数组+链表来实现的。其中数组作为数据的分片存储和访问的入口,肯定是必须要存在的。
ThreadLocalMap
的核心数据结构也一定是数组,它底层维护了一个Entry
数组,下面可以看一下Entry
的数据结构。
2.1.Entry的结构
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
Entry
是ThreadLocalMap
的一个静态内部类,继承了WeakReference
,在使用的时候会将ThreadLocal
的弱引用对象作为key。
需要注意的是,这个类中并没有定义key变量,作为key的ThreadLocal
对象,是通过super()
调用顶层的抽象类Reference
类的构造函数来保存的,再通过Entry
对象来获取key需要使用到Reference
类中的get()
方法来获取key。
2.1.1.为什么key要使用弱引用
弱引用对象指的是使用WeakReference
引用的对象,一个对象如果只存在弱引用,在JVM下一次GC时就会被回收掉。
下面是一个弱引用对象回收的示例:
public class WeekReferenceDemo {
public static void main(String[] args) {
// 创建强引用
WeekReferenceDemo demo = new WeekReferenceDemo();
// 创建弱引用
WeakReference<WeekReferenceDemo> reference = new WeakReference<>(demo);
// 打印对象地址
System.out.println(reference.get());
System.gc();
// GC后再打印地址
System.out.println(reference.get());
// 断开强引用
demo = null;
System.gc();
// 再次GC后打印地址
System.out.println(reference.get());
}
}
com.ls.practice.jvm.WeekReferenceDemo@677327b6
com.ls.practice.jvm.WeekReferenceDemo@677327b6
null
打印的结果显示,当强引用断开后,即使还存在弱引用,WeekReferenceDemo
对象也会被回收掉。
在实际的项目中使用的线程往往是可以复用的,线程的生命周期会很长,这种情况下线程对象就会长期持有对ThreadLocalMap
对象的强引用,而ThreadLocalMap
中定义了Entry
数组,它对Entry
对象也是强引用。
如果Entry
对作为key的ThreadLocal
对象也是强引用的话,当ThreadLocal
对象被使用完,需要被GC掉的时候,也会因为Entry
这个强引用导致这个ThreadLocal
对象不能被回收,引起内存泄露。
如果让Entry
对ThreadLocal
对象做弱引用,在没有其它的位置对同一个ThreadLocal
有强引用的情况下,下一次GC就可以回收掉了。
但即使对key做了弱引用的设计,也还是会因为Entry
对value是强引用,导致出现因为value不能被回收导致的内存泄露问题。
2.1.2.为什么value不使用弱引用
ThreadLocal
如果没有其它的强引用了,一定是表示它使用结束了,这种情况下就应该会回收掉,所以使用弱引用没有任何问题。
但是value不一样,value在ThreadLocal
的使用周期内,也有可能在其他位置被断开了强引用,如果在Entry
中对value使用弱引用的话,在业务流程还在跑的期间,发生了一次GC,这个value对象就被回收掉了,那业务肯定会出现错误。
为了应付这种情况,还是保留了value的强引用,通过已经被回收掉的key作为标识,在使用ThreadLocal
的get() set()
方法时,会去搜索key==null
的那些已经失效了的Entry
对象,断开Entry
对象与value引用的对象之间的强引用,并将他们从ThreadLocalMap
中移除掉。
但是我们如果不去调用get() set()
,就不会触发对失效Entry
对象的清理操作,所以我们在使用ThreadLocal
的时候,一定要在代码的结尾位置调用remove()
方法,主动发起清理操作。
2.2.Entry初始化
上面提到了,当Thread
对象中的threadLocals
为null时,或者使用ThreadLocal
查询出去Entry
为null时,会调用createMap()
方法,这里方法中会使用构造方法创建ThreadLocalMap
对象。
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// INITIAL_CAPACITY = 16
table = new Entry[INITIAL_CAPACITY];
// 计算下标
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
// threshold = INITIAL_CAPACITY * 2 / 3
setThreshold(INITIAL_CAPACITY);
}
这里的firstValue
要么是创建ThreadLocal
时通过initialValue()
返回的初始值,要么set()
方法调用时传入的value值。
- 首先是国际惯例,初始化一个Entry数组,长度
INITIAL_CAPACITY = 16
。 - 计算数组下标,这里的
threadLocalHashCode
涉及到魔数HASH_INCREMENT = 0x61c88647
的使用,目的是让Entry
在整个数组中分配的更加均匀。 - 然后创建一个
Entry
放入到计算出的下标对应的数据节点中,key是一个弱引用的ThreadLocal
对象。 - 最后设置扩容的参照参数
threshold = INITIAL_CAPACITY * 2 / 3
。
2.2.1.模拟魔数的使用
下面使用一个Demo来模拟一下数组下标的计算:
public class ThreadLocalMagicNumber {
private static final int HASH_INCREMENT = 0x61c88647;
private static AtomicInteger nextHashCode = new AtomicInteger();
/**
* 每次都加上一个魔数
*/
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
public void test() {
for (int i = 16; i <= 32; i *= 2) {
for (int j = 0; j < i; j++) {
System.out.print(nextHashCode() & (i - 1));
System.out.print(" ");
}
System.out.println();
}
}
public static void main(String[] args) {
new ThreadLocalMagicNumber().test();
}
}
分别打印了长度为16,32的分配情况,都是均匀的分配,没有重复的数字。
7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0
23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16
2.2.2.ThreadLocal是如何使用魔数的
ThreadLocal
中定义两个静态字段和一个实例常量字段,分别是:
// 魔数
private static final int HASH_INCREMENT = 0x61c88647;
// 下一个hash值
private static AtomicInteger nextHashCode = new AtomicInteger();
// 实例化ThreadLocal对象时获取的hash值常量
private final int threadLocalHashCode = nextHashCode();
看看nextHashCode()
方法,它里面其实就做了一个事,每实例化一个ThreadLocal
对象,就将静态变量nextHashCode
的值加上一个魔数。
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
由于nextHashCode
是个静态字段,每个实例化的ThreadLocal
对象都会共用这个字段,所以这种每次加上一个魔数的方式,会使每个ThreadLocal
实例分配的hash值都不一样,达到在ThreadLocalMap
中均匀分配的效果。
2.2.3.数组下标出现重复的情况
在2.2.1模拟魔数使用的测试中看到打印的结果,在某个长度范围内是均匀的打印出了不同的数值,中间没有出现重复的情况,那么数组下标会出现重复吗?答案是肯定会有下标重复的情况。
在2.1.1的例子中nextHashCode
的值是均匀增加的,但是在hash的过程中有其他的线程也对这个变量进行递增的操作,分配的结果可能就没有那么均匀了。下面看一下另外一个例子:
public void test1() {
for (int i = 0; i < 32; i++) {
System.out.print(nextHashCode() & 31);
System.out.print(" ");
// 这里模拟有其它线程插队,对静态变量做了修改
nextHashCode();
}
}
0 14 28 10 24 6 20 2 16 30 12 26 8 22 4 18 0 14 28 10 24 6 20 2 16 30 12 26 8 22 4 18
从打印结果中可以明显的看到存在重复的结果,对于ThreadLocalMap
来说,这种重复的下标就会造成Hash冲突,如何解决Hash冲突呢?可以看下面的set()
方法。
2.3.set()方法
ThreadLocalMap
的set()
方法除了存放值以外,还需要处理可能出现的Hash冲突问题,下面是set()方法的源码。
2.3.1.set()方法源码解析
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// key相等,则修改value
if (k == key) {
e.value = value;
return;
}
// entry不为null,key为null则把新的entry替换进去。
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 下标对应的数组节点为null,则set新值。
tab[i] = new Entry(key, value);
// 省略部分代码
}
通过计算出的数组下标i从数组中获取Entry
对象:
- 如果
Entry
对象值为null,则直接将新的Entry
对象设置到这个数组节点中。 - 如果
Entry
对象值不为null,则判断key是否相等,相等就替换value
值。 - 如果
key == null
则进入替换Entry
节点的方法,这个方法后面会解释里面到底做了什么。 - 如果
key != null
并且与传入的ThreadLocal
不相等,则表示发生了Hash冲突。
2.3.1.1.开放寻址法——解决Hash冲突
如果Entry
对象和key都不为null且key != threadLocal
,则根据当前计算出的数组下标i,去找i+1
的位置,如果i+1
大于数组长度则找到数组下标为0的位置,再重复做判断。
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
直到找到了Entry
对象为null的节点,这种方法叫做开放寻址法,如下图所示:
在上面的循环中做寻址操作的时候,也可能会遇到key==null
的节点,这时会调用replaceStaleEntry()
方法,将新的节点替换进去,并清理掉这个节点。如下图所示:
但是,由于存在开放寻址,上图中的ThreadLocalB
可能在之前已经set
到了计算出的下标i的更右边的位置。所以在上图的基础上,还得加上一个寻址的过程,以上图中的k==null
的节点下标为基础(下面使用staleSlot
代指这个值),向右寻址,会出现下面两种情况。
- 在寻址过程中,如果一直往右寻址到
entry==null
的节点,都没有找到,则直接将节点插入到staleSlot
位置(就是上图中的操作)。 - 如果找到了
key==ThreadLocalB
的节点,下标为i
,则将staleSlot
这个位置的失效节点移动到下标为i
的节点,然后新的节点插入到staleSlot
位置。
上图中,已经把新增的节点加入到了它最合适的位置,但是失效节点还没有清理掉,这个节点会在调用expungeStaleEntry()
方法的时候,与其它失效的entry
节点一同被清理掉。
清理的起始点是如何寻找的呢?
相对于set
节点的向右寻址,清理的起点位置是通过向左寻址来找到的,从staleSlot
值开始,每次数组下标减1。
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}
直到通过当前的下标i
找到的节点entry==null
为止。
然后就可以通过这个起始下标往向右迭代寻找key==null
的节点,每找到一个就清理一个,如下图所示:
在这一步清理完成后,可以看到还会存在其他已失效的节点没有被清理掉,为了尽可能的将失效节点清理掉,ThreadLocalMap
会调用cleanSomeSlots()
再次清理一部分失效的节点。
下面的代码省略了一部分非关键的位置:
private boolean cleanSomeSlots(int i, int n) {
// 省略部分代码
do {
// 此处的i是expungeStaleEntry结束的位置,在上图中就是6
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) {
// n传入的是数组长度,如果找到了一个失效节点,则刷新迭代次数
n = len;
// 从这个i开始,执行清理操作
i = expungeStaleEntry(i);
}
}
// 每迭代一次,将n的值除以2,长度为16最少会迭代4次
while ( (n >>>= 1) != 0);
return removed;
}
这么设计也是在做更加全面的清理与减少迭代次数之间做一个平衡,在多数情况下会取得一个比较好的效果。例如上图通过cleanSomeSlots()
处理后,所有的失效节点都会被清理掉。
经过上面的一顿处理之后,整个ThreadLocalMap
的set
逻辑已经趋于完美了,但是还存在一个问题,假如在ThreadLocalB
本来应该计算出来的下标是4,但是之前由于被其它节点抢占了,它通过开放寻址,寻址到了下标为5的位置,经过清理后,如果要对ThreadLocalB
做修改,就会遇到一个尴尬的处境,如下图所示:
这种问题ThreadLocalMap
中已经解决了,在expungeStaleEntry()
方法的寻找失效节点的过程中,每找到一个key != null
的节点(假设当前下标为i
),则再次计算它的下标(后面用h
表示),如果i != h
则将这个有效的节点移动的下标为h
的位置,如果h
的位置还有其他的节点,则向右寻址找到一个entry == null
的节点放进去。整个过程叫做Rehash。
最终的结构如下图所示:
2.3.2.扩容
每多插入一个元素(如果没有失效的节点),ThreadLocalMap
的size
就会+1,然后会做一个清理操作cleanSomeSlots()
,这个操作和上面开放寻址提到的是同一个操作。如果清理完后,size
还是大于threshold
,就会触发rehash()
操作:
if (!cleanSomeSlots(i, sz) && sz >= threshold) {
rehash();
}
触发后会对整个数组做一个完整的清理,把所有失效的节点都删除掉。然后再次判断:
private void rehash() {
// 从头到尾完整清理
expungeStaleEntries();
if (size >= threshold - threshold / 4)
resize();
}
resize()
中会创建一个长度为旧数组两倍大小的新数组,然后遍历旧数组,找到entry != null
的节点,再次做判断:
- 如果
key == null
则设置value=null
来协助GC回收entry
对象。 - 如果
key != null
则通过key的HashCode与新数组的长度-1做与运算,获得新的下标,然后做一套开发寻址操作,将entry
放入到新数组中合适的位置。
2.4.get()方法
有了上面的set()
方法的解析经验后,get()
方法就非常简单了,这里就不放源码了,总共就是下面几个步骤:
- 通过
ThreadLocal
对象计算下标值i
。 - 通过
i
查询到的entry
节点对象为null,则返回null。 - 如果
entry != null
,判断key == threadLocal
,为true
则返回这个entry
对象。 - 如果
key == null
,则做清理操作expungeStaleEntry()
。 - 如果
key != null
且key != threadLocal
,则向右寻址,直到找到了需要查询的对象或找到entry
对象为null为止。
2.5.remove()方法
remove()
方法并不是单纯的移除当前ThreadLocal
对应的节点,而是会做一次失效节点的清理。主要有下面几个步骤:
- 通过
ThreadLocal
对象计算下标值i
。 - 通过下标和向右寻址找到对应的
entry
节点。 - 将这个节点中的key断开弱引用。
- 从这个节点的下标开始,调用
expungeStaleEntry()
做清理操作。
3.总结
ThreadLocal:
ThreadLocal
的实现就是通过在线程对象Thread
中维护一个ThreadLocalMap
来实现的,线程对象提供了线程隔离的基础,而ThreadLocalMap
作为存放数据的结构,提供了参数传递的基础。
ThreadLocalMap:
- 使用
0x61c88647
这样一个魔数来保证数组元素尽可能的分散,以此来减少Hash冲突。 - 使用开放寻址法作为Hash冲突的解决方案。
- 由于线程在实际项目中可复用的使用方式,需要使用对key做弱引用这样的方式来避免内存泄露。
- 以
key == null
作为标识的失效Entry
对象的清理贯穿了ThreadLocalMap
始终。
标签:ThreadLocalMap,ThreadLocal,详解,线程,key,Entry,null,节点 来源: https://blog.csdn.net/qq_38249409/article/details/114059940
本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享; 2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关; 3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关; 4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除; 5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。