ThreadLocal类源码剖析

ThreadLocal类源码剖析

本章主要参考梦想之家的原文附加上自己的理解过程:ThreadLocal源码深度解析 | wxweven 梦想之家

一、ThreadLocal类的简介

该类提供线程本地变量,这些变量与其他正常对应变量的不同之处在于,访问一个ThreadLocal变量(通过其getset方法)的每个线程都有其==自己的、独立初始化的变量副本==。ThreadLocal实例通常是类中希望将状态与线程关联起来的私有静态字段例如用户ID或事务ID。举个例子,下面的类生成每个线程本地的唯一标识符。线程的ID在第一次调用threadId.get()时分配,并在后续调用中保持不变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.concurrent.atomic.AtomicInteger;

public class ThreadId {
// Atomic integer containing the next thread ID to be assigned
private static final AtomicInteger nextId = new AtomicInteger(0);

// Thread local variable containing each thread's ID
private static final ThreadLocal<Integer> threadId =
new ThreadLocal<Integer>() {
@Override protected Integer initialValue() {
return nextId.getAndIncrement();
}
};

// Returns the current thread's unique ID, assigning it if necessary
public static int get() {
return threadId.get();
}
}

当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。

二、ThreadLocal类的架构

QHAArF.png

ThreadLocal类的整体架构其实并不难理解,之前我们在Thread类的源码中曾经看到过两个字段threadLocalsinheritableThreadLocals,我们先暂时不用理会后一个,其实对ThreadLocal对象的getsetremove都是针对当前线程即Thread对象的ThreadLocalMap类型的threadLocals字段。

image-20231114195418794

简洁的说,ThreadLocal类只是表面上所有线程都操作的对象,真正底层处理的都是线程私有的threadLocals字段,上面也说了这是一个Map结构,存储的是ThreadLocal到对应的线程本地Value映射对Entry

也许你注意到了图1中的Entry对象的Key到ThreadLocal之间是一个虚线,这是因为Key本身是一个弱引用对象,当ThreadLocal对象没有外部强引用并且发生GC时,弱引用会被直接回收,此时Entry对象中的Key为null,我们称这种Entry是过期失效的。过期Entry的Value还是正常存在,因为一直存在着threadRef->thread->threadLocalMap->Entry->Value的强引用链关系,除非线程被销毁回收,不过实际项目中基本都是利用线程池技术实现线程复用,因此如果我们不人为干涉加以清理失效Entry,就会发生内存泄漏问题!所幸ThreadLocal本身就考虑到了内存泄漏问题并使用了两种清理手段:探索式清理启发式清理

1
2
3
4
5
6
7
8
9
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

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

三、ThreadLocal类的属性

ThreadLocal类的属性就是threadLocalHashCode哈希码值用来计算其在ThreadLocalMap中哈希表数组的映射位置,为了使哈希分布更加均匀减少哈希冲突,ThreadLocal类自定义哈希码值nextHashCode,每创建一个ThreadLocal对象其哈希值就递增HASH_INCREMENT0x61c88647,这其实是一个斐波那契数,至于为什么可以使哈希分布更加均匀就需要一定的数学基础了,这个不重点了解即可。

1
2
3
4
5
6
7
private final int threadLocalHashCode = nextHashCode();
private static AtomicInteger nextHashCode = new AtomicInteger();
// 黄金分割点:(√5 - 1) / 2 = 0.6180339887,Math.pow(2, 32) * 0.6180339887 = 0x61c88647
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}

四、ThreadLocal类的创建

