ThreadLocal学习

ThreadLocal简介

ThreadLocal顾名思义,就是线程局部变量,也可以叫线程变量。可以让不同的线程访问自己独有的变量,而不会影响其他线程变量,实现了线程隔离级别的变量存储。

常见的使用场景包括:

  • 会话管理:在web应用中,可以使用线程变量来存储请求中的相关信息,这样在后续处理请求的过程中都可以方便的获取到请求相关的信息,比如用户的相关信息。
  • 数据库连接管理:在并发访问数据库的场景下,使用线程变量管理数据库的连接,这样每个线程都有自己独立的数据库连接,避免线程之间数据不一致和连接冲突的问题。

工作原理

Thread类中有一个threadLocals的属性,这是一个ThreadLocalMap对象。ThreadLocalMap可以被看做是一个集合,其内部维护一个Entry[]数组,用来保存ThreadLocal的引用。

1
2
3
4
5
6
7
8
9
static class Entry extends WeakReference<ThreadLocal<?>> {

Object value;

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

当调用ThreadLocal的get方法时,其实是从Thread类中去取threadLocals的值,getMap方法就是从当前的thread对象里面取出threadLocals,如果取出来为null就说说明当前线程还没有创建过ThreadLocalMap就会调用setInitialValue方法来初始化一个ThreadLocalMap

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();
}

总的来说,ThreadLocal是通过在每个线程对象的实例中都维护一个threadLocalMap,然后将ThreadLocal作为map的key(准确来说是ThreadLocal对象的哈希值和此时Entry数组的长度按位与),value就是我们set的对应值,这样每个线程都会有自己的一个map,我们也可以在这一个线程中的任意地方来获取这个map中的值,并且不同线程之间不会互相影响。

内存泄漏问题

1. 不会存在内存泄漏的情况

不使用线程池创建而单独创建线程时,这种情况下因为ThreadLocalMapThread类的一个成员变量,会随着线程的消亡而消亡,即使我们不手动将value移除,ThreadLocalMap也不会存在,其中的Entry数组也不会存在。

在实际项目中一般也不会这样去使用ThreadLocal,这样使用也毫无意义,并且通常情况下我们都是用线程池来创建对象,线程池中的核心线程基本会伴随着应用程序的整个生命周期。

另一种情况是我们实例化的ThreadLocal对象是一个长生命周期的,通常就是static修饰的静态类变量,这种情况下ThreadLocal对象的生命周期和应用程序一样长,一般不将其人为赋值为null,就不会出现弱引用的情况,GC也就不会将其给回收掉。但是在我们使用完变量之后还是要将其remove掉,否则另一个用户发起会话可能会错误的读取到上一个用户的值。

2.存在内存泄漏的情况

当我们创建的ThreadLocal对象是短生命周期的,随着ThreadLocal对象变成null之后,GC会回收ThreadLocalMap中弱引用的ThreadLocal对象,而它对应的value由于是一个强引用的对象,无法被回收,这个时候value和线程的生命周期一样长,就会一直占着内存造成内存泄漏问题。

为什么key是弱引用

弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

简单理解就是当垃圾回收时,该对象只被WeakReference对象的弱引用字段(T reference)所引用,而未被任何强类型的对象引用,那么,该弱引用的对象就会被回收。

  1. 假设 Entry 的 key 是对 ThreadLocal 对象的强引用:这个 Entry 又持有 ThreadLocal 对象和 value 对象的强引用。如果在其他地方都没有对这个 ThreadLocla 对象的引用了,然后在使用 ThreadLocalMap 的过程中又没有正确地在用完后就调用 remove 方法,所以这个 ThreadLocal 对象和所关联的 value 对象就会跟随着线程一直存在,这样就会可能会造成内存泄漏问题。
    特别是在使用线程池的时候,核心线程是会一直存在直到程序结束,如果这些线程中的 ThreadLocalMap 中的数据没有被及时清理,就会一直占用内存,而且在线程复用时可能会导致数据错乱的危险。
  2. Entry 的 key 是对 ThreadLocal 对象的弱引用:弱引用就意味着,如果没有其他引用对象的强引用关系,那么这个仅被弱引用引用着的对象在下次 GC 时就会被回收掉,这样在一定程度上降低内存泄漏的风险。但同时也引入了新的问题,key 虽然被回收了,但是 value 对象还在,我们无法获取,也无法删除,这样也会存在内存泄漏的风险。虽然 ThreadLocalMap 中在进行 set 和 get 操作时会进行启发式清理和探测式清理,清理一部分 key 为 null 的 Entry 对象,但是这也只是一种后备选择方案,最重要的还是开发人员在编写代码时记得在使用完数据后及时调用 remove() 方法手动清理

清理策略

ThreadLocalMap一共有两种清理策略,分别是探测式清理启发式清理

  • 探测式清理:源码中的expungeStaleEntry()方法
  • 启发式清理:源码中的cleanSomeSlots()方法

1. 探测式清理

探测式清理会从指定的位置(也就是staleSlot)开始向后探测清理过期的数据,将过期的数据,也就是key==nullEntry设置为null,沿途中碰到未过期的数据,就将此数据rehash后重新在table数组中定位,如果定位到的位置已经有数据了,则会依次向后遍历,将未过期的数据放到最靠近此位置并且Entry==null的位置,是的rehash后的Entry数据里正确的桶的位置更近一些。

简单总结就是探测式清理会将key==null位置的Entry也置为null,不为空的则会重新计算哈希值分配位置,如果重新分配的位置上有元素了,就往后延续。

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
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

// Rehash until we encounter null
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;

// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

2. 启发式清理

启发式清理需要接受两个参数,分别是:

  1. 探测式清理后返回的下标
  2. 数组的总长度

从源码可以看出,启发式清理从传入的下标i开始向后遍历,如果发现过期的Entry就会再次触发探测式清理,并将n重置为table长度,经过数组长度的2的整数次幂的梁旭遍历之后如果没有发现过期的Entry,就人为数组中没有过期的Entry了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}

3. 哪些地方会触发这两种清理方式

  1. set() 方法中,遇到key==null的情况会触发一轮探测式清理
  2. set()方法最后会执行一次启发式清理
  3. rehash()方法会调用一次探测式清理
  4. get()方法中入到key过期的时候也会触发一次探测式清理
  5. 启发式清理过程中遇到key==null时会触发探测式清理