ThreadLocal真的会内存泄漏吗

在多线程环境下,比如在spring容器类中定义的变量,如何防止自己的变量被其它线程篡改。想要解决这个问题,不得不提到一个类:ThreadLocal

ThreadLocal的业务场景是什么?我们怎么用好这个类?你知道ThreadLocal的内存泄漏吗?当我们知道ThreadLocal的原理之后,这个问题也会迎刃而解。

我们首先看一下ThreadLocal的常用Api实现

set过程


1
2
3
4
5
6
7
8
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

步骤讲解

  1. 首先获取当前线程
  2. getMap是指从当前线程中获取到线程的threadLocals,threadLocals是线程中的一个静态内部类ThreadLocalMap
    变量为threadLocals
    1
    2
    3
    ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
    }
1
ThreadLocal.ThreadLocalMap threadLocals = null;
  1. 判断获取到的threadLocals是否为空,不为空插入数据,this为当前Threadlocal对象,value为设置的值,set过程过还伴随着清除的过程,若空间不够,还会对全部的entry进行扫描清除,进行resize
  2. 否则创建threadLocals并设置值,
    创建ThreadLocalMap过程
    1
    2
    3
    void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
    ThreadLocalMap内部又是一个继承了弱引用的ThreadLocal的entry数组

new ThreadLocalMap(this, firstValue) 的过程

1
2
3
4
5
6
7
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);
}

首先初识化一个Entry数组,entry的key持有ThreadLocal的弱引用,简单介绍一下,弱引用会在jvm发生gc时没有其他强引用关联时而断开弱引用链进行垃圾回收。稍后我会对这个情况进行分析。其次通过threadLocalHashCode找到在entry中的位置,放入到entry中,最后初始化threshold,超过threshold即2/3自动扩容

1
2
3
4
5
setThreshold(INITIAL_CAPACITY);

private void setThreshold(int len) {
threshold = len * 2 / 3;
}

归纳一下上面的流程,简要的来说就是ThreadLocal只是一个符号,并不存储任何值,通过当前线程中的ThreadLocalMap存储值,ThreadLocalMap中有一个entry数组,entry数组继承弱引用,在entry放入key,value时,entry会调用父类的构造方法,意思就是entry的key只持有ThreadLocal的弱引用。

1
2
3
4
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}

找到一张著名的网图说明一下这个关系链

get过程


在我们熟悉set过程之后,get过程更好理解了

1
2
3
4
5
6
7
8
9
10
11
12
13
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();
}
  1. 首先获取当前线程
  2. 获取到当前线程的ThreadLocalMap
  3. 判断ThreadLocalMap是否为null,为null则跳到第6步初始化
  4. 获取当前threadLocalHashCode对应的entry值
  5. 返回entry的value值,就是在业务中往ThreadLocal设置的那个值
  6. 若为null,初始化值
    看一下初始化值的操作
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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;
    }
    上面段代码简要分析就是初始化值,initialValue返回固定返回一个null,然后下面几段代码在get方法中已经分析了,这里就不在分析。总之就是把null放到entry的value位置,返回null。

需要注意的是在map.getEntry(this);这里的时候,还额外做了一个操作,就是判断获取到的entry或者entry的key是否为null,因为ThreadLocalMap使用线性地址法存储值,所以还需要依次找下去,直到entry为空为止,并清除掉key为空的entry
getEntryAfterMiss(key, i, e),核心的清除算法见文底,也可以看尾部其他作者讲解threadLocal的详细源码过程。

1
2
3
4
5
6
7
8
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);
}

remove过程


remove相对于其他两个方法比较简单,核心方法如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;
}
}
}
  1. 找到threadLocalHashCode所在的位置,也就是threadLocal在entry中的位置
  2. 因为Entry是以线地址法解决冲突而放进去的值,所以需要沿着线遍历找到正确的值
  3. 清除threadlocal的引用
  4. 对后续的值进行段式清除

其中主要的是expungeStaleEntry这个方法,这也是get、set方式清除无效引用的核心方法,以下解析截取自https://www.cnblogs.com/micrari/p/6790229.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* 这个函数是ThreadLocal中核心清理函数,它做的事情很简单:
* 就是从staleSlot开始遍历,将无效(弱引用指向对象被回收)清理,即对应entry中的value置为null,将指向这个entry的table[i]置为null,直到扫到空entry。
* 另外,在过程中还会对非空的entry作rehash。
* 可以说这个函数的作用就是从staleSlot开始清理连续段中的slot(断开强引用,rehash slot等)
*/
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// 因为entry对应的ThreadLocal已经被回收,value设为null,显式断开强引用
tab[staleSlot].value = null;
// 显式设置该entry为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();
// 清理对应ThreadLocal已经被回收的entry
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
/*
* 对于还没有被回收的情况,需要做一次rehash。
*
* 如果对应的ThreadLocal的ID对len取模出来的索引h不为当前位置i,
* 则从h向后线性探测到第一个空的slot,把当前的entry给挪过去。
*/
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;