无参构造函数这种谁都懂的我就不专门贴出来了,一般我们都是建议创建ThreadLocal的同时并赋初始化值,可以通过ThreadLocal的子类化并重写initialValue函数实现,也可以通过ThreadLocal的静态方法withInitial实现(其实它也是上一种方法的具体化,代码中可以看出)。

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
// 该方法将在线程第一次使用get方法访问变量时被调用,除非该线程之前调用过set方法,在这种情况下,该线程将不会调用initialValue方法。
// 通常,每个线程最多调用此方法一次,但如果随后调用remove,然后调用get,则可能会再次调用该方法。
// 这个实现只是返回null,如果程序员希望线程本地变量具有非null的初始值,则必须对ThreadLocal进行子类化,并重写此方法。通常,将使用匿名内部类,开头的简介案例就是这么做的。
protected T initialValue() {
return null;
}

// 创建线程本地变量,变量的初始值是通过调用Supplier上的get方法来确定的
public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
return new SuppliedThreadLocal<>(supplier);
}

// 其实归根结底还是ThreadLocal的子类化并重写initialValue方法
static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {

private final Supplier<? extends T> supplier;

SuppliedThreadLocal(Supplier<? extends T> supplier) {
this.supplier = Objects.requireNonNull(supplier);
}

@Override
protected T initialValue() {
return supplier.get();
}
}

五、ThreadLocal类的方法

1.set方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void set(T value) {
// 1.获得当前线程
Thread t = Thread.currentThread();
// 2.拿到当前线程对应的ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
// 3.针对ThreadLocalMap不为空的情况,直接加入或更新键值对Entry
map.set(this, value);
} else {
// 4.针对ThreadLocalMap为空的情况,创建有效的Map并加入键值对Entry
createMap(t, value);
}
}

ThreadLocalMap getMap(Thread t) {
// 获取线程对象中的threadLocals字段
return t.threadLocals;
}

void createMap(Thread t, T firstValue) {
// 创建新的有效Map并加入键值对Entry
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

可以看出,当我们第一次调用ThreadLocal的set方法时会在Thread对象中创建一个新的ThreadLocalMap,并把键值对加入其中;后续调用set方法会直接操作ThreadLocalMap对象,最重要的还是ThreadLocalMap的set方法!

2.get方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public T get() {
// 1.获取当前线程
Thread t = Thread.currentThread();
// 2.拿到当前线程对应的ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
// 3.根据ThreadLocal作为键拿到Map对象的键值对节点Entry
ThreadLocalMap.Entry e = map.getEntry(this);
// 4.Map中存在该键值对,直接返回value值
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 5.Map为空需要进行初始化操作,返回默认初始值
return setInitialValue();
}

可以看出,当我们第一次调用ThreadLocal的get方法时(之前没有任何set操作)会创建并初始化Thread对象中的ThreadLocalMap对象;后续调用get方法会直接操作ThreadLocalMap对象拿到对应的值,最重要的还是ThreadLocalMap的getEntry方法!

image-20231114215643893

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
if (this instanceof TerminatingThreadLocal) {
TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
}
return value;
}

这个方法就是set方法的一个变种,唯一的区别是:set方法中的value是传入的,而这个方法的value是调用initialValue方法获得的。

3.remove方法

1
2
3
4
5
6
7
8
public void remove() {
// 1.拿到当前线程对应的ThreadLocalMap
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
// 2.根据Key从ThreadLocalMap中删除指定的键值对
m.remove(this);
}
}

remove方法删除此线程本地变量的当前值。如果当前线程随后读取此线程本地变量,则将通过调用其initialValue方法重新初始化其值,除非当前线程在此期间设置了其值。这可能会导致当前线程中多次调用initialValue方法。

六、ThreadLocalMap类的原理

在详细讲解ThreadLocalMap之前,我们先要了解ThreadLocalMap的Hash冲突处理,因为这是整个ThreadLocalMap最核心的地方,理解了这个,ThreadLocalMap其他的内容也就比较好理解了。

首先我们回顾下Java中的HashMap,我们知道HashMap的实现方式是数组+链表+红黑树,其中数组用于Hash桶定位,链表用于解决Hash冲突,红黑树用于加快查找速度。ThreadLocalMap,本质上来讲也是一个Map,也用到了Hash算法,那么它在实现上与HashMap有什么区别呢?这里先把结论给出来:

Hash冲突的处理方式不一样,HashMap使用链地址法来解决Hash冲突,而ThreadLocalMap使用开放地址法来解决Hash冲突。

每一个ThreadLocal对象都有一个threadLocalHashCode哈希值字段,在将ThreadLocal对象及其对应的Value放入ThreadLocalMap中时,需要现根据threadLocalHashCode哈希值对哈希表数组长度取模(因为数组长度是2的幂次方,因此可以通过threadLocalHashCode&(length-1))找到数组中的槽位,然后构造出一个键值对Entry放入该槽位中。

尽管threadLocalHashCode哈希值映射后的分布相对均匀,但仍然无法避免哈希冲突的问题,此时采用开发地址法依次往后探查直到遇到空槽位存入。当然,ThreadLocalMap的这个过程中还有很多其他细节,如遇到Key相同的直接更新,遇到过期Entry需要清理。下图ThreadLocalA、ThreadLocalB、ThreadLocalC发生哈希冲突的情况图:

QHAtIA.png

1.构造方法

在前面分析ThreadLocal的set方法中,我们知道,如果当前Thread对应的ThreadLocalMap为null,则会调用createMap方法创建ThreadLocalMap:

1
2
3
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

即调用了ThreadLocalMap的构造函数,我们来看看构造函数源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// 1.数组默认初始长度是16
table = new Entry[INITIAL_CAPACITY];
// 2.根据键的哈希值计算对应的槽位
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
// 3.存入初始的键值对
table[i] = new Entry(firstKey, firstValue);
// 4.ThreadLocalMap的大小初始化为1
size = 1;
// 设置调整大小阈值,以在最坏的情况下保持2/3的负载系数
setThreshold(INITIAL_CAPACITY);
}

private void setThreshold(int len) {
threshold = len * 2 / 3;
}

我们多提两个小函数,后面会用到这里先看一下,功能也很简单就是向前向后移动,看成循环数组。

1
2
3
4
5
6
7
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}

2.set方法

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
private void set(ThreadLocal<?> key, Object value) {

// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.

Entry[] tab = table;
int len = tab.length;
// 1.定位Hash槽位位置
int i = key.threadLocalHashCode & (len-1);

// 2.从当前槽位位置往后遍历
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

// 3. 如果key相同,直接替换掉原来的value
if (k == key) {
e.value = value;
return;
}

// 4. 如果当前槽位对应的key为null,特殊处理:清除并替换过期Entry
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

// 5.找到没有使用的槽位即null,将传入的Entry放入该槽位中
tab[i] = new Entry(key, value);
// 6.增加ThreadLocalMap的Entry个数
int sz = ++size;
// 7.没有清理出可用的槽位而且容量超过扩容阈值,重新Hash
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

详细过程已经在源码中附加了注释,其中的『注释4』和『注释7』是跟ThreadLocal的内存泄露相关的,我们将在『ThreadLocal类的内存泄露』章节介绍到。

3.getEntry方法

image-20231114235253187

1
2
3
4
5
6
7
8
9
10
11
12
private Entry getEntry(ThreadLocal<?> key) {
// 1. 定位Hash桶槽位位置
int i = key.threadLocalHashCode & (table.length - 1);
// 2. 获取当前槽位位置的Entry
Entry e = table[i];
// 3.如果Entry不为null且key相同,说明Hash命中,返回对应的值即可
if (e != null && e.get() == key)
return e;
// 4.未命中,特殊处理
else
return getEntryAfterMiss(key, i, e);
}

get方法的作用,也无需多说,跟HashMap的get方法一样,根据key去找value。同样,考虑到Hash冲突,会有未直接命中的情况,需要做特殊处理,即调用getEntryAfterMiss方法:

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

// 1.从当前位置往下找,这个原因在Hash冲突处理章节已经介绍过:
// 插入时,如果当前位置已经有元素,则往下找一个位置,看是否为null,如此反复,直到找到一个为null的位置,把Entry放入该位置;
// 所以,查找的时候,也是一样的逻辑,如果根据key算出来的Hash值位置上,不是当前Entry,那么就顺着数组往下找;
while (e != null) {
ThreadLocal<?> k = e.get();
// 2.如果key相同,说明找到,直接返回
if (k == key)
return e;
// 3.如果当前位置key为null,特殊处理:清除过期Entry
if (k == null)
expungeStaleEntry(i);

// 4.继续找数组的下一个位置
else
i = nextIndex(i, len);
e = tab[i];
}

// 5.最后还是没有找到,返回null
return null;
}

