Skip to main content

ThreadLocal底层实现原理?

作者:程序员马丁

在线博客:https://open8gu.com

note

大话面试,技术同学面试必备的八股文小册,以精彩回答应对深度问题,助力你在面试中拿个offer。

回答话术

首先,ThreadLocal 本身并不提供存储数据的功能,当我们操作 ThreadLocal 的时候,实际上操作线程对象的一个名为 threadLocals 成员变量。这个成员变量的类型是 ThreadLocal 的一个内部类 ThreadLocalMap,它是真正用来存储数据的容器。因此,不同线程间的数据从物理上就是隔离的,所以 ThreadLocal 不需要任何同步机制也天然是线程安全的。

ThreadLocalMap 底层基于一个长度为 2 的次方的数组实现,所有的数据都会被封装为以 ThreadLocal 作为 Key 的键值对对象 Entry 存放在数组中。底层数组默认大小为 16,扩容阈值为当前容量的三分之二,每次扩容容量都翻倍。

为了提高散列效率,ThreadLocalMap 采用斐波那契散列法作为哈希算法。具体而言,当在根据 ThreadLocal 计算下标的时候,不像 HashMap 那样直接取 hashCode 方法的返回值作为哈希值,而是使用通过一个全局计数器,保证每个 ThreadLocal 实例创建的时候都采用一个特殊的魔数 0x61c88647 的倍数作为哈希值,比如第一个创建的 ThreadLocal 的哈希值为 1 * 0x61c88647,第二个则为 2 * 0x61c88647……以此类推。并且,由于 ThreadLocalMap 底层存放键值对的槽位数量总是 2 的次方,根据斐波那契散列法的特性,在这种情况下,可以大幅度降低计算得到相同下标的可能性,换而言之,就是可以减少哈希冲突发生的概率。

不过哈希冲突总是存在的,对此 ThreadLocalMap 使用线性探测的方式来解决,简单的说,就是如果发生哈希冲突,它就检查下一个槽位是否未被使用,如果未被使用就将值设置到该槽位,否则就继续向后探测,直到找到一个可用槽位为止。

最后,由于数据是直接绑定到线程上的,为了防止用户因为未及时清理数据而导致内存泄露,ThreadLocalMap 底层使用的键值对对象将其的 Key —— 也就是 ThreadLocal 本身 —— 设置为了弱引用,如此一来,当外界没有对 ThreadLocal 的强引用时,键值对的 Key 将会随着 GC 被回收,此时该数据相当于被自动标记为失效。在后续的增删改查操作时,ThreadLocalMap 将会顺带检查并清理这些失效数据

问题详解

1. 数据结构

1.1. ThreadLocal 与 ThreadLocalMap

与通常的 Map 或 List 这类数据结构不同,ThreadLocal 本身并不直接存储数据,真正的数据其实直接存储在线程对象 Thread 中

public class Thread implements Runnable {

/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

/*
* InheritableThreadLocal values pertaining to this thread. This map is
* maintained by the InheritableThreadLocal class.
*/
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}

我们可以看到,每个 Thread 都通过 threadLocalsinheritableThreadLocals 两个成员变量各持有一个特殊的 ThreadLocalMap 集合,它就是实际存储数据的地方:

static class ThreadLocalMap {

static class Entry extends WeakReference<ThreadLocal<?>> {

Object value;

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

private static final int INITIAL_CAPACITY = 16;
// 数组大小总是2的倍数
private Entry[] table;
private int size = 0;
private int threshold; // Default to 0
}

由于每个线程只操作其独有的数据,每个线程的数据都是彼此隔离的,因此不需要任何同步机制,ThreadLocal 也天然就是线程安全的

inheritableThreadLocals 这个变量是专门为 InheritableThreadLocal 准备的,具体可参见:✅ ThreadLocal 有哪些扩展实现?

1.2. 键值对对象 Entry

ThreadLocalMap 和我们熟悉的 HashMap 一样,它使用数组最为最底层的数据结构,数组中的每个槽位都对应一个键值对对象 Entry ,其中 Key 就是对应的 ThreadLocal 本身,而 Value 则是要“存储”到 ThreadLocal 里的数据。

static class Entry extends WeakReference<ThreadLocal<?>> {

Object value; // 存储的数据

Entry(ThreadLocal<?> k, Object v) {
super(k); // 对 ThreadLocal 的引用为弱引用
value = v;
}
}

image.png

此外,值得注意的是,Entry 继承了 WeakReference,并且将 ThreadLocal 作为弱引用,这意味着当外界对 ThreadLocal 的强引用消失后,即使该 Entry 依然在槽中存在,但是它的 Key 却已经变为了 null,这种键值对实际上是已经失效的。

在后文,我们会在 ThreadLocalMap 的增删改方法中看到对槽位中失效的键值进行清理的操作。

1.3. 为什么需将 Key 设置为弱引用?

在理解这个问题之前,我们不妨想一下,如果 Entry 不设置为弱引用会怎么样?

以下面的代码为例:

public class static run() {
ThreadLocal tl = new ThreadLocal();
Object value = new Object();
tl.set(value);
}

结合之前的例子,我们知道,当执行完上述代码后,当前线程将会把 tlvalue 作为一个 Entry 对象存储在自己拥有的 ThreadLocalMap 中。

由于 tlvalue 都间接的被当前线程对象强引用,也就是说,在当前线程对象的生命周期结束前, tl 和 value 一直都不会被回收

并且,由于我们也没有调用 remove 方法主动的让线程对象把 tl 从它拥有的 ThreadLocalMap 中移除,这样等于实质上的发生了内存泄露

而当 Entry 里面的 Key —— 也就是 ThreadLocal —— 被设置为弱引用后,哪怕用户没有及时清空数据,在 GC 的时候 JVM 也会自动回收 ThreadLocal,这等于主动标记 Entry 为失效数据,如此一来,当后续进行增删改等操作的时候,ThreadLocalMap 将会自动清除失效数据,实现内存的自动释放,减小内存泄露的可能性。

关于 ThreadLocal 与内存泄露的问题,具体可以参见:✅ ThreadLocal 什么场景内存泄露?

1.4. 为什么不选择把 Value 设置为弱引用?

从原理来说,要确认一个 Entry 是失效的,只要有办法让 Key 或者 Value 失效就行,从这个角度上来看,把 Key 或者 Value 设置为弱引用都可以实现自动回收的效果。

不过,把 Value 而不是 Key 作为弱引用,最大的问题在于 Value 的生命周期是不确定的。比如,如果缓存的值对象恰好是 String 或者 Integer 类型,由于值本身具备缓存机制导致很难被回收,会进而导致数据迟迟无法失效,进而导致内存泄露。因此,为了避免用户使用常量或长生命周期的对象作为弱引用导致数据迟迟无法被回收,需要把 Key 而不是 Value 设置为弱引用。

2. 哈希算法

键值对集合要实现高效的访问,就需要一个合理的哈希算法,而要理解其哈希算法的运作过程,就要理解一个值是如何添加到集合中的。

我们查看 ThreadLocalMap 的 set方法:

private void set(ThreadLocal<?> key, Object value) {

// 根据桶容量对哈希取模确定下标
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)]) {
ThreadLocal<?> k = e.get();

// 1、如果槽中的 ThreadLocal 就是当前要操作的,则更新值
if (k == key) {
e.value = value;
return;
}

// 2、如果槽中的 ThreadLocal 已经被回收,则更新整个键值对
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

// 如果目标槽位仍然未被使用,则直接设置一个键值对
tab[i] = new Entry(key, value);
int sz = ++size;
// 清空一些必要的槽位,如果已用槽位仍然大于扩容阈值,则进行扩容
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

2.1. 斐波那契散列法

根据上文的代码,我们知道 ThreadLocalMap 通过 key.threadLocalHashCode & (len-1) 这段代码计算下标。这段看似平平无奇的代码其实暗藏玄机。

我们先从 ThreadLocal 哈希值的生成看起:


public class ThreadLocal<T> {

private final int threadLocalHashCode = nextHashCode();

private static AtomicInteger nextHashCode =
new AtomicInteger();

// 每次创建对象时,其哈希值都比上一次递增 0x61c88647
private static final int HASH_INCREMENT = 0x61c88647;


private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}

}

简单的来说,ThreadLocal 的哈希值并不像 HashMap 那样,使用 Key 的 hashCode() 方法的返回值进行高低位混淆后作为哈希值,而是直接声明了一个全局的静态计数器 nextHashCode,以该计数器按特定规律生成的固定值作为哈希值。

每当创建一个 ThreadLocal 实例时,就获取当前计数值并累加 0x61c88647,简单的来说:第一个 ThreadLocal 的哈希值是 0x61c88647 * 1,而第二个是 0x61c88647 * 2…… 以此类推。

这里每次递增的魔数 0x61c88647 转为十进制是 1640531527,而 1640531527 则是整数位数(即 2^32)乘以黄金分割比例 0.68 得到的近似结果。当 ThreadLocal 底层槽位的大小 n 为 2 的次方时,key.threadLocalHashCode & (n-1) 计算将得到是 key.threadLocalHashCode 的低 n 位,换算成十进制数后得到的恰好是一个小于 n 且大概率不重合的数。

