偷吃禁果
摘要
Threadlocal是java中的一个类,在Java 1.2版本加入,它的作者是Josh Bloch和Doug Lea。这篇文章会详细深入的分析ThreadLocal(基于JDK 1.8)。
ThreadLocal介绍
Java文档中对ThreadLocal的介绍如下图所示:
图中大概意思:该类为线程提供局部变量,每个线程会维护一个该变量独立的副本,直到线程结束。
这段话不太好理解,太抽象,下面通过一些示例,来直接的感受ThreadLocal。
示例1
public class ThreadLocalDemo {
public static void main(String[] args) throws InterruptedException {
ThreadLocal<integer> threadLocal = new ThreadLocal<>();
threadLocal.set(11);
// 新建一个线程Thread-1,对threadLocal变量进行操作
new Thread("Thread-1") {
@Override
public void run() {
threadLocal.set(22);
System.out.println("Thread Name: " + Thread.currentThread().getName() + " threadLocal = " + threadLocal.get());
}
}.start();
Thread.sleep(100); // 阻塞100ms,让线程Thread-1先输出
System.out.println("Thread Name: " + Thread.currentThread().getName() + " threadLocal = " + threadLocal.get());
}
}
输出结果如下图所示:
虽然在线程Thread-1中,设置threadLocal为22,但主线程中的threadLocal值仍然为11,并没有收到影响。
示例2
修改示例1的例子,再添加一个线程来修改threadLocal的值,代码如下所示:
public class ThreadLocalDemo {
public static void main(String[] args) throws InterruptedException {
ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
threadLocal.set(11);
// 新建一个线程Thread-1,对threadLocal变量进行操作
new Thread("Thread-1") {
@Override
public void run() {
threadLocal.set(22);
System.out.println("Thread Name: " + Thread.currentThread().getName() + " threadLocal = " + threadLocal.get());
}
}.start();
// 新建一个线程Thread-2,对threadLocal变量进行操作
new Thread("Thread-2") {
@Override
public void run() {
threadLocal.set(33);
System.out.println("Thread Name: " + Thread.currentThread().getName() + " threadLocal = " + threadLocal.get());
}
}.start();
Thread.sleep(100); // 阻塞100ms,让线程Thread-1、Thread-2先输出
System.out.println("Thread Name: " + Thread.currentThread().getName() + " threadLocal = " + threadLocal.get());
}
}
输出结果如下图所示:
虽然在Thread-2中修改了threadLocal的值,同样未影响主线程中threadLocal的值。
现在,再回过来看ThreadLocal的介绍:该类为线程提供局部变量,每个线程会维护一个该变量独立的副本,直到线程结束。
也就是说,如果一个变量为ThreadLocal对象,并在每一个线程中对ThreadLocal变量用set方法赋值,那么每一个线程都会在堆中新建一个对象与之对应,所以在线程中对ThreadLocal变量的操作,只是操作与之对应的对象,不会影响其他线程(如下左图)。
而普通变量,在不同的线程中,对应的依然是堆中同一个对象(如下右图)。
ThreadLocal源码分析
ThreadLocal会为每一个线程创建独立的副本,这是如何实现的呢?要回答这个问题,需要阅读ThreadLocal的源码。
ThreadLocal构造方法
ThreadLocal构造方法,如下图所示:
public ThreadLocal() {
}
它只有一个构造方法,并且这个构造方法什么也没做。
set方法
查看set方法,一步一步分析ThreadLocal是如果存储数据的,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);
}
代码不复杂:
- 获取当前线程;
- 以当前线程为参数,获取map;
- 如果map不为null,则把数据存入map;
- 如果map为null,则创建map,然后把数据存入map。
获取map的getMap方法代码如下:
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
getMap中,返回的是Thread中的一个成员变量threadLocals。但在Thread中,并没有对这个成员变量threadLocals的初始化。所以,第一次调用会返回null。
调用getMap返回null,所有会调用createMap方法创建map,然后存入数据,createMap代码如下所示:
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
在这个方法中,会对当前线程的threadLocals成员变量初始化。threadLocals为ThreadLocalMap类型。ThreadLocalMap为ThreadLocal的内部静态类,ThreadLocalMap类中,又包含内部静态类Entry,Entry能存储一对键值对。我们要存储的数据存储在Entry键值对对象中,做为键值对的值,ThreadLocal对象的弱引用做为键。
所以,最终的存储关系为:Thread类中,有一个ThreadLocalMap对象,ThreadLocalMap对象中有一个Entry数组table,要存储的数据最终存储在Entry对象的value变量中,UML图如下图所示:
因为数据最终是存储在Thread中,所以不同的线程对数据的操作是独立,不受影响的。
接着看ThreadLocalMap,构造方法代码如下所示:
ThreadLocalMap(ThreadLocal<"ff0000">提示: replaceStaleEntry方法比较复杂,比较难于理解,如果不理解,可以先看后面expungeStaleEntry和cleanSomeSlots两个方法的分析,然后再回来看这个方法,会更好的理解。replaceStaleEntry方法,代码如下:
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
replaceStaleEntry方法代码相对复杂,从名字可以看出,方法的功能是替换陈旧的元素。
如果tab是普通的数组,替换陈旧元素很简单,以下30、31这2行代码就搞定了:
tab[staleSlot].value = null; // 置为null
tab[staleSlot] = new Entry(key, value); // 更新为新元素
但因为tab是一个Hash表,所以更新的时候,还需要考虑Hash冲突的情况。
这是一个private方法,并且只在ThreadLocalMap.set方法中被调用过一次,调用场景如下:
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
什么时候k会等于null呢?k是Entry对ThreadLocal对象的弱引用,查看Entry的get方法说明,如下:
Returns this reference object’s referent. If this reference object has been cleared, either by the program or by the garbage collector, then this method returns null.
Entry的get方法中说明了,当引用对象被清理,或者被GC回收了,调用get方法会返回null。
所以调用replaceStaleEntry的前提就是,table数组中下标为i的元素不为null(即之前设置过),但是所引用的ThreadLocal对象被清理或者回收,也就是元素已经陈旧了。
逐步分析replaceStaleEntry方法的代码,第一个for循环,代码如下图所示:
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
这个循环的作用是,从staleSlot
下标开始往前找,当tab[i]
不等于null
时,找到tag[i].get == null
的最左下标slotToExpunge
,这个下标用于标识需要删除陈旧元素的起始下标。
为什么要这么找呢?因为ThreadLocal.set
方法用i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)
方式确定table
数组下标i
时,如果下标i
已有元素,则说明发生了Hash冲突,处理冲突的办法,就是下标i + 1
,如果i + 1
后再冲突,再i + 1
,直到找到tab
元素为null
的下标。
所以这个循环的作用,就是找到发生Hash冲突时的最左陈旧元素下标。
接着看replaceStaleEntry
方法中第二个for循环,代码如下:
for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
这个循环的作用是,从staleSlot + 1
下标开始往后找table
数组中连续不为null的元素。如果有元素的key,等于参数传入的key,则说明此元素之前设置过,那么更新此元素的value值,然后和table[staleSlot]交换。这里为什么要交换?
因为调用replaceStaleEntry方法的前提就是table[staleSlot]元素是陈旧的(元素本身null,但key为null)。所以把更新后的有效元素e和table[staleSlot]元素互换。互换之后,staleSlot位置的元素就是有效的了,如果slotToExpunge == staleSlot,则更新slotToExpunge的位置。
接着调用expungeStaleEntry方法和cleanSomeSlots方法(这两个方法接下来详细讲解)清理陈旧的数据,然后结束返回。
接着分析没有进入for循环中if (k == key)
语句的情况。
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
如果k == null
,说明这是一个陈旧的元素;slotToExpunge == staleSlot
,说明在第一个for循环中,没用找到陈旧的元素,所以table[i]就是第一个陈旧的元素。
方法最后30 - 34行代码:
tab[staleSlot].value = null; // 置为null
tab[staleSlot] = new Entry(key, value); // 更新为新元素
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
如果slotToExpunge == staleSlot
,slotToExpunge标示的是需要删除陈旧元素的下标,但是tab[staleSlot]元素已经替换为有效的新元素了,所以没有需要删除的陈旧元素。
所以只有在slotToExpunge != staleSlot
的情况下,才说明有陈旧元素,需要调用expungeStaleEntry方法和cleanSomeSlots方法(这两个方法接下来详细讲解)清理陈旧的数据。
ThreadLocalMap.expungeStaleEntry
这个方法的功能是删除陈旧的元素。具体是如何删除的,需要分析源代码,方法源代码如下:
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
Entry e;
int i;
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
如果只是普通的数组,则通过第5、6、7行代码,把元素置空,然后size--
即可。
然而这里处理的不是普通的数组,而是Hash表,所有还要考虑有Hash冲突的情况下,如何删除陈旧元素,这个处理放在了for循环中,接下来分析for循环。
在for循环中,从staleSlot开始往后找连续的table中不为null的元素,如果key为null,则说明元素已经陈旧,则删除。如果key不为null,则通过k.threadLocalHashCode & (len - 1)
计算下标(存放在h
中),如果下标等于i
则不处理,不等于i
则处理。那等于i
,不等于i
分别代表什么意思呢?
- 等于
i
,说明没有Hash冲突,k.threadLocalHashCode & (len - 1)
计算的下标就是i
。
- 不等于
i
,说明有Hash冲突,处理的方式是k.threadLocalHashCode & (len - 1)
加1,如果还冲突,再加1,直到不冲突。也就是说,i
是k.threadLocalHashCode & (len - 1)
加1,或者多次加1的值,所有h
必然小于i
。
所以,19 - 25行代码的作用是整理发生冲突的元素,对发生冲突的元素重新Hash,整理的效果如下图所示:
比如图中,i = 5,此时tab[i] != null,并且k != null。因为发生了Hash冲突,h = k.threadLocalHashCode & (len - 1)
后,h = 2。按照19 - 25行代码整理后,tab[5] = null,tab[3]等于原来tab[5]的值。
为什么要对元素重新Hash呢?因为不重新Hash,可能会因为之前的元素已经陈旧被删除,而找不到发生冲突后续的元素。比如上图中整理前,tab[5]Hash下标为2,但因为tab[3],tab[4]为null,导致找不到tab[5]元素。
总结:ThreadLocalMap.expungeStaleEntry方法的功能是,删除staleSlot到之后第一个为null元素之间陈旧的元素,并可能会对期间的元素重新Hash。最后返回staleSlot之后第一个为null元素的下标。
ThreadLocalMap.cleanSomeSlots
cleanSomeSlots方法代码如下:
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
方法中会执行一个循环,循环的次数是n的对数级别。在循环中,如果元素不为null,并且key为null(这就是陈旧的元素),则调用expungeStaleEntry方法,删除陈旧元素,设置removed为true(标示已经删除元素),重新赋值n为table数组长度(扩大n的值)。
cleanSomeSlots的注释如下:
意思是:试探性的扫描一些陈旧元素。这个方法会在插入新元素,或者删除陈旧元素时调用。它执行指数级扫描,是不扫描(更块,但是会存留垃圾)和线性扫描的一种折中。线性扫描会找到所有的垃圾,但是会导致一些插入时间复杂度为O(n)。
所以这个方法,可能会找到一些陈旧元素并删除,也可能不会。如果找到,返回true,否则返回false。
ThreadLocalMap.rehash
rehash方法代码如下:
private void rehash() {
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}
在rehash方法中,先调用expungeStaleEntries方法删除所有的陈旧元素,expungeStaleEntries方法代码如下:
private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get() == null)
expungeStaleEntry(j);
}
}
expungeStaleEntries方法很简单,就是对每一个元素进行判断,如果元素不为null,并且key为null,则为陈旧元素,即调用expungeStaleEntry方法删除陈旧元素。
回到rehash方法中,只剩下2行代码:
if (size >= threshold - threshold / 4)
resize();
在对Hash表table进行调整前,还做了一次判断size >= threshold - threshold / 4
,注释中给出了解释:用更低的阈值,更好的避免滞后。
最后调用resize方法对Hash表table进行调整,resize接下来详细分析。
ThreadLocalMap.resize
resize方法代码如下:
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}
setThreshold(newLen);
size = count;
table = newTab;
}
resize方法并不复杂,总结起来做了3件事:
- 扩容Hash表,扩容后的大小为原来的2倍。
- 对原Hash表中的每一个元素,在新Hash表中重新Hash。
- 设置新的阈值和Hash表元素数量。
到这里,ThreadLocalMap.set方法分析完毕,接下来分析ThreadLocal.get方法。
get方法
get方法代码如下:
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();
}
方法中,先去获取当前线程,然后获取当先线程的ThreadLocalMap变量threadLocals。于是分为2种情况:
- threadLocals变量为null;
- threadLocals变量不为null;
先看第1种情况,threadLocals为null时,调用setInitialValue方法,代码如下:
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;
}
查看代码,发现这个方法和set方法极度相似,区别只是set方法有参数、无返回值;setInitialValue方法无参数,有返回值。
setInitialValue方法的作用是设置初始值,并返回初始值。设置初始值的方式和set方法完全一样。那设置的初始值从哪里来呢?
从initialValue方法中来,initialValue方法代码如下:
protected T initialValue() {
return null;
}
方法返回null,可以从写此方法,设置ThreadLocal的初始值。
接着看get方法中的第2中情况,threadLocals不为null。这时会调用ThreadLocalMap的getEntry获得元素,如果获得元素为null,处理和第一种情况threadLocals为null一样。
ThreadLocalMap.getEntry方法代码如下:
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);
}
这里也分为2中情况,通过key拿到的元素不为null并且key相等,则直接返回该元素,否则返回getEntryAfterMiss方法调用结果。
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方法中,如果传入的元素e为null,则返回null;如果传入的元素e不为null,则利用while循环,继续查找元素,while循环中的处理如下:
- 判断元素是否为null,如果为null,结束循环,否则继续循环;
- 判断元素的key和传入key是否相同,相同则返回该元素;
- 找到元素不为null,但key为null,说明是陈旧元素,则调用expungeStaleEntry方法删除陈旧元素,经过这一步处理之后,tab[i]的元素要么为null,要么不为null并且key也不为null(如果不理解,请看expungeStaleEntry方法分析),进入步骤5。
- 找到元素不为null,key也不为null,用处理Hash冲突的方式,拿到下一个元素的下标i;
- 取得tab[i],跳转到1;
所以getEntryAfterMiss方法的调用结果只有2中,一种是找到元素,一种就是null。因此getEntry返回的结果也只有2种,一种是找到元素,一种就是null。
最后总结,get方法获取到的结果有3种:
- 找到元素;
- null;
- initialValue方法设置的初始值;如果默认,也为null。
remove方法
remove方法代码如下:
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
代码很简单,就是调用ThreadLocamMap的remove方法,ThreadLocamMap.remove方法代码如下:
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;
}
}
}
ThreadLocamMap.remove代码和很简单。查找元素,如果key相同,清除元素key,使key为null。当key为null后,元素则为陈旧元素,再调用expungeStaleEntry删除陈旧元素。
到这里,ThreadLocal的核心代码就分析完了。
ThreadLocal内存泄漏
内存泄漏的原因
ThreadLocal的引用关系如下图所示:
要保存的数据,保存在Entry对象的value变量中,为强引用。Entry对象的key为ThreadLocal的弱引用。Entry对象保存在ThreadLocalMap对象table数组中,也为强引用。ThreadLocalMap保存在Thread对象的threadLocals变量中,也为强引用。因此存在以下2条引用链:
- Thread ——> ThreadLocalMap ——> Entry ——> Object;
- Thread ——> ThreadLocalMap ——> Entry ------> ThreadLocal;
在第2条引用链中,Entry对ThreadLocal的引用为弱引用,GC进行垃圾回收的时候,如果没有其他对ThreadLocal的强引用,ThreadLocal就会被回收。
在第1条引用链中,所有的引用都是强引用,所以引用链上对象生命周期都和Thread相同,如果Thread长时间未结束,那么Entry、Object对象就不能被回收,从而引起内存泄漏。
内存泄漏的处理
知道了内存泄漏的原因,ThreadLocalMap的设计中也已经考虑到了这种情况,所以Entry中对ThreadLocal的引用为弱引用,当GC回收垃圾的时候,ThreadLocal如果没有其他强引用,则会被回收。这个时候Entry中的key为null,在replaceStaleEntry方法分析中,说到Entry不为null,key为null的情况,即为Entry已经陈旧。
对于陈旧的元素,set、get、remove方法的调用,都会删除它。删除陈旧元素的方法就是元素的value置为null,元素置为null,这样Entry对象就能被GC回收了。
但是并不能保证set、get、remove方法一定会被调用。所以为了确保不发生内存泄漏,需要在ThreadLocal使用完毕后,手动调用remove删除元素。这一点在线程池中尤为重要,因为线程池中的线程会复用,如果复用时,使用的是上一次ThreadLocal的结果,会引发严重的后果。
参考资料
- ThreadLocal 和神奇的数字 0x61c88647
- 深入分析 ThreadLocal 内存泄漏问题
- ThreadLocal和synchronized的区别?
相关阅读