同样,这里需要特别注意的是『注释3』:当前数组位置key为null的情况,也是跟内存泄露相关的,『ThreadLocal类的内存泄露』章节会介绍到。

4.remove方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;

// 1.定位Hash槽位位置
int i = key.threadLocalHashCode & (len-1);

// 2.遍历找出key对应的Entry
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
// 3.找到对应的Entry后,清理该Entry的弱引用
e.clear();
// 4.特殊处理:清除过期Entry
expungeStaleEntry(i);
return;
}
}
}

remove方法,根据key去删除map中的元素,这一过程中的特殊处理,也是跟内存泄露相关,会在『ThreadLocal类的内存泄露』章节介绍。

5.rehash方法

image-20231115012517958

1
2
3
4
5
6
7
8
9
private void rehash() {
// 1.清除所有的过期Entry
expungeStaleEntries();

// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
// 2.如果数组容量依然不够只能扩容
resize();
}

expungeStaleEntries的源代码如下:

1
2
3
4
5
6
7
8
9
private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get() == null)
expungeStaleEntry(j);
}
}

resize的源代码如下:

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
private void resize() {
// 1.扩容两倍大小
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;

for (Entry e : oldTab) {
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
// 2.过期Entry可以忽略不用拷贝过去
e.value = null; // Help the GC
} else {
// 3.正常Entry需要重新计算合适的槽位位置
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}

// 4.设置大小阈值和Entry个数
setThreshold(newLen);
size = count;
table = newTab;
}

rehash的逻辑比较简单,我们就不详细介绍了,其实就是把哈希表中的原数组元素拷贝到扩容后的新数组,注意这个过程中不考虑过期Entry,并且正常Entry需要重新计算放置的槽位位置。

七、ThreadLocal类的内存泄漏

1.内存泄漏的原理

我们再回过头来看看ThreadLocal的底层实现:

QHAArF.png

在ThreadLocal的生命周期中,都存在这些引用,如下图实线代表强引用,虚线代表弱引用。

QHABM8.png

ThreadLocal的实现是这样的:==每个Thread维护一个ThreadLocalMap映射表,这个映射表的key是ThreadLocal实例本身,value是真正需要存储的Object。==也就是说ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。值得注意的是图中的虚线,表示ThreadLocalMap是使用ThreadLocal的弱引用作为key的,弱引用的对象在GC时会被回收。

ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统GC的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的过期Entry,就没有办法访问这些key为null的过期Entry的value,如果当前线程再迟迟不结束的话(典型情况就是线程池方式,线程并真正不结束,只是归还到线程池中),这些key为null的Entry的value就会一直存在一条强引用链:

Thread Ref -> Thread -> ThreaLocalMap -> Entry -> Value

导致value永远无法回收,造成内存泄漏。

在ThreadLocalMap的set、getEntry、remove方法中,都提到了『特殊处理』,这个『特殊处理』就是为了解决内存泄露问题,它会清理掉不再被使用的过期Entry对象。

2.过期Entry清理原理

ThreadLocalMap特殊的Hash冲突处理方式,导致了:

清理ThreadLocalMap时候要保证将一个index指向的Slot清理之后,需要连带着将挨着该index的非空Slot内的ThreadLocal对象全部Rehash一遍。

