ThreadLocal详解

1. ThreadLocal 介绍

ThreadLocal的定义如下:

此类提供线程局部变量。这些变量与普通变量的不同之处在于,每个线程访问一个变量时(通过getset方法)都有自己独立初始化的变量副本。ThreadLocal实例通常是希望将状态与线程(例如,用户ID或事务ID)相关联的类中的私有静态字段。
也就是说 ThreadLocal 为每个线程都提供了变量的副本,使得每个线程在某一时间访问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享

ThreadLocal 和 Synchronized 的区别

  • ThreadLocal为每个线程都提供了变量的副本,使得每个线程在某一时间访问到的并非同一个对象,从而隔离了多个线程对数据的共享访问。
  • Synchronized是通过锁的机制,使变量或代码块在某一时间只能被一个线程访问,而ThreadLocal则是副本机制。

ThreadLocal 的使用

ThreadLocal类接口很简单,只有4个方法:

  • void set(Object value): 设置当前线程的线程局部变量的值。
  • public Object get(): 获取当前线程所对应的线程局部变量。
  • public void remove(): 移除当前线程的局部变量值,以减少内存占用。
  • protected Object initialValue(): 返回线程局部变量的初始值。
initialValue()

返回该线程局部变量的初始值,该方法是一个 protected 的方法,显然是为了让子类覆盖而设计的。这个方法是一个延迟调用方法,在线程第 1 次调用 get(或 set(Object)时才执行,并且仅执行 1 次。ThreadLocal 中的缺省实现直接返回一个 null。

2. ThreadLocal 实现解析

ThreadLocalMap

ThreadLocal 的性能远超Syncronized,这是因为ThreadLocal并没有使用锁,而是通过每个线程维护一个ThreadLocalMap来实现的。

由于变量副本跟随着线程本身,而不是将变量副本放在一个地方保存,这样就可以在存取时避开线程之间的竞争。

同时,因为每个线程所拥有的变量的副本数是不定的,有些线程可能有一个,有些线程可能有 2 个甚至更多,则线程内部存放变量副本需要一个容器,而且容器要支持快速存取,所以在每个线程内部都可以持有一个 Map 来支持多个变量副本。

这个Map就是ThreadLocalMap,它是ThreadLocal的静态内部类,每个Thread对象都持有一个ThreadLocalMap

threadLocalMap

1
2
3
4
5
6
7
8
9
10
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private Entry[] table;
}

可以看到,Entry继承了WeakReference,表明ThreadLocal的Key是弱引用,这意味着当ThreadLocal对象被GC回收时,Key会变为null

Hash冲突的解决

ThreadLocalMap采用的是开放定址法来解决Hash冲突。
基本思想是,出现冲突后按照一定算法查找一个空位置存放,根据算法的不同又可以分为线性探测再散列、二次探测再散列、伪随机探测再散列。线性探测再散列即依次向后查找,二次探测再散列,即依次向前后查找,增量为 1、2、3 的二次方,伪随机,顾名思义就是随机产生一个增量位移。
ThreadLocal 里用的则是线性探测再散列

其他解决Hash冲突办法

链地址法:
这种方法的基本思想是将所有哈希地址为 i 的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第 i 个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。Java里的 HashMap 用的就是链地址法,为了避免 hash 洪水攻击,1.8 版本开始还引入了红黑树。
再哈希法:
这种方法是同时构造多个不同的哈希函数:Hi=RH1(key) i=1,2,…,k当哈希地址 Hi=RH1(key)发生冲突时,再计算 Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。

3. ThreadLocal 引发的内存泄漏分析

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
public class ThreadLocalMemoryLeak {
private static final int TASK_LOOP_SIZE = 500;

/*线程池*/
final static ThreadPoolExecutor poolExecutor
= new ThreadPoolExecutor(5, 5, 1,
TimeUnit.MINUTES,
new LinkedBlockingQueue<>());

static class LocalVariable {
private byte[] a = new byte[1024*1024*5];/*5M大小的数组*/
}

ThreadLocal<LocalVariable> threadLocalLV;

public static void main(String[] args) throws InterruptedException {
SleepTools.ms(4000);
for (int i = 0; i < TASK_LOOP_SIZE; ++i) {
poolExecutor.execute(new Runnable() {
public void run() {
SleepTools.ms(500);

// LocalVariable localVariable = new LocalVariable();


ThreadLocalMemoryLeak oom = new ThreadLocalMemoryLeak();
oom.threadLocalLV = new ThreadLocal<>();
oom.threadLocalLV.set(new LocalVariable());

oom.threadLocalLV.remove();

System.out.println("use local varaible");
}
});
SleepTools.ms(100);
}
System.out.println("pool execute over");
}
}

