参考代码:openjdk-11
参考文档:
面试官连环炮轰炸的ThreadLocal 吃透源码的每一个细节和设计原理
面试官:小伙子,听说你看过ThreadLocal源码?(万字图文深度解析ThreadLocal)
基本概念
ThreadLocal
java.lang.ThreadLocal 类提供线程局部变量。
每个访问一个线程(通过其 get/set 方法)都有自己的、独立初始化的变量副本。
ThreadLocal 的实例通常是“希望将状态与线程相关联”的类中的私有静态字段(例如用户 ID 或事务 ID)。
举个🌰,下面的类保存着每个线程本地的 token,即登录凭证。
public class TokenHolder {
private static ThreadLocal<String> tokenThreadLocal = new ThreadLocal<>();
public static void setToken(String token) {
tokenThreadLocal.set(token);
}
public static String getToken() {
return tokenThreadLocal.get();
}
public static void clear() {
tokenThreadLocal.remove();
}
}
ThreadLocalMap
在 ThreadLocal 类中维护了一个静态内部类 ThreadLocalMap。
虽然名为 Map,但与 java.util.Map 接口无关。
ThreadLocalMap 是一种定制的哈希映射,仅适用于维护线程本地值。不会在 ThreadLocal 类之外导出任何操作。
该类是包私有的,以允许在类 Thread 中声明字段。
为了帮助处理非常大且长期存在的使用,哈希表条目使用 WeakReference 类型的对象作为 key。但由于不使用引用队列,只有在表开始耗尽空间时才能保证删除陈旧条目。
映射中的条目也是定制的:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
/** The initial capacity -- MUST be a power of two. */
private static final int INITIAL_CAPACITY = 16;
/** The table, resized as necessary.
* table.length MUST always be a power of two. */
private Entry[] table;
/** The number of entries in the table. */
private int size = 0;
/** The next size value at which to resize. */
private int threshold; // Default to 0
// ...省略代码
}
这个哈希映射中的条目继承了 WeakReference,使用它所引用的对象作为键(总是一个 ThreadLocal 对象)。请注意,空键(entry.get() == null 也即 k == null)意味着不再引用该键,因此可以从表中删除该条目,称之为“陈旧条目”,具体参见 ThreadLocalMap#set() 方法。
线程是如何与线程局部变量相关联的?
只要线程处于活动状态并且 ThreadLocal 实例可访问时,每个线程都持有对其线程局部变量副本的隐式引用;线程消失后,它的所有线程本地实例副本都将进行垃圾回收(除非存在对这些副本的其他引用)。
Thread 类中声明的字段如下:
// 线程对局部变量副本的引用
ThreadLocal.ThreadLocalMap threadLocals = null;
// 继承自其他线程的局部变量初始值
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
简单来说:Thread 中声明了 ThreadLocal.ThreadLocalMap 类型的引用,ThreadLocalMap 中有一个 Entry<ThreadLocal<?>> 数组。
使用
在上述 TokenHolder 的例子中,调用了 ThreadLocal 的三个常用方法:set、get、remove。
在此之前,需要先讲讲 ThreadLocalMap 中使用的哈希算法。
哈希算法
ThreadLocalMap 中存储着 Entry 类型的数组,如何确定每个元素的数组下标?
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
i 即为 ThreadLocal 类型的对象 key 在散列表 table 中对应的数组下标。
使用开放定址法:当冲突发生时,使用某种探测技术在散列表中形成一个探测序列。沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址(即该地址单元为空)为止。
开放定址法的缺点:
- 容易产生堆积问题,不适于大规模的数据存储。
- 散列函数的设计对冲突会有很大的影响,插入时可能会出现多次冲突的现象。
- 删除的元素是多个冲突元素中的一个,需要对后面的元素作处理,实现较复杂。
这里采用开放定址法的原因:
- 0x61c88647 让哈希码能均匀的分布在 2 的 N 次方的数组里。
- ThreadLocal 往往存放的数据量不会特别大(而且 key 是弱引用又会及时被垃圾回收),开放地址法简单的结构会更省空间,查询效率更高,采用的散列函数也能使冲突概率变低。
如何降低冲突,看 threadLocalHashCode 值的计算方式:
public class ThreadLocal<T> {
/**
* ThreadLocal依赖于附加到每个线程的线性探针哈希映射
* ThreadLocal对象充当键,通过该字段值进行搜索
*/
private final int threadLocalHashCode = nextHashCode();
/** 要给出的下一个哈希码,原子更新,从零开始 */
private static AtomicInteger nextHashCode = new AtomicInteger();
/**
* 连续生成的哈希码之间的差异
* 将隐式顺序的线程本地ID转换为接近最优分布的乘法哈希值,用于2次方大小的表(参考下面的例子)
*/
private static final int HASH_INCREMENT = 0x61c88647;
/** 返回下一个哈希码 */
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
static class ThreadLocalMap {
/** 初始容量,必须是2的幂 */
private static final int INITIAL_CAPACITY = 16;
// 惰性构造
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);
}
}
}
每创建一个 ThreadLocal 对象,nextHashCode 就会增加 0x61c88647。
0x61c88647 是斐波那契数,也叫黄金分割数。增量为这个数字,好处就是 hash 的分布非常均匀。
测试一下:public class ThreadLocalTest { private static final int HASH_INCREMENT = 0x61c88647; private static final int INITIAL_CAPACITY = 16; public static void main(String[] args) { int hashCode = 0; for (int n = 0; n < INITIAL_CAPACITY; n++) { hashCode = n * HASH_INCREMENT + HASH_INCREMENT; int index = hashCode & (INITIAL_CAPACITY -1); System.out.println("第 " + n + " 个哈希码在数组中的位置:" + index); } } }
打印结果:
第 0 个哈希码在数组中的位置:7 第 1 个哈希码在数组中的位置:14 第 2 个哈希码在数组中的位置:5 第 3 个哈希码在数组中的位置:12 第 4 个哈希码在数组中的位置:3 第 5 个哈希码在数组中的位置:10 第 6 个哈希码在数组中的位置:1 第 7 个哈希码在数组中的位置:8 第 8 个哈希码在数组中的位置:15 第 9 个哈希码在数组中的位置:6 第 10 个哈希码在数组中的位置:13 第 11 个哈希码在数组中的位置:4 第 12 个哈希码在数组中的位置:11 第 13 个哈希码在数组中的位置:2 第 14 个哈希码在数组中的位置:9 第 15 个哈希码在数组中的位置:0
table 中存储的值将如下所示:
key k15 k6 k13 k4 k11 k2 k9 k0 k7 k14 k5 k12 k3 k10 k1 k8 ┌─────────────────────────────────────────────────────────────────────┐ value │ 15 │ 6 │ 13 │ 4 │ 11 │ 2 │ 9 │ 0 │ 7 │ 14 │ 5 │ 12 │ 3 │ 10 │ 1 │ 8 │ └─────────────────────────────────────────────────────────────────────┘ index 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
此处的散列函数为:f(x) = x & (table.length - 1)。
例如,假设初始哈希码对应条目的 value = 0,下一个哈希码对应条目的 value = 1,以此类推,第 n 个哈希码对应条目的 value = n。若要向初始数组中写入 key = k1 & value = 27 的条目,计算可得下标为 4,而此时该槽位已存在 key = k2 & value = 11 的条目,故向后线性探测,会有三个结果:
- 找到一个空槽存入;
- 找到之前已存在的 key = k1 的条目进行更新;
- 找到一个 key = null 的条目(陈旧条目)进行替换。
set
由我们直接调用的 ThreadLocal.set() 方法。
// 将此线程局部变量的当前线程副本设置为指定值
public void set(T value) {
// 获取当前线程
Thread t = Thread.currentThread();
// 从线程获取局部变量映射
ThreadLocalMap map = getMap(t);
if (map != null) {
// this即ThreadLocal类对象
map.set(this, value);
} else {
createMap(t, value);
}
}
其中的 getMap() 方法:
ThreadLocalMap getMap(Thread t) {
// 获取线程对局部变量副本的引用
return t.threadLocals;
}
其中的 createMap() 方法:
void createMap(Thread t, T firstValue) {
// 放入ThreadLocal类对象与相关联的值
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
由此也可知:ThreadLocalMap 是惰性构造的,只有在至少有一个条目可以放入时才会被创建。
下面着重来看 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();
// 先遇到匹配条目则直接更新
if (k == key) {
e.value = value;
return;
}
// 遇到空键,即陈旧条目
if (k == null) {
// 将设置操作期间遇到的陈旧条目替换为指定键的条目
replaceStaleEntry(key, value, i);
return;
}
}
// 向空槽存入条目
tab[i] = new Entry(key, value);
int sz = ++size;
// 启发式清理并判断是否需要再哈希
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
继续看 replaceStaleEntry() 方法:
将陈旧条目替换为指定键的条目。将 value 参数传递的值存储在条目中,无论指定键的条目是否已经存在。
副作用是,此方法会清除“运行”中的所有陈旧条目。 (“运行”是指两个空槽之间的一系列条目。)
注释的说法很抽象,看源码。
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
// 设置待删除槽位值为初始陈旧槽
int slotToExpunge = staleSlot;
// 从初始槽向前遍历,找到最前面的陈旧条目(遇到空槽为止),更新待删除槽位值
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
// 从初始槽向后遍历(遇到空槽为止)
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// 匹配到key,需将初始陈旧槽与该陈旧条目交换以维护哈希表顺序(使偏移数据的位置更合理)
// (在此处匹配到了,说明tab[i]的条目当时在插入时目标槽被占用了,向后线性探测到此的)
if (k == key) {
e.value = value;
// 交换至初始陈旧槽的位置(相当于将其rehash到合适的位置)
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// 如果待删除槽就是初始陈旧槽(说明陈旧槽之前没有更多的陈旧条目了)
if (slotToExpunge == staleSlot)
// 更新待删除槽位值为交换后的陈旧槽位置
slotToExpunge = i;
// 将待删除槽传入expungeStaleEntry以清理或重新散列运行中的所有其他条目
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
// 如果发现新的陈旧条目,且待删除槽就是初始陈旧槽(说明陈旧槽之前没有更多的陈旧条目了)
if (k == null && slotToExpunge == staleSlot)
// 将待删除槽位值更新为新发现的
slotToExpunge = i;
}
// 如果未找到key或新的陈旧槽,则将新条目放入初始陈旧槽中
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// 如果运行中有任何其他陈旧条目,则清理它们
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
清理
ThreadLocalMap 中的陈旧条目,有探测式和启发式清理,对应 expungeStaleEntry() 和 cleanSomeSlots() 方法。
探测式清理
从当前槽位向后清理,遇到值为 null 则结束,属于线性探测清理。
/**
* 通过重新散列位于staleSlot和下一个空槽之间的任何可能冲突的条目来清除陈旧的条目
* 这也会清除在尾随空值之前遇到的任何其他陈旧条目
* @param 已知具有空键的槽索引
* @return staleSlot后下一个空槽的索引(所有在staleSlot和这个槽之间的都将被检查以进行清除)
*/
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// Rehash until we encounter null
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 {
// 计算当前条目的对应下标h
int h = k.threadLocalHashCode & (len - 1);
// 不等于当前条目的槽位下标i,说明该条目是被偏移过的
if (h != i) {
// 置空当前槽位
tab[i] = null;
// 从”正确“下标向后遍历,找到空槽存放当前条目,使之更接近”正确“位置
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
经过一轮探测式清理后,陈旧条目会被清理掉,正常的条目经过 rehash 后所处的位置理论上更接近 i= key.hashCode & (tab.len - 1)
的位置,这种优化会提高整个散列表查询性能。
启发式清理
启发式扫描一些单元格以查找过时的条目。
在添加新元素或删除另一个陈旧元素时调用。它执行对数扫描,作为不扫描(快速但保留垃圾)和 扫描次数与元素数量成正比 之间的平衡,这将找到所有垃圾但会导致某些插入花费 O(n) 时间。
参数 i 是已知不会持有过时条目的位置。
参数 n 是扫描控制:扫描 log2(n) 单元格,除非找到过时的条目,在这种情况下,将扫描 log2(table.length) - 1 附加单元格。从插入调用时,此参数是元素数,但从 replaceStaleEntry 调用时,它是表长度。(注意:所有这些都可以通过对 n 进行加权而不是仅使用直接对数 n 来更变得或多或少的激进。但此版本简单、快速,并且似乎运行良好。)
// 如果删除了任何陈旧条目,则返回true
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);
return removed;
}
比如对以下数据执行 cleanSomeSlots(0, 16):
key k15 k6 k11 k9 k7 k5 k3 k1
┌─────────────────────────────────────────────────────────────────────┐
value │ 15 │ 6 │ 13 │ │ 11 │ │ 9 │ │ 7 │ │ 5 │ │ 3 │ │ 1 │ │
└─────────────────────────────────────────────────────────────────────┘
index 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
第一轮 n >>>= 1 的值是 8,i 的下一个位置是 1,槽位 1 中不是陈旧条目,跳过;第二轮 n >>>= 1 的值是 4,i 的下一个位置是 2,槽位 2 中是陈旧条目,则将 n 重置为表长度 16,并对槽位 2 进行探测式清理,将最终被清理的槽位 2 的下标赋给 i;第二轮 n >>>= 1 的值又是 8,i 的下一个位置是 3,空槽跳过;第四轮 n >>>= 1 的值又是 4,i 的下一个位置是 4,不是陈旧条目,跳过;以此类推,4 >>>= 1 是 2, 2 >>>= 1 是 1,1 >>>=1 是 0 止。
扩容
在 ThreadLocalMap.set() 方法的最后,启发式清理未删除任何条目,且散列数组中条目的数量已经达到扩容阈值,就开始执行 rehash() 逻辑。
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
rehash():重新包装或调整表的大小。首先扫描整个表,删除陈旧条目。若这不能充分缩小表的大小,请将表大小加倍。
private void rehash() {
// 探测式清理所有陈旧条目
expungeStaleEntries();
// size >= threshold * 3/4,使用较低的加倍阈值以避免滞后
if (size >= threshold - threshold / 4)
resize();
}
// Double the capacity of the table
private void resize() {
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) {
e.value = null; // Help the GC
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}
setThreshold(newLen);
size = count;
table = newTab;
}
get
由我们直接调用的 ThreadLocal.get() 方法:返回此线程局部变量的当前线程副本中的值。如果该变量对于当前线程没有值,则首先将其初始化为 initialValue() 方法返回的值。
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();
}
setInitialValue() 方法是用于建立初始值的 set() 变体,代替 set() 以防用户覆盖 set() 方法。
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;
}
initialValue() 的默认实现仅返回 null。如果开发者希望线程局部变量具有除 null 以外的初始值,则必须对 ThreadLocal 进行子类化,并覆盖此方法。通常使用匿名内部类。
initialValue() 方法将在线程第一次使用 get 方法访问变量时调用,除非线程之前调用了 set 方法。通常,每个线程最多调用此方法一次,但在后续调用 remove 后跟 get 的情况下,它可能会再次调用。
也就是说,要调用 get 方法,需先赋值:调用 set 方法;或手动覆盖 initialValue 方法。
ThreadLocal<String> threadLocalStr = new ThreadLocal<String>() { @Override protected String initialValue() { return "hello world"; } };
接着看 ThreadLocalMap.getEntry 方法,此方法本身仅处理快速路径:直接命中现有key。否则它会中继到 getEntryAfterMiss。这旨在最大限度地提高直接命中的性能,部分原因是使该方法易于内联。
// 获取与key关联的条目
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;
}
remove
由我们直接调用的 ThreadLocal.remove() 方法:删除此线程局部变量在当前线程中的值。如果此线程局部变量随后再被当前线程读取,它的值将通过调用 initialValue() 方法被重新初始化,除非它的值在过渡期被当前线程 set。这可能会导致 initialValue() 方法在当前线程中被多次调用。
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
m.remove(this);
}
}
具体逻辑位于 ThreadLocalMap.remove() 方法:
private void remove(ThreadLocal<?> key) {
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)]) {
if (e.get() == key) {
// 清除引用
e.clear();
// 探测式清理
expungeStaleEntry(i);
return;
}
}
}
弱引用与内存泄漏
弱引用
判定对象是否存活,与对象是否被引用有关。程序员无法控制垃圾回收线程,什么时候释放内存,销毁哪些对象,这些都是都 jvm 自己控制的,但是随着 java.lang.ref 这个包下的类的引进,程序员拥有了一点点控制创建的对象何时释放,销毁的权利。
在 jdk1.2 之前,对引用的定义:如果 Reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。
这个定义很狭窄,我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中,当内存空间在进行垃圾收集后还是很紧张,则可以抛弃。
于是 jdk1.2 之后,Java 对引用的概念进行了扩充,由强到弱分为 4 类:
- 强引用(Strong Reference):在程序代码中普遍存在的,类似 “Object obj = new Object()” 中 obj 这类的引用,垃圾收集器永远不会回收被强引用关联的对象。
- 软引用(Soft Reference):用来描述一些还有用但并非必需的对象,在系统将要发生内存溢出异常前,会把被软引用关联的对象列入回收范围中进行第二次回收,若这次还没有足够的内存,则抛出内存溢出异常。
- 弱引用(Weak Reference):用来描述非必需的对象,被弱引用关联的对象,只能生存到下一次垃圾回收之前,即 GC 工作时,无论当前内存是否足够,被弱引用关联的对象都会被回收。
- 虚引用(Phantom Reference):也称幽灵引用或幻影引用,完全不会对被关联对象的生存时间构成影响,无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的,就是能在这个这个对象被垃圾收集器回收时收到一个系统通知。
ThreadLocal 中的弱引用
ThreadLocalMap 中的 Entry 继承了 WeakReference,也就是 Entry 对象中持有的是对 ThreadLocal 对象的弱引用。
再贴一下示例和图:
public class TokenHolder {
private static ThreadLocal<String> tokenThreadLocal = new ThreadLocal<>();
public static void setToken(String token) {
tokenThreadLocal.set(token);
}
public static String getToken() {
return tokenThreadLocal.get();
}
public static void clear() {
tokenThreadLocal.remove();
}
}
如图所示,一个 ThreadLocal 对象,它同时受到来自两处的引用:一是我们直接声明的 tokenThreadLocal 字段(作为 key),二是当前线程局部变量副本 ThreadLocalMap 对象 中的 Entry 对象(存储 value)。
我们对 ThreadLocal 对象的操作,都是通过直接声明的 tokenThreadLocal 进行的,故此处使用了强引用;而程序在执行时,会通过当前线程获取到 ThreadLocalMap 对象,进而操作其中 Entry 存储的 value 值,那这里为什么使用了弱引用?
假设 Entry 中的是强引用,当我们不再需要使用 tokenThreadLocal 字段时,比如直接将 tokenThreadLocal 设为 null,此时堆中的 ThreadLocal 实例,会因为受到来自当前线程中 Entry 的引用而无法被 GC,这就造成了内存泄漏(暂时的),只有当线程被销毁时,ThreadLocal 实例才能随之被 GC。但在使用线程池的时候,线程结束是不会被销毁的,这就会导致真正的(长期近乎永久)内存泄漏了。
关于使用规范:当 ThreadLocal 对象使用完毕后,应该手动调用其 remove() 方法。原因如下。
当直接把 ThreadLocal 对象的引用置为 null 后,堆中的 ThreadLocal 实例被 GC,而由于来自当前线程的强引用,Entry 中存储的 value 对象未被回收,且无法再通过 ThreadLocal 对象的 get() 方法被访问到,变为陈旧条目(k == null),这也造成了内存泄漏(当然也是暂时的,之后陈旧条目会被清理)。