因为这些Slot内存储的ThreadLocal对象和index指向的Slot内存储的ThreadLocal对象可能都Hash到了同一个ThreadLocalMap内的Slot,如果把开头Slot清理后面的不去Rehash就无法找到他们了,这一过程详见『ThreadLocalMap类的原理』。

JDK源码中,执行清理ThreadLocalMap的操作的有三个地方:

  • 主动调用ThreadLocalMap内的remove时执行expungeStaleEntry
  • set值到ThreadLocalMap时调用replaceStaleEntry和cleanSomeSlots
  • getEntry时如果发现key找不到会执行expungeStaleEntry

3.remove方法(使用expungeStaleEntry)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;

// 1.定位Hash槽位位置
int i = key.threadLocalHashCode & (len-1);

// 2.遍历找出key对应的Entry
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
// 3.找到对应的Entry后,清理该Entry的弱引用
e.clear();
// 4.特殊处理:清除过期Entry
expungeStaleEntry(i);
return;
}
}
}

其中的清理工作就是在expungeStaleEntry方法中执行的。我们来看看这个神秘的expungeStaleEntry方法。

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

// 1.清理当前槽位的Entry
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

// Rehash until we encounter null
Entry e;
int i;

// 2. 从当前位置开始往下遍历,直到找到为null的slot
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// 3.key为null,该Entry已经无法被访问,顺带着清除该过期Entry
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
// 4.key不为null,需要rehash,原因在『过期Entry清理原理』小节讲过
int h = k.threadLocalHashCode & (len - 1);

// 5.如果h==i,说明当前ThreadLocal与清理开始的ThreadLocal不是同一个run,不需要处理
if (h != i) {
tab[i] = null;

// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
// 6.从哈希映射基槽位开始依次往后找到一个空槽位,即开放地址法
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
// 7.返回连续非空槽位区间即run范围随后的空槽位位置
return i;
}

expungeStaleEntry的工作是传入一个Slot的index,将该index指向的Slot清理,并且将该index之后同一个run范围内的所有Slot都检查一遍,发现Slot指向的ThreadLocal被GC则也清理该Slot,没被GC就将该ThreadLocal对象重新rehash到ThreadLocalMap的其它合适Slot上。最终会返回目标index所在run范围的终点序号,也即一个run末尾的空Slot的index值。

这就是ThreadLocalMap中的第一种清理手段:探索式清理

4.set方法(使用cleanSomeSlots和replaceStaleEntry)

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
private void set(ThreadLocal<?> key, Object value) {

// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.

Entry[] tab = table;
int len = tab.length;
// 1.定位Hash槽位位置
int i = key.threadLocalHashCode & (len-1);

// 2.从当前槽位位置往后遍历
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

// 3.如果key相同,替换掉原来的value
if (k == key) {
e.value = value;
return;
}

// 4.如果当前槽位对应的key为null,特殊处理:替换并清除过期Entry
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

// 5.找到没有使用的槽位即null,将传入的Entry放入该槽位中
tab[i] = new Entry(key, value);
// 6.增加ThreadLocalMap的Entry个数
int sz = ++size;
// 7.没有清理出可用的槽位而且容量超过阈值,重新Hash
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

set操作是传入一个ThreadLocal对象和其绑定的value,将这个ThreadLocal和value存入ThreadLocalMap中。存的时候也是需要先对ThreadLocal对象做Hash找到其在ThreadLocalMap中的Slot,如果Slot被占用,会有三种情况:

  • Slot内存储的ThreadLocal对象就是当前待存储的ThreadLocal对象,此时只需要用新Value替换原来的Value就结束了;

  • Slot内存储的ThreadLocal不是当前待存储的ThreadLocal对象,并且之前存的ThreadLocal对象已经被GC掉,Slot内过期Entry的WeakReference读取后返回空,这种情况下需要将原来的过期Entry清理并建立新的Entry指向这个新的ThreadLocal对象,存入当前的Slot。这个替换过程使用的是 replaceStaleEntry 方法;

  • 如果不是上面两种情况,则需要继续查看紧挨着的Slot直到遇到空Slot。找到空Slot说明我们找到一个空位置,则创建全新的Entry指向当前ThreadLocal对象,存入这个找到的空Slot;

如果是上面第三种情况,添加完新的 Entry 之后,还会执行一次 cleanSomeSlots 方法,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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];
// 1.清理当前已经失效的Entry
if (e != null && e.get() == null) {
n = len;
removed = true;
// 2.清理过期Entry,前文讲过
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);

