小护士今天要给大家讲讲老生常谈的ThreadLocal
。这个类在网上有很多位博主都讲过,例如:
set()
、get()
、remove()
;ThreadLocalMap
,深入讲解里面关于哈希码计算与resize()
的逻辑细节,slot回收复用的处理,replaceStaleEntry()
、cleanSomeSlots()
;remove()
导致ThreadLocalMap
的slot数组空间不断增长。)小护士觉得既然这么多前辈已经把这些技术细节讲得清清楚楚了,这里就没必要重新再讲一遍了。所以小护士决定从ThreadLocal
的设计理念入手,站在两位大神级作者(Josh Bloch & Doug Lea)的肩膀上,回答下面这个问题:
ThreadLocal
是否可以这样写:
Thread thread = new Thread(()->{
ThreadLocal threadLocal = new ThreadLocal<>();
});
???
回答是:Noooooo !!!
这样写还不如直接用局部变量:
Thread thread = new Thread(()->{
Integer local = 1;
});
在现实当中,小护士还真的看见过有同事会在run()
方法中写ThreadLocal
局部变量。哎~
public class ThreadId {
// Atomic integer containing the next thread ID to be assigned
private static final AtomicInteger nextId = new AtomicInteger(0);
// Thread local variable containing each thread's ID
private static final ThreadLocal threadId =
new ThreadLocal() {
@Override protected Integer initialValue() {
return nextId.getAndIncrement();
}
};
// Returns the current thread's unique ID, assigning it if necessary
public static int get() {
return threadId.get();
}
// 小护士添加的
public static void set(int i) {
threadId.set(i);
}
}
上面的代码就是ThreadLocal
注释中列举的例子。
ThreadLocal
是用来做线程资源隔离的。众所周知,类的静态变量的生命周期与类的生命周期有关,贯穿从类的加载开始到类卸载回收的整个过程,每条线程都会共享这个静态变量,互相读写,并且伴随出现线程资源竞争的情况。但有时候,某些场景需要某个类去托管线程自己的资源(变量),而且希望这个资源不被其他线程访问修改;所以,此时ThreadLocal
应运而生。
本例中的public static int get()
方法会返回属于当前调用该方法的线程的变量。这些变量在线程间互不影响,各个线程各自修改自己的变量;如果某条线程调用了小护士添加的set()
方法,则该线程对变量的修改不会影响到其他线程的使用。
像ThreadLocal
这种线程级别的酷似Docker容器特性的资源隔离设计,到底是怎么实现的呢?按照常人的思路,应该就是用某种方法拿到当前线程对象,然后从当前线程对象中获得该ThreadLocal
携带的变量。emmmm,真的是这样吗?
在说ThreadLocal
源码之前,先说说Thread
的字段:
public class Thread implements Runnable {
...
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
...
}
Thread
有自己的ThreadLocalMap
,这里就验证了上面的假想,只不过没想到的是用了Map结构而不是数组。(其实就是数组)
现在来分析一下ThreadLocal
的基本结构:
public class ThreadLocal<T> {
private final int threadLocalHashCode = nextHashCode();
private static AtomicInteger nextHashCode = new AtomicInteger();
private static final int HASH_INCREMENT = 0x61c88647;
...
static class ThreadLocalMap {
...
static class Entry extends WeakReference> {
Object value;
Entry(ThreadLocal> k, Object v) {
super(k);
value = v;
}
}
private Entry[] table;
...
}
...
}
ThreadLocal
、ThreadLocalMap
、Entry
三者关系阐明:
ThreadLocal
没有任何ThreadLocalMap
的字段,ThreadLocalMap
的字段是在Thread
中维护的。Entry
是ThreadLocalMap
的slot(桶),哈希表用private Entry[] table
字段表示。ThreadLocalMap
用OpenAddressing(开放寻址法)方法实现哈希表,所以Entry
只有Object value
。Entry
的key(键)严格来说不是哈希表的key,只是ThreadLocal
对象的一个引用地址,由WeakReference
维护。Entry
装载一个ThreadLocal
对象和一个value
值。WeakReference
是Java几种引用方式之一,未来会详细介绍,本文不展开讲。ThreadLocal
的私有常量threadLocalHashCode
决定的。threadLocalHashCode
的值由静态变量nextHashCode
的自增值决定的。nextHashCode
通过自加HASH_INCREMENT
来实现自增。只要把这三者关系搞清楚了,剩下就只有ThreadLocal
的set()
、get()
、remove()
方法的细节理解了。这些方法里面会涉及到OpenAddressing的哈希表CRUD实现逻辑,小护士表示这些比起HashMap
的已经简单不少了,起码没有红黑树化处理。
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
方法细节:
Thread.currentThread()
是一个本地方法,返回当前Java线程对象引用地址;getMap()
是一个简单的封装,返回线程对象的threadLocals
字段引用地址;map.set()
是ThreadLocalMap
的设值方法,在哈希表中插入值;createMap()
是ThreadLocalMap
的初始化方法,可知线程对象的threadLocals
字段是延迟加载的;从这里开始会详细讲解内部方法的处理细节,精髓就在里面。
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
简单方法不多描述。
private void set(ThreadLocal> key, Object value) {
// 1. compute hash code
...
// 2. for loop set slot
...
// 3. clean some slots
...
}
set()
方法总共分为三部分,计算哈希值、循环遍历哈希表插值、回收无用值。下面会拆解这三部分,每部分单独细讲。
private void set(ThreadLocal> key, Object value) {
// 1. compute hash code
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
...
}
第一部分,table
是ThreadLocalMap
的哈希表存储字段。哈希表的长度是以2为底的指数(a power of two),也就是 2、4、8、16、32、64等等值。这里有个算术技巧,一般来说,计算哈希值都是用求余计算hash = value % len
;但是这里两位大神用了hash = value & (len-1)
;因为len
是以2为底的指数,减一,可得一段全为1的二进制数值,例如000011111111
,做&
运算时,正负得负、正正得正、负负得负,只把value
的二进制值中处于len-1
范围的位数保留下来,而不在此范围内的位数全部置零。举个例子:
value = 0100 1010
len = 0001 0000
len - 1 = 0000 1111
hash = value & (len - 1)
0100 1010
&
0000 1111
=
0000 1010
hash = 0000 1010
这样清楚了吧,小护士表示已经非常尽力地用最简单的语言讲这块内容了。计算完哈希值以后就开始for循环遍历哈希表,做寻址操作,找哈希表中的空位插入值;下面请看第二部分。
private void set(ThreadLocal> key, Object value) {
...
// 2. for loop set slot
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
...
}
首先,i
就是刚才计算好的哈希值,所以循环初始化条件直接用tab[i]
定位到哈希表的索引位置(桶、slot);循环退出的条件是e == null
,e
就是tab[i]
。每次循环后通过nextIndex()
方法来获得下一个索引位置。
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
这里有个小插曲,展示nextIndex()
细节,注意,如果遍历时超出了哈希表长度就会返回0;这里也暗示了tab[0]
永远为null
。秘密在于哈希自增的值HASH_INCREMENT = 0x61c88647
。这里不讲太多数学上细节,反正这个值不是随便编出来的,小护士建议你请记住这一点就好。
接下来,提出一个疑问,为什么需要循环遍历哈希表,哈希值明明可以一步定位到指定的索引位置直接设值。因为有哈希冲突(hashcode conflict),意思就是两个值计算哈希值的时候得出同一个哈希值,这就叫哈希冲突。对于HashMap
来说,因为采用的是链式法(chain),可以直接把冲突的值追加到链的尾部(或头部);但对于ThreadLocalMap
来说,因为采用的是开放寻址法(OpenAddressing),所以要在冲突位置的基础上往后遍历,直到找到空位才设值。
由于set()
方法也提供替换原有哈希值(key)所对应的value
特性;所以在for循环中需要先做一次判断;如果k==key
,则替换value
。
如果k==null
,意味着这个位置曾经被使用过,Entry e
已经实例化并占用这个索引位置(slot);但由于remove()
方法的调用,导致这里的ThreadLocal
引用地址被置空。这时候就需要做replaceStaleEntry()
处理。
replaceStaleEntry()
顾名思义就是替换陈旧的entry
,这个entry
直译理解是条目,意译理解则是索引位置或者桶或者数组元素。里面的细节在目前对于还没阅读《算法导论》的我们(小护士和你)来说,不需要太过分地追求弄明白。
为什么不需要过分地追求弄明白,因为当你看见两位大神在注释中频繁地提及《计算机程序设计艺术》这本上古神书级别的作者——Knuth(小护士尊称他为高德纳祖师),你就知道此事要全面地掌握并非一日之寒可以完成的,即便你看完了方法实现细节,靠死记硬背也得不到祖师爷真传。(倘若你是祖师爷的门生则另当别论。)
private void set(ThreadLocal> key, Object value) {
...
// 3. clean some slots
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
如果前面的for循环都走完以后,始终没有符合if条件而return
的,则认为这个新的哈希值对应哈希表中的索引位置是仍然没有被初始化过的,或者在replaceStaleEntry()
里面的cleanSomeSlots()
调用中恰巧被认为是stale entry(陈旧的条目)而清空的。因满足上述条件,执行tab[i] = new Entry(key, value)
,绑定弱引用。
最后,如果在cleanSomeSlots()
返回为false(意思是没有发现需要清空的stale entry),且当前的哈希表的桶占用位置数量大于阈值时,则进行rehash()
。这个rehash()
会对哈希表进行扩容,然后重新计算哈希值,把那些占用的桶重新排列位置。
如果你对expungeStaleEntry()
的细节感兴趣,请自行了解OpenAddressing哈希表的算法细节,或者在不久的未来审读小护士写的《算法导论》读书笔记。
ThreadLocalMap(ThreadLocal> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
createMap()
方法逻辑相对简单很多了,只是调用了ThreadLocalMap
的有参构造方法,无需再多描述。
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
get()
方法就朴素很多了,没有set()
方法那样复杂,如果ThreadLocalMap
已经初始化了,则直接使用map.getEntry()
方法,通过ThreadLocal
对象的引用地址找到对应value。如果没有初始化的,则初始化呗。
private Entry getEntry(ThreadLocal> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}
getEntry()
的入参是ThreadLocal
对象的引用地址,因此,需要重新计算一次哈希值;这一点与HashMap
很相似,但是如果计算出来的哈希值所指向的索引位置的entry
不是想要的结果时,就需要调用getEntryAfterMiss()
来补偿了。
private Entry getEntryAfterMiss(ThreadLocal> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal> k = e.get();
if (k == key)
return e;
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
getEntryAfterMiss()
方法没有想象中那么复杂,如果匹配到k == key
,则返回值;否则,如果k == null
,则认为这是一个stale entry,需要被清空回收;如果都没有满足前面两个if条件,则顺延查找下一个entry
。如果始终找不到,则返回null
。
同样的,这里也不会描述expungeStaleEntry()
方法细节。
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
setInitialValue()
方法的处理与ThreadLocal
的有参构造方法很相似,只是多了一个initialValue()
,这个方法直接返回了null
。
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
remove()
方法是用来移除哈希表中的entry
的。移除的具体逻辑则在ThreadLocalMap
中维护。
private void remove(ThreadLocal> key) {
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)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
remove()
方法的逻辑也是很简单,入参是ThreadLocal
对象的引用地址,需要重新计算哈希值,然后通过for循环的方式去寻找对应的entry
;如果匹配上了,就调用Reference
的clear()
方法,解绑引用关系;然后调用expungeStaleEntry()
来清空该entry
对象。
小护士听闻有同学在使用ThreadLocal
的过程中,不小心造成了生产一级故障;程序跑着跑着就内存溢出了。
后来分析原因,得知是因为程序中使用了没有回收线程对象资源的线程池;而在线程对象中的run()
方法使用了某些单例对象,而这些单例中包含了ThreadLocal
字段;由于run()
方法跑完以后,没有在该单例对象中显式地调用ThreadLocal
的remove()
方法,导致线程对象的ThreadLocalMap
中的entry
一直都没有回收,而又因为线程对象在线程池中一直没有销毁;久而久之,ThreadLocalMap
的哈希表Entry[] table
不断地膨胀,最终因为数组长度过长且堆中已无法申请更大的内存空间来做扩容而跑出OutOfMemory异常。
小护士打算不写ThreadGroup
的源码阅读篇了,因为逻辑太简单,只是一棵树。
目前,lang包的内容已经全部完结了。下一篇开始就是大名鼎鼎的JUC内容了,敬请期待util包源码阅读篇。
如果,你对小护士之前的源码阅读内容感兴趣的话,可以戳下面的链接:
为了方便大家日后技术交流,小护士在这里特别推荐如下交流方式:
QQ群:JAVA高级交流(329019348)
QQ群:大宽宽的技术交流群(317060090)
如果有对博文内容有任何技术问题,欢迎评论留言或加群讨论,谢谢大家。