ThreadLocal 使用的这种哈希算法被称为斐波那契散列,它是一种神奇而高效的哈希算法。

有的同学看到这里可能会感觉很懵,关于为当数组长度为 2 的次方的时候,哈希值每次递增 0x61c88647 在计算下标的时候就可以得到很好的散列效果?这就是一个有意思的数学 & 计算机科学问题了,三言两语很难讲清楚,因此这里推荐直接阅读文章,虽然是英文的,不过简单机翻一下也可以看懂,感兴趣的可以了解一下 斐波那契散列 sourl.cn/8Ucdag

2.2. 如何解决哈希冲突?

不过,即使再强大的哈希算法,要把无限的数据映射到有限的空间里,总归要面临哈希冲突问题。目前主流解决哈希冲突的方案有两种:

  • 拉链法:发生哈希冲突的元素,在同一槽位中形成链表。
  • 开放定址法:发生哈希冲突的元素,通过其他的方式转移到另一个空闲槽位。

其中,ThreadLocalMap 选择的使用开放定址法作为解决方案,而开放定址法根据二次定位的方式,又分为线性探测、随机探测与平方探测等多种具体方案,而 ThreadLocalMap 使用了其中最为直观的一种,也就是线性探测

简单的来说,当计算出下标后,如果下标对应的槽位已经被占用,ThreadLocalMap 会尝试访问下一个下标,直到找到一个可用的槽位位置

image.png

相比起 HashMap 使用的拉链法,这种解决方式实现起来更加简单,并且更加节约内存,不过当频繁发生哈希冲突时也会带来额外的性能开销。不过,考虑 ThreadLocal 本身的哈希算法十分高效,并且一个线程往往不会拥有太多的 ThreadLocal,哈希冲突的概率非常小,因此这个缺点也就不那么明显了。

3. 无效数据的清理

在上文,我们知道,ThreadLocalMap 通过将 Key —— 也就是 ThreadLocal 本身 —— 设置为弱引用,从而实现了让数据自动失效的效果。

不过,失效不代表数据已经被移除,当 Entry 中的 Key 被回收后,Entry 实际上依然存在于槽位中。因此,ThreadLocalMap 会一些情况下被动的清理失效数据

  • 当进行增删改查操作时,会清空指定范围内的失效数据。
  • 当进行扩容操作时,会清空所有失效数据。

3.1. expungeStaleEntry

所有的数据清理操作,最终都会调用 expungeStaleEntry来清理指定的槽位:

private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// 移除指定槽位上的数据
tab[staleSlot].value = 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();

// 如果数据已失效,则将其移除
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;
}

我们需要注意的是,删除数据并不是直接清空指定的槽位就可以了,由于 ThreadLocalMap 使用线性探测解决哈希冲突,因此连续的不为空的槽位中的数据有可能在最开始计算得到的是同一个下标,只是因为哈希冲突才挪到了这里。

因此,在清除指定槽位后,还需要会向后遍历,在这个过程中:

  1. 如果遇到的槽位中的数据已经失效,则将其移除。
  2. 如果遇到的槽位中的数据还未失效,则对其重新哈希,并进行迁移
  3. 如果已经没有下一个槽位了,或者下一个槽位为空,则终止遍历。

在后面,我们还会在查找和更新数据的操作里面看到类似的做法,它们是思路基本都是一样的。

image.png

3.2. cleanSomeSlots

expungeStaleEntry 方法每次只能清理一段相连的槽位,因此基于它, ThreadLocalMap 还提供了批量清理的方法 cleanSomeSlots,它通常在增删改查等常规操作中调用:

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); // 清理范围为 log(n)
return removed;
}

�相比起 cleanSomeSlots,它的清理范围是从指定下标开始向后延伸 log(n)长度。

3.3. expungeStaleEntries

�在进行扩容的时候,会调用 expungeStaleEntries 方法清空全局的无效数据:

private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
// 循环调用 expungeStaleEntry 方法
if (e != null && e.get() == null)
expungeStaleEntry(j);
}
}

这个清理方法是最重的,因此一般只在扩容的时候调用。

4. 设置值

在了解了 ThreadLocalMap 的数据结构,与哈希算法,还有失效数据的清理机制后,我们可以正式开始了解一个值是如何添加到 ThreadLocalMap 里面的了:

private void set(ThreadLocal<?> key, Object value) {

// 确定下标
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)]) {
ThreadLocal<?> k = e.get();

// 1、如果槽中的 ThreadLocal 就是当前要操作的,则更新值
if (k == key) {
e.value = value;
return;
}

// 2、如果槽中的 ThreadLocal 已经被回收,则更新整个键值对
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

// 如果目标槽位仍然未被使用,则直接设置一个键值对
tab[i] = new Entry(key, value);
int sz = ++size;
// 清空一些必要的槽位,如果已用槽位仍然大于扩容阈值,则进行扩容
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

在方法的最开始,自然是获取 ThreadLocal 的哈希值,并根据哈希算法计算下标,然后又因为线性探测的特殊性,在得到下标后,我们还需要从这个下标开始依次向后遍历每个槽位:

  1. 如果该槽位已被当前操作的 ThreadLocal 使用,则更新槽位中键值对的值;
  2. 如果该槽位已被使用,但是对应的 ThreadLocal 已经被回收,则替换该槽位中的键值对,并清空一些槽位;
  3. 如果该槽位尚未被使用,则直接创建并设置一个键值对,并终止遍历。此外,如果有必要,清理一些槽位,并视情况决定是否要扩容。

5. 扩容

在 ThreadLocalMap 的构造函数中,我们可以知道它的默认大小是 16,扩容阈值为当前容量的 2/3,且不可更改:

// 初始容量
private static final int INITIAL_CAPACITY = 16;

// 扩容阈值为容量的三分之二
private void setThreshold(int len) {
threshold = len * 2 / 3;
}

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

而在真正用于扩容的则是 resize 方法中,每次扩容容量都翻倍,且每个槽位中的数据都要进行重哈希:

private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
// 新长度为旧长度的两倍
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;

for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
// 如果 Key 已经被回收,则将 Value 也置空
if (k == null) {
e.value = null; // Help the GC
} else {
// 对每个 Key 进行重哈希
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}

// 更新扩容阈值
setThreshold(newLen);
size = count;
table = newTab;
}

为什么选择 2/3 作为负载系数?

这里有个额外的问题,为什么扩容阈值要是 2/3 这么一个奇怪的数值?关于这方面,笔者目前没有找到比较权威的解释,不过我们可以大致推测一下:

首先,我们都知道,由于哈希函数的散列度直接受槽位数量的影响,槽位可用率较低的时候会导致哈希冲突比较严重。

因此,哈希函数的扩容阈值必定不能过大,否则扩容前的空闲槽位较少的那段时间哈希冲突会比较严重,并且 ThreadLocalMap 采用线性探测的方式解决哈希冲突,此时相比起 HashMap 使用的拉链法会更加消耗性能,所以 ThreadLocalMap 的负载系数起码要小于 HashMap 的 0.75。 不过,如果设置的过小,又会导致槽位闲置率过高,浪费内存,因此起码得大于 0.5。综合考虑一下,2/3 就是一个比较合适的值。

6. 获取值

我们看看 ThreadLocal 的 get 方法,整个流程大概分为三步:

  1. 先确认线程里面的 ThreadLocalMap 是否初始化,如果未初始化则进行初始化,如果已初始化则开始进行查找;
  2. 通过哈希值计算得到下标,如果下标对应的槽位为空,或者直接找到了目标数据,则直接返回,否则说明存在哈希冲突,需要进行线性探测;
  3. 从指定下标开始向后探测:
    1. 如果找到了目标数据,则中断探测,直接返回数据;
    2. 如果槽位不为空,且数据已经失效,则进行清理;
    3. 如果槽位为空或者已没有下一个可遍历的槽位,说明没有要查找的数据,直接返回空。
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();
}

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

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;

// 线性探测,从指定下标开始遍历槽位,直到找到为止
while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
if (k == null)
// 如果发现槽位中的数据失效,则进行清理
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}

7. 数据的初始化

这里我们关注一下 ThreadLocalMap 中数据的初始化。在最开始的时候,由于每个线程 threadLocalsinheritableThreadLocals 两个变量都未初始化,此时就会在 setInitialValue 方法里面创建 ThreadLocalMap 实例,并对数据进行初始化:

private T setInitialValue() {
// 为 ThreadLocal 设置初始值,
// 不重写 initialValue 方法的话默认都是 null
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
// 注册到 TerminatingThreadLocal 注册表
if (this instanceof TerminatingThreadLocal) {
TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
}
return value;
}

简单的来说,这里先确认线程的 threadLocals 是否已经初始化,若没有则初始化一个 ThreadLocalMap,并调用 initialValue 方法获取并添加一个初始值。这里的 initialValue 方法是一个留给子类重写的钩子方法,默认返回的是一个 null。

围绕着数据的初始化和生命周期管理,还延伸出了一些扩展类,具体可以参见:✅ ThreadLocal 有哪些扩展实现?