return removed;
}

在当前新添加的Entry所在Slot之后,连续的找logN个Slot,判断这些Slot内存储的Entry是否指向一个已经被GC的ThreadLocal对象,是的话就对这个Slot执行expungeStaleEntry做清理。它执行对数次扫描,作为不扫描(快速但保留垃圾)与元素数量成正比的扫描(这将找到所有垃圾,但会导致某些插入花费O(n)时间)次数之间的平衡。

这就是ThreadLocalMap中的第二种清理手段:启发式清理

对于上面第二种情况中使用的 replaceStaleEntry 其实现还比较复杂,拿下图来说:

QHAyZQ.png

假设当前要set的是ThreadLocalB,并且ThreadLocalA、B、C在这个ThreadLocalMap都具有相同的Hash值,从而都Hash到同一个Slot即现在ThreadLocalA所在的Slot。也正因为碰撞所以ThreadLocalB、C都是紧挨着ThreadLocalA存储的。3号位Slot指向null表示它本来是存一个ThreadLocal对象的,但这个对象被GC了,所以按照上面对set方法的描述,再次set ThreadLocalB的时候发现3号位是null就会执行replaceStaleEntry,希望将3号位replace为ThreadLocalB并绑定上最新的Value。

但是因为我们只检查到3号位,我们只能确认2、3两个位置没有ThreadLocalB对象,但ThreadLocalB对象可能存在于3号位之后的Slot中,所以直接将ThreadLocalB存入3号位是不行的,需要从3号位向后遍历着查找一下看看3号位之后还有没有ThreadLocalB对象了,如上图所示3号位之后还确实是有ThreadLocalB对象,并且因为发现3号位原来的ThreadLocal对象已经被 GC,所以replaceStaleEntry需要将4号位的ThreadLocalB挪到3号位,并且将该ThreadLocalB对象绑定上新的Value。交换之后4号位我们知道是需要被清理的,所以会调用expungeStaleEntry将该位置的Slot清理,并且将4号位之后的Slot都进行rehash。

当前面expungeStaleEntry执行之后,还是会调用cleanSomeSlots来探测当前run之后,也即6号位Slot之后logN个Slot看看有没有被GC掉的ThreadLocal,有的话就用expungeStaleEntry做清理。

需要注意的是如果在4号位找到ThreadLocalB,则4号位之后是不可能再有ThreadLocalB的,所以找到4号位做完交换和更新Value之后不需要从4号位再往后找有没有ThreadLocalB了。

除了上面说的这一大堆之外,replaceStaleEntry实际还会检查同一个run内3号位之前的Slot,看看这些Slot的ThreadLocal对象有没有被GC掉,虽然这些Slot在replaceStaleEntry执行之前,在set方法内已经检查过一次。从replaceStaleEntry内注释来看主要原因是想避免连续的rehash。我个人推测,主要是因为set操作三种情况中,最耗时的就是第二种需要执行replaceStaleEntry的情况,无论是直接找到被更新的ThreadLocal对象直接更新绑定的Value还是在一个run内没有发现被GC的ThreadLocal对象直接将新的ThreadLocal存在一个run的末尾的空Slot内,耗时都是比较小的,而需要执行replaceStaleEntry时因为清理一个Slot需要将后续所有Slot全部Rehash所以耗时最大,所以要尽可能的避免replaceStaleEntry的执行。而GC是任意时刻都可能执行的,虽然set操作内检查过上图2位,但是GC过后可能2号位的ThreadLocalA也被GC掉了,所以再次检查一下能更好的避免replaceStaleEntry的执行。

