【Java 并发】详解 ThreadLocal
前言
ThreadLocal 主要用来提供线程局部变量,也就是变量只对当前线程可见,本文主要记录一下对于 ThreadLocal 的理解。更多关于 Java 多线程的文章可以转到 这里。
线程局部变量
在多线程环境下,之所以会有并发问题,就是因为不同的线程会同时访问同一个共享变量,例如下面的形式
1 | public class MultiThreadDemo { |
在上面的代码中,increase 线程和 decrease 线程会操作同一个 number 中 value,那么输出的结果是不可预测的,因为当前线程修改变量之后但是还没输出的时候,变量有可能被另外一个线程修改,下面是一种可能的情况:
1 | increase value: 10 |
一种解决方法是在 increase()
和 decrease()
方法上加上 synchronized 关键字进行同步,这种做法其实是将 value 的 赋值 和 打印 包装成了一个原子操作,也就是说两者要么同时进行,要不都不进行,中间不会有额外的操作。我们换个角度考虑问题,如果 value 只属于 increase 线程或者 decrease 线程,而不是被两个线程共享,那么也不会出现竞争问题。一种比较常见的形式就是局部(local)变量(这里排除局部变量引用指向共享对象的情况),如下所示:
1 | public void increase() throws InterruptedException { |
不论 value 值如何改变,都不会影响到其他线程,因为在每次调用 increase 方法时,都会创建一个 value 变量,该变量只对当前调用 increase 方法的线程可见。借助于这种思想,我们可以对每个线程创建一个共享变量的副本,该副本只对当前线程可见(可以认为是线程私有的变量),那么修改该副本变量时就不会影响到其他的线程。一个简单的思路是使用 Map 存储每个变量的副本,将当前线程的 id 作为 key,副本变量作为 value 值,下面是一个实现:
1 | public class SimpleImpl { |
但是上面的实现会存在下面的问题:
- 每个线程对应的副本变量的生命周期不是由线程决定的,而是由共享变量的生命周期决定的。在上面的例子中,即便线程执行完,只要
number
变量存在,线程的副本变量依然会存在(存放在 number 的 cacheMap 中)。但是作为特定线程的副本变量,该变量的生命周期应该由线程决定,线程消亡之后,该变量也应该被回收。 - 多个线程有可能会同时操作 cacheMap,需要对 cacheMap 进行同步处理。
为了解决上面的问题,我们换种思路,每个线程创建一个 Map,存放当前线程中副本变量,用 CustomThreadLocal 的实例作为 key 值,下面是一个示例:
1 | public class SimpleImpl2 { |
在上面的实现中,当线程消亡之后,线程中 cacheMap 也会被回收,它当中存放的副本变量也会被全部回收,并且 cacheMap 是线程私有的,不会出现多个线程同时访问一个 cacheMap 的情况。在 Java 中,ThreadLocal 类的实现就是采用的这种思想,注意只是思想,实际的实现和上面的并不一样。
使用示例
Java 使用 ThreadLocal 类来实现线程局部变量模式,ThreadLocal 使用 set 和 get 方法设置和获取变量,下面是函数原型:
1 | public void set(T value); |
下面是使用 ThreadLocal 的一个完整示例:
1 | public class ThreadLocalDemo { |
下面是一种可能的输出:
1 | Thread-0: threadLocal=87, value=15 |
我们看到虽然
threadLocal
是静态变量,但是每个线程都有自己的值,不会受到其他线程的影响。具体实现
ThreadLocal 的实现思想,我们在前面已经说了,每个线程维护一个 ThreadLocalMap 的映射表,映射表的 key 是 ThreadLocal 实例本身,value 是要存储的副本变量。ThreadLocal 实例本身并不存储值,它只是提供一个在当前线程中找到副本值的 key。 如下图所示:
图片来自 http://blog.xiaohansong.com/2016/08/06/ThreadLocal-memory-leak/
我们从下面三个方面看下 ThreadLocal 的实现:
- 存储线程副本变量的数据结构
- 如何存取线程副本变量
- 如何对 ThreadLocal 的实例进行 Hash
ThreadLocalMap
线程使用 ThreadLocalMap 来存储每个线程副本变量,它是 ThreadLocal 里的一个静态内部类。ThreadLocalMap 也是采用的散列表(Hash)思想来实现的,但是实现方式和 HashMap 不太一样。我们首先看下散列表的相关知识:
散列表
理想状态下,散列表就是一个包含关键字的固定大小的数组,通过使用散列函数,将关键字映射到数组的不同位置。下面是理想散列表的一个示意图:
图片来自 数据结构与算法分析: C语法描述
在理想状态下,哈希函数可以将关键字均匀的分散到数组的不同位置,不会出现两个关键字散列值相同(假设关键字数量小于数组的大小)的情况。但是在实际使用中,经常会出现多个关键字散列值相同的情况(被映射到数组的同一个位置),我们将这种情况称为散列冲突。为了解决散列冲突,主要采用下面两种方式:
- 分离链表法(separate chaining)
- 开放定址法(open addressing)
分离链表法
分散链表法使用链表解决冲突,将散列值相同的元素都保存到一个链表中。当查询的时候,首先找到元素所在的链表,然后遍历链表查找对应的元素。下面是一个示意图:
图片来自 http://faculty.cs.niu.edu/~freedman/340/340notes/340hash.htm
开放定址法
开放定址法不会创建链表,当关键字散列到的数组单元已经被另外一个关键字占用的时候,就会尝试在数组中寻找其他的单元,直到找到一个空的单元。探测数组空单元的方式有很多,这里介绍一种最简单的 — 线性探测法。线性探测法就是从冲突的数组单元开始,依次往后搜索空单元,如果到数组尾部,再从头开始搜索(环形查找)。如下图所示:
关于两种方式的比较,可以参考 这篇文章。ThreadLocalMap 中使用开放地址法来处理散列冲突,而 HashMap 中使用的分离链表法。之所以采用不同的方式主要是因为:在 ThreadLocalMap 中的散列值分散的十分均匀,很少会出现冲突。并且 ThreadLocalMap 经常需要清除无用的对象,使用纯数组更加方便。
实现
我们知道 Map 是一种 key-value 形式的数据结构,所以在散列数组中存储的元素也是 key-value 的形式。ThreadLocalMap 使用 Entry 类来存储数据,下面是该类的定义:
1 | static class Entry extends WeakReference <ThreadLocal <?>> { |
Entry 将 ThreadLocal 实例作为 key,副本变量作为 value 存储起来。注意 Entry 中对于 ThreadLocal 实例的引用是一个弱引用,该引用定义在 Reference 类(WeakReference的父类)中,下面是
super(k)
最终调用的代码:1 | Reference(T referent) { |
关于弱引用和为什么使用弱引用可以参考 Java 理论与实践: 用弱引用堵住内存泄漏 和 深入分析 ThreadLocal 内存泄漏问题。下面看一下 ThreadLocalMap 的 set 函数
1 | private void set(ThreadLocal <?> key, Object value) { |
关于 set 方法,有几点需要地方:
int i = key.threadLocalHashCode & (len - 1);
,这里实际上是对 len-1 进行了取余操作。之所以能这样取余是因为 len 的值比较特殊,是 2 的 n 次方,减 1 之后低位变为全 1,高位变为全 0。例如 16,减 1 之后对应的二进制为: 00001111,这样其他数字中大于 16 的部分就会被 0 与掉,小于 16 的部分就会保留下来,就相当于取余了。- 在 replaceStaleEntry 和 cleanSomeSlots 方法中都会清理一些陈旧的 Entry,防止内存泄漏
- threshold 的值大小为
threshold = len * 2 / 3;
- rehash 方法中首先会清理陈旧的 Entry,如果清理完之后元素数量仍然大于 threshold 的 3/4,则进行扩容操作(数组大小变为原来的 2倍)
1
2
3
4
5
6private void rehash() {
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}
我们再看一下 getEntry (没有 get 方法,就叫 getEntry)方法:
1 | private Entry getEntry(ThreadLocal <?> key) { |
因为 ThreadLocalMap 中采用开放定址法,所以当前 key 的散列值和元素在数组中的索引并不一定完全对应。所以在 get 的时候,首先会看 key 的散列值对应的数组元素是否为要查找的元素,如果不是,再调用
getEntryAfterMiss
方法查找后面的元素。1 | private Entry getEntryAfterMiss(ThreadLocal <?> key, int i, Entry e) { |
最后看一下删除操作。删除其实就是将 Entry 的键值设为 null,变为陈旧的 Entry。然后调用
expungeStaleEntry
清理陈旧的 Entry。1 | private void remove(ThreadLocal <?> key) { |
副本变量存取
前面说完了 ThreadLocalMap,副本变量的存取操作就很好理解了。下面是 ThreadLocal 中的 set 和 get 方法的实现:
1 | public void set(T value) { |
存取的基本流程就是首先获得当前线程的 ThreadLocalMap,将 ThreadLocal 实例作为键值传入 Map,然后就是进行相关的变量存取工作了。线程中的 ThreadLocalMap 是懒加载的,只有真正的要存变量时才会调用 createMap 创建,下面是 createMap 的实现:
1 | void createMap(Thread t, T firstValue) { |
如果想要给 ThreadLocal 的副本变量设置初始值,需要重写 initialValue 方法,如下面的形式:
1 | ThreadLocal <Integer> threadLocal = new ThreadLocal() { |
ThreadLocal 散列值
当创建了一个 ThreadLocal 的实例后,它的散列值就已经确定了,下面是 ThreadLocal 中的实现:
1 | /** |
我们看到 threadLocalHashCode 是一个常量,它通过
nextHashCode()
函数产生。nextHashCode()
函数其实就是在一个 AtomicInteger 变量(初始值为0)的基础上每次累加 0x61c88647,使用 AtomicInteger 为了保证每次的加法是原子操作。而 0x61c88647 这个就比较神奇了,它可以使 hashcode 均匀的分布在大小为 2 的 N 次方的数组里。下面写个程序测试一下:1 | public static void main(String[] args) { |
我们将 size 设为 16,32 和 64 分别测试一下:
1 | // size=16 |
可以看到随着 size 的变化,hashcode 总能均匀的分布。其实这就是 Fibonacci Hashing,具体可以参考 这篇文章。所以虽然 ThreadLocal 的 hashcode 是固定的,当 ThreadLocalMap 中的散列表调整大小(变为原来的 2 倍)之后重新散列,hashcode 仍能均匀的分布在散列表中。
应用场景
最常见的ThreadLocal使用场景为 用来解决 数据库连接、Session管理等。如
1 | private static ThreadLocal < Connection > connectionHolder = new ThreadLocal < Connection > () { |
1 | private static final ThreadLocal threadSession = new ThreadLocal(); |
参考文章
- http://www.iteye.com/topic/103804
- http://www.jianshu.com/p/529c03d9b67e
- http://stackoverflow.com/questions/38994306/what-is-the-meaning-of-0x61c88647-constant-in-threadlocal-java
- http://jerrypeng.me/2013/06/thread-local-and-magical-0x61c88647/
- ThreadLocal是否会引发内存泄露的分析(转)
- [Java并发包学习七]解密ThreadLocal
- 数据结构与算法分析: C语法描述