ThreadLocal一文直接通透
ThreadLocal概述
讲到ThreadLocal应该大家都不陌生,这也是个老生常谈的问题的,也是我们在学习JUC的时候绕不过去的话题。那么先来聊下,什么是ThreadLocal。
在多线程的环境下,多个线程同时访问一个共享变量的时候,在没有任何措施的情况下是非常容易出现线程安全的问题的。例如,下面的Demo中,有10个线程,每个线程都是将count的值自增100次,在单线程的场景下它的结果就是1000。但是在多线程的场景下,最后输出的count的值是不确定的。
public class Demo {
private static Integer count = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 100; j++) {
count++;
}
}).start();
}
Thread.sleep(3000);
System.out.println(count);
}
}当然,解决上面的办法也有很多,例如采用AtomicInteger原子来代替Integer操作、使用synchronized等等措施。我们聊的是ThreadLocal的解决思想:
同步的思想
当我们的使用AtomicInteger或者Synchronized的时候的,其实更多的时候思考的是同步的思想。那么,什么是同步的思想呢?我们假设下,现在只有一个玩具,有三个小朋友都在抢,那么在同步的思想下,我们的策略就是让他们按照顺序来玩,小A -> 小B -> 小C。这样的话,小A在玩的时候,小B和小C都必须等待小A玩腻了才可以。假如小A在玩的过程,将玩具损坏了,那么小B和小C都只能玩损坏掉的玩具了。
共享的思想
在使用ThreadLocal的时候,依旧是上面的场景,但是我们的思路是:每个人都发一个玩具。这样的话,小A、小B和小C可以同时玩。但是随之而来的问题是什么呢?假如这次玩完了,下次还要玩,那么小A、小B和小C是不是要把玩具存储起来。所以必须要保证,小A下次拿到的玩具还是自己上次玩的,同理小B和小C也是一样的道理。最先想到的办法就是标签,给每个玩具都带上标签,这个下次小朋友按照标签来拿取玩具就可以。
所以ThreadLocal和Synchronized是解决不同场景下的问题的,Synchronized更多的是限制的保证多个线程在访问同一个资源时的线程安全,而ThreadLocal更多的是在保证每个线程都有自己的副本的时候,多个副本之间的线程安全问题。
ThreadLocal的基本用法
🔻 创建一个ThreadLocal
// 定义一个 ThreadLocal 变量,用于保存 Integer 类型的线程本地值
ThreadLocal<Integer> threadLocalValue = new ThreadLocal<>();🔻设置当前线程自己的值
threadLocalValue.set(100); // 当前线程设置自己的 threadLocalValue 为 100🔻获取当前线程存储的值
Integer value = threadLocalValue.get(); // 获取当前线程的 threadLocalValue 值
System.out.println("当前线程的 value = " + value);🔻删除当前线程存储的值
threadLocalValue.remove(); // 清除当前线程的 threadLocal 值ThreadLocal的基本结构
在上面的描述中,对于ThreadLocal具体形态其实我们已经有了一个大概的了解,它更像是一个柜子,每个线程都是这个柜子的拥有者,例如商超中的储物柜,每个用户都有自己的钥匙来拿取这个储物柜中的物品。废话就不多讲了,直接来看ThreadLocal的类结构:

想必看到这里,会有一点疑惑?既然ThreadLocal是一个储物柜,为什么看不到代表储物柜的容器呢?😄这是因为在设计的时候ThreadLocal不是储物柜,而是储物柜的钥匙,真正的储物柜是ThreadLocalMap。ThreadLocalMap是ThreadLocal的内部类,其中table属性就是真正的储物柜,而且对于这个名字ThreadLocalMap,显而易见的是它的数据结构是一个Map。

然后找ThreadLocalMap使用的地方的时候可以看到在Thread类中存在的一个属性:
public class Thread implements Runnbale{
// 代表ThreadLocalMap的具体实体类
ThreadLocal.ThreadLocalMap threadLocals = null;
}所以Thread、ThreadLocal和ThreadLocalMap之间的关系就是:

每个Thread内部都保存着一个ThreadLocalMap,这个Map的Key就是ThreadLocal,value就是要存储的值。
ThreadLocal的源码分析
ThreadLocal的初始化
ThreadLocal只有一个无参的构造函数,所以在创建ThreadLocal的时候可以直接通过new关键字来进行创建。在ThreaLocal中有以下几个属性:
/**
* 这个属性的表示的是threadLocal的唯一标识
* 用于在ThreadLocalMap中作为hash key定位存储位置
*/
private final int threadLocalHashCode = nextHashCode();
/**
* 用于为每一个新创建的ThreadLocal对象分配一个唯一的threadLocalHashCode属性
*/
private static AtomicInteger nextHashCode =
new AtomicInteger();
/**
* 哈希增量,它是斐波那契数列中常用的一个常熟,目的是为了让哈希值更加均匀,减少哈希冲突
*/
private static final int HASH_INCREMENT = 0x61c88647;
/**
* Returns the next hash code.
*/
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}从上面的几个属性来看,其实ThreadLocal的属性是非常简单的,就是threadLocalHashCode用来作为threadLocal的唯一标识。
set()方法
public void set(T value) {
// 获取当前线程
Thread t = Thread.currentThread();
// 获取当前线程的threadLocals属性,也就是ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
// 如果当前map不为空,则将当前值存储到threadLocalMap中
if (map != null) {
map.set(this, value);
} else {
// 如果当前map为空,则先初始化一个ThreadLocalMap,然后再将它存储进去
createMap(t, value);
}
}set()方法的主体逻辑是非常简单的,就是在设置值的时候判断下ThreadLocalMap的值是否初始化过了。如果初始化了,就直接设置值,如果没有就调用createMap()方法来初始化。
createMap()初始化
createMap比较好理解,就是直接new一个ThreadLocalMap对象,然后直接设置初始值;
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
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;
// 初始化哈希表的容量:16 * 2 / 3 = 10
setThreshold(INITIAL_CAPACITY);
}ThreadLocalMap#set()方法
ThreadLocalMap的set()方法就会出现诸多的讲究了,依旧是上源码:
private void set(ThreadLocal<?> key, Object value) {
// 获取当前的用于存储值的table对象(后续称之为哈希表)
Entry[] tab = table;
// 计算哈希表的长度
int len = tab.length;
// 计算当前threadLocal对象在ThreadLocalMap中的下标,标准的取模运算
int i = key.threadLocalHashCode & (len-1);
// 循环遍历ThreadLocalMap(这里需要额外关注下这里的循环条件)
// Entry e = tab[i] 每次遍历的对象
// e != null:跳出循环的条件
// e = tab[i = nextIndex(i, len)]:发生hash冲突后生成的新的下标
// 假如这里是第一次设置值,那么table[i]一定是null,所以不满足循环的条件
// 假如这里是第n次设置值,那么table[i]就不是null,
// 所以只有两种情况:hash冲突的或者对应的key就是的我们要插入的key
for (Entry e = tab[i];e != null;e = tab[i = nextIndex(i, len)]) {
// 判断下e的key是不是就是本次插入的,如果是就直接修改值就好了
if (e.refersTo(key)) {
e.value = value;
return;
}
// 判断e的key是不是null,如果是null说明当前元素已经是过期的了
if (e.refersTo(null)) {
// 构建新的Entry对象来替换这个过期的元素
replaceStaleEntry(key, value, i);
return;
}
}
// 如果是第一次设置值,就直接构建一个Entry对象
tab[i] = new Entry(key, value);
// 元素的数量加1
int sz = ++size;
// 清除掉哈希表中过期的元素,然后判断清理后哈希表的长度是否超过了阈值
if (!cleanSomeSlots(i, sz) && sz >= threshold)
// 如果超过了阈值,就执行哈希表的扩容
rehash();
}在ThreadLocalMap#set()方法中的核心在于如何处理hash冲突的问题,处理hash冲突的问题核心在于nextIndex()方法,它的作用是计算出一个新的下标。
// 这里其实就是循环,如果1冲突了,就是2,2也冲突了就是3,依次类推
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}在ThreadLocalMap#set()方法中还有个比较有意思的方法就是replaceStaleEntry()方法和cleanSomeSlots()方法,它们都设计到处理ThreadLocalMap中的Key为null的元素。这里可能就会有点疑惑,为啥会出现key为null的元素呢?这里我们放在下文中详细描述,这里主要来看下replaceStaleEntry()方法和cleanSomeSlots()方法的主体逻辑。
replaceStaleEntry()方法
// key标识要插入的新的threadLocal对象,value表示要插入的新值,staleSlot表示新元素的下标
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
// 获取当前的哈希表中的内容
Entry[] tab = table;
// 计算哈希表的长度
int len = tab.length;
Entry e;
// 记录新元素的下标为清理的起始下标
int slotToExpunge = staleSlot;
// 这循环的作用主要是从当前下标开始往前找,找到第一个key为null的元素
for (int i = prevIndex(staleSlot, len); // 找到null的元素的前一个下标
(e = tab[i]) != null; // 它的前一个元素不为空,跳出循环
i = prevIndex(i, len)) // 从当前元素往前遍历
if (e.refersTo(null))
slotToExpunge = i;
// 从当前下标往后遍历
// 如果staleSlot下标对应的处的元素为null,不需要往下走,直接向对应下标插入值就好了
// 如果staleSlot下标对应处的元素不为null,就有两种情况了:
// 1.这个key就是我们要出的值对应的key
// 2.这个key不是我们要插入的值对应的key,它为null或不为null
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
// 如果要存储进来的值就是这个key的,就直接放进去
// 这里还要判断是因为如果发生了哈希冲突的话,存储的位置会往后递增
// 所以这里也是有可能找到我们要操作的那个key的元素的
if (e.refersTo(key)) {
e.value = value;
// 这里table[i]和table[staleSlot]这两个元素发生了hash冲突
// 这里的做法就是要将这两个元素交换下,因为这个table[staleSlot]元素的key为null
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// 如果当前下标之前有key为null的元素就执行清理
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
// 在往后遍历的时候发现了新的key为null的元素,就标记当前元素为需要清理的点
// slotToExpunge == staleSlot 这里的意思是前端没有key为null的元素
if (e.refersTo(null) && slotToExpunge == staleSlot)
slotToExpunge = i;
}
// 如果在哈希表没有找到这个key,就直接将新的值存储到当前位置上
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// I说明当前位置往前还有key为null的元素,那么就清理下
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}replaceStaleEntry()这个方法主要处理的问题是:我们要往index=2的slot中写入数据的时候,这个slot中已经有数据了,并且它的key还是null。它的处理策略是:
- 从当前index=2的下标往后遍历,找到我们要操作的那个key。这里其实存在两种可能,我们找到了,说明这个key不是第一次写入值了;我们没找到,这个key是第一个写入值;
- 对于第一次写入值,我们将index=2的slot中的元素的value值设置为null(方便GC回收),然后再这个slot中创建一个新的Entry对象;
- 对于不是第一次写入值,那么我们找到这个key后将它交换到index=2的slot里面来。因为index=2的slot中的元素是过时的,所以交换到前面来是有助于ThreadLocalMap的健康的;
- 记录要开始清理的最小下标。首先是从index=2的下标往前遍历,找到第一个key为null的元素,如果找到了用slotToExpunge变量存储;往前遍历的时候如果没有找到,后面还会往后遍历的,往后遍历的时候如果找到了仍然会使用slotToExpunge变量来进行存储的。最后都是调用 cleanSomeSlots()方法来进行清除的。
cleanSomeSlots()方法
cleanSomeSlots()方法的作用是清除掉ThreadLocalMap中key为null的slot中的元素。
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];
// 如果当前元素不为空,且当前元素的key为null
if (e != null && e.refersTo(null)) {
// n为长度
n = len;
removed = true;
// 清理指定下标的元素
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}expungeStaleEntry()方法
无论是replaceStaleEntry()方法还是cleanSomeSlots()方法,最终都是调用expungeStaleEntry()方法来完成slot的清理的。
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 清除掉当前slot中元素的value属性
tab[staleSlot].value = null;
// 清除掉当前slot中的元素
tab[staleSlot] = null;
// 哈希表中元素的数量减一
size--;
Entry e;
int i;
// 从下标i开始往下清理,直到遇到第一个空slot就不往下了
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// 如果当前Entry的key为null,就执行清理
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
// 如果不为null,就重新计算下他的下标(防止出现扩容)
int h = k.threadLocalHashCode & (len - 1);
// 如果新计算出来的下标与原本的下标不一致说明发生了扩容
if (h != i) {
// 清理掉当前slot中的元素
tab[i] = null;
// 将原本table[i]移动到table[h],放到新的下标位置
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
// 返回第一个为null的slot的下标
return i;
}总结
ThreadLocalMap#set()方法在将值存储到哈希表中的时候,还会清理当前哈希表中key为null的元素。
get()方法
get()方法是从ThreadLocal中获取当前线程存入的值。
public T get() {
// 获取当前线程
Thread t = Thread.currentThread();
// 获取当前线程的threadLocals属性,也就是ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
// 如果ThreadLocalMap初始化过了
if (map != null) {
// 将从ThreadLocalMap中获取到这个元素
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
// 如果元素不为null,就强转成对应的类型返回
return result;
}
}
//
return setInitialValue();
}
private T setInitialValue() {
// 获取默认值
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
// 如果map没有初始化,就初始化下
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
if (this instanceof TerminatingThreadLocal) {
TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
}
return value;
}get()方法就比较好理解了,就是直接从ThreadLocalMap中获取到对应的元素。如果在获取元素的时候发现,ThreadLocalMap还没有初始化,那么就初始化下。如果获取到元素的为null,就会返回它的默认值,如果没有设置默认值的话,它就直接返回null了。
ThreadLocalMap#getEntry()方法
getEntry()方法就是真正的从ThreadLocalMap中拿取元素的方法。
private Entry getEntry(ThreadLocal<?> key) {
// 计算当前threadLocal所在的下标
int i = key.threadLocalHashCode & (table.length - 1);
// 获取对应下标的元素
Entry e = table[i];
// 有两种可能:
// 1.当前下标已经有元素了,key为null则过时,key不为null则不过时
// 2.当前下标没有元素了
if (e != null && e.refersTo(key))
// 如果下标有元素,且它key就是我们要操作的这个threadLocal,就直接返回这个Entry对象
return e;
else
// 反之就执行下面的方法
return getEntryAfterMiss(key, i, e);
}
// 进入这个方法有两种可能:
// 1.index=i的slot内没有元素,这个threadLocal是不存在,压根就没找
// 2.index=i的slot内有元素(发生了hash碰撞)
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
// 遍历哈希表
while (e != null) {
// 发生了哈希冲突,当前threadLocal在存入的时候向后偏移了,这里直接找到了
if (e.refersTo(key))
return e;
// 在遍历的过程中发现了该处元素的key为null
if (e.refersTo(null))
// 从当前下标开始执行依次清理
expungeStaleEntry(i);
else
// 当前下标不为null,说明与要操作的threadLocal对象哈希冲突的另外一个threadLocal对象还是有效的
i = nextIndex(i, len);
e = tab[i];
}
// 如果遍历完了之后都没有找到我们要操作的这个threadLocal,就直接返回null
return null;
}总结
在ThreadLocalMap#get()方法中,通过下标计算找到了我们要操作的ThreadLocal对象,就直接返回slot中的Entry对象。如果没有找到的话,就会从当前index向后遍历开始寻找,找到了就返回,找不到就返回nul。在找的过程中,如果发现了key为null的Entry对象,就执行一次清理。
remove()方法
remove()方法将ThreadLocalMap中该ThreadLocal对象对应的元素清除掉,最终实现的逻辑还是在ThreadLocalMap#remove()方法中。
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)]) {
// 如果当前slot中的元素的key就是我们要删除就清除掉
if (e.refersTo(key)) {
e.clear();
// 然后从当前下标开始执行一次清理
expungeStaleEntry(i);
return;
}
}
}总结
对于remove()方法比较好理解,就是清除当前threadLocal对象对应的Entry对象,在清除的同时也会重新清理一遍ThreadLocalMap中key为null的元素。
rehash()方法
rehash()方法是当ThreadLocalMap中存储的元素数量超过了阈值的时候就会触发扩容。
ThreadLocal扩容的条件主要有两个阶段:
- 首次触发检查:当调用
set方法时,如果清理无效槽(key为null)后,有效槽的数量size仍然大于等于当前数组长度的2/3时,就会触发扩容检查。 - 实际扩容:在真正进行扩容前,会先尝试清理一次无效槽。如果清理后,有效槽的数量
size仍然大于等于当前数组长度的 1/2时,才会执行真正的扩容操作
threshold=32×lensize≥threshold−threshold×41=threshold×43=len×32×43=21×len
阈值的计算方式:threshold = len * 2 / 3
private void rehash() {
// 循环遍历清理掉key为null的所有元素
expungeStaleEntries();
// 判断需要扩容的标准
if (size >= threshold - threshold / 4)
resize();
}
/**
* 直接扩容成当前容量的2倍
*/
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;
// 遍历整个tab
for (Entry e : oldTab) {
if (e != null) {
ThreadLocal<?> k = e.get();
// 如果当前Entry对象的key为null,执行清理
if (k == null) {
e.value = null; // Help the GC
} else {
// 计算新的下标,如果有hash冲突就向后偏移
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
// 将元素存储到新的Entry列表中
newTab[h] = e;
count++;
}
}
}
// 设置新的引用
setThreshold(newLen);
// 设置新的元素数量
size = count;
// 将新的数组赋值给table对象
table = newTab;
}ThreadLocal的内存泄漏问题
内存泄漏指的是程序中已经不再使用的对象,由于某种原因仍然被引用(没有被释放),导致垃圾回收器无法回收这些无用的对象,造成内存的浪费。随着时间的推移,这种浪费不断累积,最终可能引发内存溢出(OOM)。
ThreadLocal在JVM内存中的结构
在JVM中我们的内存主要分为栈内存(虚拟机栈)和堆内存,如果我们在一个方法中使用了ThreadLocal对象,那么它在JVM内存对象中的结果如下图所示:

在栈内存中存在一个Thread对象的引用,它执行堆内存中的Thread对象,同理ThreadLocal对象的引用也会指向堆内存中的ThreadLocal对象,这两个引用都是强引用。
在堆内存的Thread对象中,它存在一个引用指向了一个ThreadLocalMap对象,在这个ThreadLocalMap对象中也存在两个引用:
- 一个是Entry的Key引用,虚引用指向ThreadLocal对象;
- 一个是Entry的Value引用,强引用指向value对象;
为什么是虚引用,这是因为Entry的结构来决定的:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}可以看到Entry类是继承了WeakReference类,然后Key对应的ThreadLocal对象是一个弱引用。
弱引用是一种特殊的对象引用方式,它不会阻止垃圾回收器回收该对象。也就是说,只要某个对象只被弱引用指向,那么就会被垃圾回收器回收掉。
内存溢出的场景
正常情况下而言,我们先来看一个线程从开始到销毁的过程中JVM内存中引用的变化关系:

还是来看上面的图,虚线中表示方法执行完,线程被销毁掉了。然后虚线的引用消失了,那么堆内存中的Thread、ThreadLocalMap对应的引用都会消失。也就是堆内存的ThreadLocal对象和value对象都没有强引用了,所以它会被GC之后被回收掉;
因为线程的销毁和创建时非常消耗资源的,所以更多的时候我们都是通过线程池的方式来使用线程。因此在栈帧出栈的时候,Thread对象它是不会被销毁的,就如下图所示:

此时Thread对象的引用还在,所以ThreadLocalMap的引用也是在的。那么在栈帧出栈后,栈内存中ThreadLocal对象引用就消失了。此时:堆内存中的ThreadLocal对象只有一个ThreadLocalMap中的Entry对象指向它的弱引用。所以在下一次GC的时候,就会导致堆内存的ThreadLocal对象被清理掉了,因此ThreadLocalMap中的Entry对象的Key就是null。因此,我们没有办法清理掉key为null的value属性了,导致这个堆内存的value属性会长期存在 —— 内存泄漏。
内存溢出与虚引用是没有关系的
在网上很多的文章都会说因为这个Key的引用是虚引用,其实不是这样的,这种说法是错误的。这个虚引用恰恰是开发人员为了尽可能避免内存泄漏而选择的。
试想下,假如这里是个强引用,那么在threadLocal对象在栈内存的中的引用消失后,它还存在一个ThreadLocalMap指向的强引用。后续的GC就不会清理堆内存中的ThreadLocal对象了。看似,key不为null,它没有泄漏,但是栈内存中的threadLocal引用已经消失了,那么我们如何来清理这个Entry对象呢?你储物柜内的物品还在,但是要是丢了,那么存储空间还是泄漏的。所以说:造成内存溢出的跟Key是虚引用没有关系的,恰恰相反虚引用是为了尽可能来避免内存溢出的。其实再想下,假如我们用的是强引用,那么Entry中每个Key都是有值的,请问你清除的时候,你怎么识别哪些是过时的的Entry。
反而,当我们使用的弱引用的话,Entry对象中key为null的时候我们就知道了这个Entry对象过时了,所以可以直接删除掉的。
在上面分析set()、get()和remove()方法的时候就发现了,每次执行相关操作的时候都会去清理key为null的Entry对象,这就是在极力避免内存泄漏的问题了,但是仍然会存在内存溢出的问题的。
如果这个ThreadLocal消失后,我们再也不去调用set()、get()或remove()方法了,那么还是会出现内存泄漏的问题。所以在使用ThreadLocal的时候,用完后主动调用remove()方法清理掉来避免内存泄漏的问题。