ThreadLocal


七月的第一周,来学习 ThreadLocal。

半年前我完全看不懂这个类,现在已经能看懂了。这几天刚开始看源码时,觉得这是个很精巧的小设计,后来又找资料,发现里面的知识很深,没我想的那么简单。

我在学习 ThreadLocal 时,看到两个很好视频,安利一下。一个是《【java】什么是ThreadLocal》(短小紧凑,科普向,但是结尾有关弱引用的结论是错的,瑕不掩瑜,记得看评论区),另一个是《只有马士兵老师能把ThreadLocal底层原理、内存泄漏分析的这么测透》(马士兵老师讲的,比较长,但是非常出色,看得我很 high)。


ThreadLocal 类与线程局部存储(Thread Local Storage, TLS)的概念有关,意思是说对象是线程独有的:

维基:对象的存储是在线程开始时分配,线程结束时回收,每个线程有该对象自己的实例。

可以把 ThreadLocal 视为一个普通变量,他与普通的变量之间的区别在于,ThreadLocal 变量只属于某个线程。

1
2
3
4
5
String word1;
ThreadLocal<String> word2 = new ThreadLocal<>();

word1 = "pz";
word2.set("pz");

例如上面的代码,声明了两个变量 word1 和 word2。这两个变量在使用时都是字符串,也都声明成是”pz”,但是前者 word1 在任何情况下都能获得,后者只有为它赋值的线程能获得,其他的线程都不能获得:

1
2
// 对于刚才赋值的线程而言,s是"pz",对于其他线程,s是null
String s = word2.get();

线程局部存储,可以让某些信息在同一个线程中,实现上下文数据共享,比如一个线程先后执行多个方法,每个方法都需要 User 信息,那么可以定义一个 ThreadLocal<User>,每个线程都能获取到各自的 User。

再举一个应对面试的 Spring 应用实例,在事务管理时(@Transactional),Spring 会把数据库连接或者 hibernate session 存储在 ThreadLocal 中,这样就可以在各个方法查询各张表时实现事务控制。

基本原理

ThreadLocal 的实现原理,是让每个线程自己维护一张 map,key 是 threadLocal 对象,value 是实际想存储的内容。

每个线程拿着 ThreadLocal 对象,把它作为 key,去自己独有的 map 里找到 key 对应的 value,由此实现了线程局部存储。

ThreadLocal