如果发现3号位之前有ThreadLocal对象被GC,则在替换完3号位后,会直接从3号位之前这个被GC的ThreadLocal对象所在Slot开始,完整的执行一遍expungeStaleEntry,全部执行完后相当于是从expungeStaleEntry执行开始的Slot到一个run的末尾所有被GC掉的ThreadLocal都会被清理。

replaceStaleEntry方法源码如下:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;

// Back up to check for prior stale entry in current run.
// We clean out whole runs at a time to avoid continual
// incremental rehashing due to garbage collector freeing
// up refs in bunches (i.e., whenever the collector runs).
int slotToExpunge = staleSlot;
// 1.向前找到可能的过期Entry
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;

// Find either the key or trailing null slot of run, whichever
// occurs first
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();

// If we find key, then we need to swap it
// with the stale entry to maintain hash table order.
// The newly stale slot, or any other stale slot
// encountered above it, can then be sent to expungeStaleEntry
// to remove or rehash all of the other entries in run.
// 2. 向后找到key相同的Entry
if (k == key) {
// 3.更新并替换该Entry,当前槽位置为过期Entry
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;

// Start expunge at preceding stale entry if it exists
if (slotToExpunge == staleSlot)
slotToExpunge = i;
// 4.清理过期Entry
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}

// If we didn't find stale entry on backward scan, the
// first stale entry seen while scanning for key is the
// first still present in the run.
// 5.如果前面没有过期Entry,则记录传入的staleSlot过期Entry位置后的第一个过期Entry位置
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}

// 6.没有key相同的Entry,直接把过期Entry更换为新的Entry,符合开放地址规则
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);

// If there are any other stale entries in run, expunge them
// 7.清理过期Entry
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

5.getEntry方法(使用expungeStaleEntry)

ThreadLocalMap的getEntry方法,在未直接命中时,会调用getEntryAfterMiss,该方法也会做一次清理:

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

// 1.从当前位置往下找,这个原因在Hash冲突处理章节已经介绍过:
// 插入时,如果当前位置已经有元素,则往下找一个位置,看是否为null,如此反复,直到找到一个为null的位置,把Entry放入该位置;
// 所以,查找的时候,也是一样的逻辑,如果根据key算出来的Hash值位置上,不是当前Entry,那么就顺着数组往下找;
while (e != null) {
ThreadLocal<?> k = e.get();
// 2.如果key相同,说明找到,直接返回
if (k == key)
return e;
// 3.如果当前位置key为null,特殊处理:清除过期Entry
if (k == null)
expungeStaleEntry(i);

// 4.继续找数组的下一个位置
else
i = nextIndex(i, len);
e = tab[i];
}

// 5.最后还是没有找到,返回null
return null;
}

在key为null时,会调用expungeStaleEntry方法进行清理,前文已经分析过expungeStaleEntry过程,不再赘述。

6.内存泄漏总结

大多数情况下,使用ThreadLocal不会产生内存泄露问题,因为在后续的set、get过程中,ThreadLocal会自动进行内存清理。

ThreadLocal自动清理机制需要依赖于用户调用ThreadLocalMap下的set和getEntry两个方法,即ThreadLocal的set、get方法,如果一个ThreadLocal对象已经被GC,用户不再向同一个Thread绑定新的ThreadLocal对象,也再不读取Thread上的其它ThreadLocal对象,就无法触发ThreadLocalMap的set和getEntry方法,导致ThreadLocal内存储的Value对象永久驻留内存。

==所以即使ThreadLocal有自动内存清理机制,依然建议使用remove方法来手动清理内存。使用完ThreadLocal变量后,手动remove是个非常好的习惯!==