内存泄漏的现象

场景1:线程池任务执行完成后,内存占用基本为25M左右。

场景2:任务中new出一个数组后,内存占用和场景1相同。

场景3:启用ThreadLocal后,内存占用上升到100M以上。

场景4:调用remove()方法后,内存占用恢复到25M左右。

内存泄漏的原因

  1. ThreadLocal的Key是弱引用,GC时Key会被回收,导致ThreadLocalMap中的Entry的Key为null
  2. value仍然被线程池中的线程持有,不会被回收,形成内存泄漏。
  3. 线程池中的线程长期存活,而ThreadLocal变量未被手动清除,导致ThreadLocalMap中的无效Entry无法及时清理。

threadLocalMemoryLeak

详细解释

图中的虚线表示弱引用。
这样,当把 threadlocal 变量置为 null 以后,没有任何强引用指向 threadlocal实例,所以 threadlocal 将会被 gc 回收。这样一来,ThreadLocalMap 中就会出现key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value,如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value,而这块 value 永远不会被访问到了,所以存在着内存泄露。
只有当前 thread 结束以后,current thread 就不会存在栈中,强引用断开,Current Thread、Map value 将全部被 GC 回收。最好的做法是不在需要使用ThreadLocal 变量后,都调用它的 remove()方法,清除数据。
所以回到我们前面的实验场景,场景 3 中,虽然线程池里面的任务执行完毕了,但是线程池里面的 5 个线程会一直存在直到 JVM 退出,我们 set 了线程的localVariable 变量后没有调用 localVariable.remove()方法,导致线程池里面的 5 个线程的 threadLocals 变量里面的 new LocalVariable()实例没有被释放。
其实考察 ThreadLocal 的实现,我们可以看见,无论是 get()、set()在某些时候,调用了 expungeStaleEntry 方法用来清除 Entry 中 Key 为 null 的 Value,但是这是不及时的,也不是每次都会执行的,所以一些情况下还是会发生内存泄露。只有 remove()方法中显式调用了 expungeStaleEntry 方法。

解决方案

使用ThreadLocal时,务必在不再使用时调用remove()方法。

1
2
3
4
5
6
try {
threadLocal.set(value);
// 业务逻辑
} finally {
threadLocal.remove();
}

为什么使用弱引用?

key 使用强引用:对 ThreadLocal 对象实例的引用被置为 null 了,但是ThreadLocalMap 还持有这个 ThreadLocal 对象实例的强引用,如果没有手动删除,ThreadLocal 的对象实例不会被回收,导致 Entry 内存泄漏。

key 使用弱引用:对 ThreadLocal 对象实例的引用被被置为 null 了,由于ThreadLocalMap 持有 ThreadLocal 的弱引用,即使没有手动删除,ThreadLocal 的对象实例也会被回收。value 在下一次 ThreadLocalMap 调用 set,get,remove 都有机会被回收。

比较两种情况,我们可以发现:由于 ThreadLocalMap 的生命周期跟 Thread一样长,如果都没有手动删除对应 key,都会导致内存泄漏,但是使用弱引用可以多一层保障。

因此,ThreadLocal 内存泄漏的根源是:由于 ThreadLocalMap 的生命周期跟Thread 一样长,如果没有手动删除对应 key 就会导致内存泄漏,而不是因为弱引用。

总结
JVM 利用设置 ThreadLocalMap 的 Key 为弱引用,来避免内存泄露。
JVM 利用调用 remove、get、set 方法的时候,回收弱引用。
当 ThreadLocal 存储很多 Key 为 null 的 Entry 的时候,而不再去调用 remove、get、set 方法,那么将导致内存泄漏。
使用线程池+ ThreadLocal 时要小心,因为这种情况下,线程是一直在不断的重复运行的,从而也就造成了 value 可能造成累积的情况。