直接来看 get() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public T get() {
// 获取当前线程
Thread t = Thread.currentThread();
// 根据当前线程,获取map
ThreadLocalMap map = getMap(t);
// 如果map不为空,去map里面找
if (map != null) {
// 根据this(也就是本ThreadLocal对象),去map里面找到对应的entry
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 如果map为空,初始化map,并返回null
return setInitialValue();
}

基本逻辑很简单,获取当前线程,根据当前线程获取 map,在 map 里根据 ThreadLocal 对象找 entry,返回 entry 的 value。

在这个主要流程里,我们还需要去扒:

  1. 怎么根据当前线程获取 map
  2. 怎么在 map 里根据 ThreadLocal 找到 entry

第一个问题特别简单,map 就是 Thread 的一个成员变量,直接返回:

1
2
3
4
5
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
// 再去看Thread类的源码,可以看到有一条成员变量:
ThreadLocal.ThreadLocalMap threadLocals = null;

第二个问题有点难,涉及到 map 的数据结构。

先提一下 map 具体是什么,这里的 map 实际上就是一个数组(没有链表),这个数组里的每一个元素都是一个 Entry:

1
2
3
4
5
6
7
8
9
10
11
static class ThreadLocalMap {
// 这个table就是map背后的数组:存放entry的数组
private Entry[] table;

// 这就是数组中一个个的元素
static class Entry extends WeakReference<ThreadLocal<?>> {
...
}

...
}

Entry 涉及到弱引用,这个后面再提,先看怎么从 map 中找到 entry:

1
2
3
4
5
6
7
8
9
10
11
private Entry getEntry(ThreadLocal<?> key) {
// 根据ThreadLocal对象(即key)计算出数组下标
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// 如果数组下标内存的key能对上,那么就是
if (e != null && e.get() == key)
return e;
// 如果key对不上,线性探测往后找
else
return getEntryAfterMiss(key, i, e);
}

从整体上讲,这个 map 采用开放定址法,而不是拉链法,来解决哈希碰撞问题。

这种算法的大致原理是,计算出哈希值,然后存入数组里相应的位置,如果这个位置已经存在元素了(哈希碰撞),那么查看下一个数组格子是否是空的,一直找,直到找到一个空的位置,然后存进去。取的时候也是一样,先计算出哈希值,然后找相应数组下标内的元素,如果 key 对上了就是,如果对不上,找下一个。

关于使用开放定址法,存在两个疑问:为什么要使用它,而不是使用像 HashMap 一样的拉链法?使用这种需要优化什么?

第一个问题涉及到弱引用,这个后面再提。

第二个问题其实是在思考开放定址法的问题:如果每次计算出来的数组下标,距离靠得很近,那如果发生哈希碰撞,这个不对,找下一个,还不对,再找下一个,岂不是要找死?所以要让计算出来的数组下标,要分布尽可能地均匀。

特殊的 hash 算法

ThreadLocal 计算数组,是通过内部的一个数字计算的,这个数字很奇特,每个 ThreadLocal 的数字都不一样,但是它们都是一个公共数字 HASH_INCREMENT 的整数倍(会溢出,但是无所谓)。在程序启动之后,出现的第一个 ThreadLocal 对象内部的数字是 HASH_INCREMENT 的一倍,第二个 ThreadLocal 对象内部的数字是 HASH_INCREMENT 的两倍,以此类推,第 n 个 ThreadLocal 对象内部的数字是 HASH_INCREMENT 的 n 倍。

每个 ThreadLocal 对象内部的那个数字是 threadLocalHashCode(见名知意,就是 hash 值),而以 HASH_INCREMENT 为基的这个数字,大小初看很奇怪:

1
private static final int HASH_INCREMENT = 0x61c88647;

这个数字用十进制表示是 1640531527,它是 2^32 的黄金分割数,也就是 (2^32)/0.618。使用这个数字的整数倍,能计算得出很均匀的 map 下标(前提 map 的长度是 2 的幂)。具体的计算步骤是,获取 ThreadLocal 内部的那个数字(threadLocalHashCode),然后对 map 的长度 -1 取模,计算出一个不超过 map 长度的数字,这个数字就是数组的下标。

举个例子,0 - 31 这 32 个数字分别与 0x61c88647 相乘,然后再对 31 (0b11111)取模,依旧能获得 32 个不同的数字:

1
2
3
4
5
int HASH_INCREMENT = 0x61c88647;
for (int i = 0; i < 32; i++) {
System.out.print((i * HASH_INCREMENT & 0b11111) + " ");
}
// 控制台打印结果:0 7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25

当数组长度是 2 的幂时,都可以做到如此均匀,虽然我不知道为什么(试着找了一些文章,但是看不懂hhh),但是觉得好牛x啊。

下图来源自文章《Fibonacci Hashing》,感受一下分布均匀度。

连续分布

有关 ThreadLocal 的 hash 算法就写到这里,然后我们来看 map 中的每一个元素:Entry。

Entry 与弱引用

这是 Entry 的源码:

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

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

它实际上就是 key-value 键值对,跟其他任何 map 里的设计都是一样的,有 key,也有 value。

它的 key,是 ThreadLocal 对象(尽管看源码有点懵),它的 value,就是线程局部存储的值,例如下面这两行代码:

1
2
ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("pz");

执行 set 方法的那个线程,它内部的 map 里的某一个格子,就是一个 Entry,这个 Entry 的 key 就是 threadlocal 对象,它的 value 是 “pz”。

Entry 源码里面看到了 value,但是没有看到 key,key 是通过 WeakReference(弱引用)来实现的。

弱引用是 JDK 1.2 出现的,这个概念跟 Java 虚拟机有关。弱引用是引用的一种,引用总共有四种,,我简单列一下:

  • 强引用(Strong Reference):正常引用,根据垃圾回收算法,当这个引用存在时,就无法对引用对象进行 GC(如果根可达的话)
  • 软引用(Soft Reference):能够获取到引用对象,当发生 FGC 时,会回收引用对象,应用在缓存等。
  • 弱引用(Weak Reference):能够获取到引用对象,当发生 GC 时,会回收引用对象,应用在 ThreadLocal 等。
  • 虚引用(Phantom Reference):不能获取到引用对象,作用是当引用对象被 GC 时,虚引用会获得一个系统通知,应用场景跟一般的代码无关。

上面写的引用,我只是简单一写,想理解的话去看《深入理解 Java 虚拟机》讲垃圾回收的部分,或是最开始提到的马士兵老师的视频,都讲得很好。

话说回来,继续看弱引用,看下面这两行代码:

1
2
WeakReference<String> r = new WeakReference<>("pz");
r.get();

上面创建了一个弱引用 WeakReference 对象 r,它引用到了一个字符串 “pz”,如果没有发生垃圾回收,那么可以通过 r.get() 方法获取到 “pz”,但如果发生了垃圾回收,这个虚引用并不会影响到字符串 “pz” 的回收,如果它真被回收了,那么执行 r.get() 方法将会获得 null。

因此再回去看 Entry 源码,发现 Entry 继承自 WeakReference,它可以通过虚引用获取到 ThreadLocal 对象,比如创建一个 Entry 对象(当然由于 private,下面的代码是实现不了的hh):

1
2
3
Entry entry = new Entry(threadLocal, "pz");
entry.get(); // ---> 能拿到threadLocal,即key
entry.value; // ---> 能拿到"pz",即value

如此可以看出,Entry 实际上就是 key-value 键值对,从 hash 开放定址法上来说,应该是没问题了,逻辑闭环了,但是有另一个问题:为什么要使用弱引用?

这主要与垃圾回收有关:如果 Entry 通过正常的引用关联 ThreadLocal 对象,那么如果线程不死,map 不清,由于强引用关系,ThreadLocal 对象就永远也不能被垃圾回收,这样即使某些 ThreadLocal 用不上了,它仍然不会被清理掉,造成内存泄漏。

(这里我还是有点疑惑,真的有这种内存泄漏的场景吗)


本来还想学点数据库事务的 ThreadLocal 原理的,但是写了这么多字懒了,告辞。