/*
* 在原代码的这里有句注释值得一提,原注释如下:
*
* Unlike Knuth 6.4 Algorithm R, we must scan until
* null because multiple entries could have been stale.
*
* 这段话提及了Knuth高德纳的著作TAOCP(《计算机程序设计艺术》)的6.4章节(散列)
* 中的R算法。R算法描述了如何从使用线性探测的散列表中删除一个元素。
* R算法维护了一个上次删除元素的index,当在非空连续段中扫到某个entry的哈希值取模后的索引
* 还没有遍历到时,会将该entry挪到index那个位置,并更新当前位置为新的index,
* 继续向后扫描直到遇到空的entry。
*
* ThreadLocalMap因为使用了弱引用,所以其实每个slot的状态有三种也即
* 有效(value未回收),无效(value已回收),空(entry==null)。
* 正是因为ThreadLocalMap的entry有三种状态,所以不能完全套高德纳原书的R算法。
*
* 因为expungeStaleEntry函数在扫描过程中还会对无效slot清理将之转为空slot,
* 如果直接套用R算法,可能会出现具有相同哈希值的entry之间断开(中间有空entry)。
*/
while (tab[h] != null) {
h = nextIndex(h, len);
}
tab[h] = e;
}
}
}
// 返回staleSlot之后第一个空的slot索引
return i;
}

执行原理如下图所示

ThreadLocal的常见问题


  1. gc后key一定会为null吗,get为什么还可以获取到值

在我们使用ThreadLocal的时候,常使用全局的final static来修饰ThreadLocal变量,这种变量的生命周期和jvm一样长,存放在jvm的方法区内,会一直保持有保持对ThreadLocal的强引用,所有不管jvm何时gc,只要我们持有这种引用关系,ThreadLocal就不会被回收,ThreadLocal不被回收,弱引用关系自然也存在,我们也就可以获取到他的值。举个String类型的弱引用看一下就可以很清楚的明白这个关系,ThreadLocal道理也是如此

1
2
3
4
5
6
7
WeakReference<String> staticString=new WeakReference<>(test);
WeakReference<String> ordinaryString=new WeakReference<>(new String("ordinaryString"));
System.out.println(staticString.get());
System.out.println(ordinaryString.get());
System.gc();
System.out.println(staticString.get());
System.out.println(ordinaryString.get());


2. 为什么ThreadLocalMap内部是entry数组,而不是单个entry

因为在一个线程中,不止我们使用的ThreadLocal,还存在其他地方设置的ThreadLocal,比如Tomcat内,在idea环境中看一下实际情况


在我们还没有设置ThreadLocal自己的值的时候,在线程内已经存在了三个ThreadLocal设置的值,所有说需要我们用一个Entry数组来接收不同的ThreadLocal对象设置的值
3. 为什么ThreadLocalMap中的Entry不用强引用,而继承WeakReference

因为在我们开发过程中,经常使用线程池进行线程的链接复用,如果用强引用的话,会造成我们已经离开的ThreadLocaL变量的作用域,而ThreadLoca却迟迟得不到回收,下面我通过画一张图来说明这个情况

若使用强引用方式,在ThreadLocal ref断开之后,但是entry中的key和ThreadLocal对象的引用却没有断开,在之后也没有对ThreadLocal进行remove,在后续线程不进行使用或者不正确的使用下,则会发生内存泄漏或者数据混乱

  1. 真的会内存泄漏吗

在正常情况下,我们对线程池进行使用,在对在ThreadLocal进行set、get时ThreadLocalMap内部都会对entry数组进行expungeStaleEntry操作,在扩容时还会进行全量清除,在网上的篇子中常说会持有 当前线程->当前线程的threadLocals(ThreadLocal.ThreadLocalMap对象)->Entry数组->某个entry.value】,并且key为null的情况,但是在同一个线程对同一个ThreadLocal进行expungeStaleEntry会自我清理,在set的时候又会重新设置进这个位置,key为null的情况又被还原了,因为threadLocalHashCode在同一个ThreadLocal对象下值不会变。除非线程池线程不在使用,或者ThreadLocalMap无法扩容或者无法遍历到entry中这个位置,这种情况下才会发生内存泄漏,但是为了安全,请保持好良好的编码习惯,及时remove
5. 为什么会数据混乱

在我们对ThreadLocal进行使用的时候,没有良好的编码习惯,对ThreadLocal没有进行remove,并且在进行逻辑判断的时候还意外的进入了get方法内,没有进行set,这个时候就会把上次设置进的值重新取出来,造成数据的紊乱。举个列子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1, 1, 10, TimeUnit.SECONDS, new ArrayBlockingQueue(10));
//i代值不同的用户
for (int i=0;i<5;i++){
threadPoolExecutor.execute(()->{
String name = Thread.currentThread().getName();
System.out.println(name);
if(Math.random()>0.6){
threadLocal.set((int)(Math.random()*1000000));
System.out.println(threadLocal.get());
}else {
System.out.println(threadLocal.get());
}
});
}
}


只取一个线程,代表复用这个线程,不断的执行业务代码,当我们第一次进入到(int)(Math.random()*1000000)的时候,设置进了值,但是我们没有remove,在下次判断的时候又没有进入set里面,导致还是取用了上次的值,导致数据混乱,所以说还是要养成良好的编码习惯,及时remove

以下是我看过的最精彩的一篇ThreadLocal源码文章,详细讲解了阶段清除和全量清除的步骤:

参考文章:https://www.cnblogs.com/micrari/p/6790229.html