7.13 ThreadLocal
自从学习Java并发以来,过一段时间在我的知乎推荐、csdn推荐或多或少都提到了ThreadLocal内存泄漏的问题,一直以来都没有详细地研究过这个ThreadLocal究竟是一个什么东西,趁这段时间好好地研究研究相关的知识。
首先,我们先得了解什么是ThreadLocal。ThreadLocal类头顶上的注释写得很清楚了:
* This class provides thread-local variables.These variables differ from * their normal counterparts in that each thread that accesses one (via its * {@code get} or {@code set} method) has its own, independently initialized * copy of the variable. {@code ThreadLocal} instances are typically private * static fields in classes that wish to associate state with a thread (e.g., * a user ID or Transaction ID).
这个类提供线程来持有一个属于线程自身的本地变量,这样的变量跟存储在线程工作内存中的共享变量副本不同。
那么什么是“存储在线程工作内存中的共享变量副本”呢?
一、 线程共享变量的“不可见”
线程对共享对象的操作本质上是在对自己的共享对象副本进行操作,当一个线程改变自己的变量时,从线程“本地内存“中的变量副本刷新到主内存中的时机是并不确定的,也就是说一个线程修改共享变量时另外一个线程对这个修改有可能是不可见的。
下面是一个例子:Task
实现了Runnable
接口,它有一个Integer
对象且是public
属性的,当这个对象不为0它就一直执行循环,直到该对象变为值为0;而main方法,也就是main
线程则是去修改这个对象的;从理论上,对象实例都在JVM
的堆上也就是说main
线程只要改变了Integer
对象,按照一般理解堆上的Integer
对象的值就会发生改变,但上文已经提到了JMM
规定了每个线程对共享对象的操作都是在“工作内存”上(这个工作内存是一个概念,并非明确地指内存的某一部分,而是包括CPU缓存(还有JVM
栈,当然在栈上只是我个人的猜测和找了身边朋友讨论后的结果,并没有很细致地去考证,我猜测的理据是JVM
中栈是每个线程独享的)),但是从“工作内存”将值刷新到堆上的时机是并不确定的,也有可能main
线程将值刷新到了堆上而task
线程并没有拿到最新的值,它的对象副本的值仍然是0。
下面是验证代码:
public class Test {
public static void main(String[] args) throws InterruptedException {
Task task = new Task();
Thread t = new Thread(task);
t.start();
TimeUnit.SECONDS.sleep(1);
task.testNum = 0;
System.out.println("end main");
}
static class Task implements Runnable{
public Integer testNum = 1;
@Override
public void run() {
while(true){
if(testNum==0){
break;
}
}
System.out.println("end task");
}
}
}
它的结果就是task
线程一直在循环中不能退出:
当然了,解决方法有很多,比如:用volatile
修饰共享变量、用原子类(本质上跟前者相同)、还有用synchronized
修饰的等等,其实在while循环中不断打印Integer
对象的值也是一个选择,当然这个选择跟使用synchronized一样,因为System.out.println()中也有synchronized修饰代码块。
二、 ThreadLocal的作用
也许看到这里,你会很兴奋的说:ThreadLocal
是用来解决上述问题的,那么很抱歉我必须要泼一盆冷水,并不是这样的。先放上结论:ThreadLocal
是用来设置线程本地变量,某些数据是以线程为作用域并且不同线程具有不同的数据的副本时就可以用ThreadLocal
。按我的理解,ThreadLocal
中最为重要的是其内部类ThreadLocalMap
,这一个Map
的对象才是每个线程真正用来存储线程本地变量。
public class ThreadLocal<T> {
//...
static class ThreadLocalMap {
//...
}
}
而这一个Map
对象在哪呢?顾名思义,将其翻译过来即:线程本地散列表,这一个对象当然是在线程Thread
里面了。
既然是散列表,那么很明显就会有Key-Value
的结构吧,在ThreadLocalMap
中也有一个内部类名为Entry
,也就是散列表中的每一个节点的类,他继承了弱引用WeakReference
,从构造器可以看的出Entry
是以ThreadLocal
作为Key
,这么做有什么好处呢我们下文再解释。
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private Entry[] table;
//...
}
所以线程的结构其实如下:
也就是说:ThreadLocalMap
是以ThreadLocal
作为Key
存储线程本地变量,ThreadLocal
对象是用来操作ThreadLocalMap
的。
三、ThreadLocal的重要操作
上文已经得出了结论:ThreadLocal
对象并非真正存储线程本地变量,而是各个ThreadLocalMap
来存储,而ThreadLocal
对象是用来操作ThreadLocalMap
。下文将会分析其中的操作的方法。
1.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);
}
以上就是ThreadLocal
中的set
方法,首先获得当前线程以及他的ThreadLocalMap
,判断这个散列表是否为null
,如果是就给这个线程创建一个本地变量散列表并将当前值存进散列表中;否则存入以当前ThreadLocal
对象为Key
的键值对。实际上设置线程本地变量就是调用ThreadLocalMap
中的set
方法,需要传入当前ThreadLocal
对象以及value
。下面就研究一下ThreadLocalMap
当中的set
方法。
ThreadLocalMap中的set方法
private void set(ThreadLocal<?> key, Object value) {
//...
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
//...
}
}
进入了这个方法,首先会算出当前ThreadLocal
对象在散列表中可能出现的位置,并循环地寻找当前ThreadLocal
对象在散列表中可能的位置,由于有可能产生哈希冲突,ThreadLocalMap
解决哈希冲突的做法是开放地址法,即产生哈希冲突后往后每一个每一个位置地去试探哪个位置可能放得下当前键值对。散列表当前位置的Key
是当前ThreadLocal
对象,那么就将这个值覆盖到当前位置,完成set
方法;
//...
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
//...
如果当前位置的Key
为null
,并非散列表当前位置为null
,有可能是在ThreadLocal
对象被回收,那么Entry
的Key
也会被回收,但这一时刻仍未发生gc
,弱引用还未被回收,那么Entry
对象还没回收也就是说散列表当前位置并不是为null
,那么就将进入replaceStaleEntry
方法;
//...
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
如果扫描到散列表中某一位置为null
那么就插入一个新的Entry
对象,并做一些操作,这些操作下面都会一一细说。
ThreadLocalMap中的replaceStaleEntry方法
private void replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot) {
//...
for (int i = prevIndex(staleSlot, len);(e = tab[i]) != null;i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
//...
}
进入这一个方法,因为我们获得插入的位置有可能是经过多次哈希冲突后的结果,所以进入这一方法后做的第一件事是在当前位置之前寻找是否有一些因为gc
而产生的Key
为null
的位置,直到散列表中当前位置的Entry
为null
,这样做是为了减少因为还未发生gc
回收而产生的重哈希,提高查询的命中能力,这个循环的任务就是寻找散列表中接近当前ThreadLocal
对象哈希值的应该被回收的Entry
对象的下标。
//...
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;
}
接下来这一个循环尝试寻找散列表中是否有与当前ThreadLocal
对象相同但因为哈希冲突跑到比现在理应插入位置staleSlot
还要后的位置i
(仔细回想上面第一部分哈希冲突时的过程),有的话将它的value
覆盖后交换两个Entry
的位置,然后将需要清理的位置slotToExpunge
置为i
(没有理由让我们辛辛苦苦插入之后回收位置为staleSlot
的Entry对象吧),并调用cleanSomeSlots
方法和expungeStaleEntry
方法。
下图为上述过程的一个描述:
有的小伙伴可能会问了:在第二个循环中没有找到相同的Key
时那这个键值对应该插入到哪里呢?答案是插入到一开始经过开放性地址法找到的应该插入的位置,即下面这一小段代码了,过多的东西就不解释了。
tab[staleSlot].value = null;//帮助gc回收原来散列表下标为staleSlot指向的Entry对象
tab[staleSlot] = new Entry(key, value);
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
ThreadLocalMap中的expungeStaleEntry方法与cleanSomeSlots方法
这两个方法只讲述它们干了什么活,就不详细地一步一步分析了。
private int expungeStaleEntry(int staleSlot) {
//...
//帮助gc回收散列表中下标为staleSlot的Entry对象
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// ...
//从回收位置向后,直到找到null的Entry对象
for (i = nextIndex(staleSlot, len);(e = tab[i]) != null;i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
//帮助gc回收key为null的Entry
//...
} else {
//重新计算哈希值
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
//如果计算到的哈希值不是当前下标,那么就将当前下标的Entry挪到一个合适的tab[h],
//并将散列表下标为i的位置置为null
tab[i] = null;
//如果哈希冲突了就使用开放地址法
while (tab[h] != null)
h = nextIndex(h, len);
//直到找到相应的下标
tab[h] = e;
}
}
}
//返回一个散列表中下标位置指向null的下标
return i;
}
以上是expungeStaleEntry
方法,比较简单就不多赘述了。
接下来是cleanSomeSlots
方法
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
//i就是上面指向null的下标
do {
i = nextIndex(i, len);
Entry e = tab[i];
//尝试将key为null而对象不为null的Entry回收
if (e != null && e.get() == null) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
可能有的小伙伴会发现这里的循环条件是(n >>>= 1) != 0
,这里尝试回收的时间复杂度最优为O(log n),也就是向后走log n
次都是不需要进行回收的;最坏的时间复杂度为O(nlog n),也就是说每一步都需要回收对象,并且下一个null
对象离当前很远。
2. get方法
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();
}
首先,该方法获得当前线程的ThreadLocalMap
对象,当该对象不为null
,尝试获得以这一个ThreadLocal
对象为Key
的元素e
,如果不为null
那么就返回他的value
值,否则的话会将一个以当前ThreadLocal
对象为Key
,值为null
的对象newEntry
尝试插入当前ThreadLocalMap
对象中(如果Map
为null
就创建一个Map
对象)并返回newEntry
。通过上面的代码可以看到,这一个get
方法本质上是调用ThreadLocalMap
中的getEntry
方法,接下来我们来看一下这一个方法。
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);
}
这段代码也很简单,直接能找得到Entry
对象就返回,否则调用getEntryAfterMiss
使用开放地址法寻找对应的Entry
对象,值得注意的是getEntryAfterMiss
方法在使用开放地址法寻找的时候遇到Key
为null
的Entry
时也会帮忙调用expungeStaleEntry
方法。
四、ThreadLocal的使用危机
我们在搜索引擎搜索ThreadLocal
时往往有一个结果是"ThreadLocal
内存泄漏",这究竟是什麽一回事呢?我们先看看什么是内存泄漏:
内存泄漏(Memory leak)是在计算机科学中,由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。
——维基百科
前文已经提到过,在ThreadLocalMap
中Entry
是继承了弱引用,是引用各种ThreadLocal
对象,假如现在ThreadLocalMap
里存有一个Entry
,它是ThreadLocal t
的弱引用,这是时将t
置为null
(也就是说把在堆中的对象t
的强引用给消除掉),因为弱引用的对象并定在下一次发生gc
的时候堆中的对象t
将会被回收。因为我们将t
对象的强引用断掉,也就是说现在t==null
,所以就会产生Key
为null
的Entry
,这样的Entry
是无法被获得到它的Value
值的。由于Entry
对应的散列表仍持有对它的强引用,因此如果不手动设置断开强引用这样的Entry
是没有办法被回收掉的,”导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费“。
因此,我们使用ThreadLocal
的时候必须要小心谨慎地使用:第一,防止Key
为null
的Entry
出现,使用ThreadLocal
的时候尽量不要手动将ThreadLocal
的引用手动设置为null
,即使expunge
方法能回收这些Key
为null
的Entry
对象,但避免手动设置null
可以降低内存泄漏的几率;第二,防止ThreadLocal
对象长时间存在,我们可以在线程池(线程池可以看看这篇文章)中使用ThreadLocal
方便我们进行传参,但是因为线程池的核心池中的线程并不会销毁,如果在这些核心线程中使用到了ThreadLocal
那么这些对象也不会被回收,也就意味着ThreadLocal
、对应的Entry
以及它的Value
对象都不会被gc
回收,我们本希望这些对象随着线程的消亡而被垃圾收集器回收掉而这种情况下并没有发生,也就是产生了内存泄漏的问题了。
五、 ThreadLocal在本博客项目中的使用
在本博客项目中,由于作者本人比较懒惰,并没有自己编写一套用户系统而是直接接入Github
的第三方登录只保存评论、用户token
等信息,由于Github api
并没有提供批量查询用户信息的接口,因此目前的做法是比较笨的一种:先在数据库中读出文章相关评论,接着循环使用restTemplate
发送请求。这样做的并发度并不高(虽然没人评论的当下可以凑合着来使用),但是在学习完ThreadLocal
以及线程池之后我萌生了优化这个系统的想法。
我的想法如下:如果评论数增多之后,由于网络访问是耗时较长,串行获得token
的用户信息效率比较低下;我可以在数据库中增加一张表,存储token
、token
对应的平台以及用户信息存进去;读取评论的时候就现在数据库中寻找对应的token
,剩下不能在数据库中寻找到对应的token
就使用网络访问第三方用户信息的接口,为了提高并发度可以使用多线程,但由于直接创建多线程的控制度比较小,因此选择线程池是一个不错的选择;不使用ThreadLocal
的话就需要使用Future
以及使用线程池的submit
方法,接收对应的POJO;而使用ThreadLocal
可以方便的将POJO的引用传入对应的线程中,无需考虑返回值的问题,但是使用ThreadLocal
的时候必须要注意上述的危害。
如果上述文章有任何错误还希望各位大佬指出,感谢阅读。