一、前言
最近做代码评审时,因为有使用到ThreadLocal做上下文承载,一大佬便问到在使用后是否有进行remove操作,如果没有的话可能存在内存泄漏。当时根据一点点微弱的记忆,本人觉得是没有这种风险的,但记忆有限,便在之后再次阅读了源码并请教了大佬。接下来,就聊聊这次的关于ThreadLocal内存泄漏的探讨。
二、基本原理
对于初学者来说,关于ThreadLocal的原理,如果直接看源码的话可能得多看几遍才能比较清晰。网络上有很多详尽的文章讲解它的构造,可以细品,帮助思考。这里主要呈现我对它的理解。当我们在一个线程中第一次实例化一个ThreadLocal,并调用它的set方法,具体如下:
ThreadLocal<Integer> tl= ThreadLocal.withInitial(()->null);
tl.set(1);
那么会在该线程实例中会创建出一个ThreadLocalMap对象实例,该对象实例底层就是一个16大小的数组,数组元素类型为静态内部类Entry,Entry结构暂且可以理解为只具有key,value两个属性。数组中现在只有一个元素即一个Entry实例,Entry实例的key的是对tl的引用,value为 Integer实例1。具体结构如下图:
如果在该线程中再实例化一个ThreadLocal并调用set函数,代码示例如下:
ThreadLocal<Integer> tl= ThreadLocal.withInitial(()->null);
tl.set(1);
ThreadLocal<Integer> tl2= ThreadLocal.withInitial(()->null);
tl2.set(1);
那么其结构示意图将会是如下样子:
根据以上,可以简单的总结为,每个thread实例会维护一个自己特有的threadLocalMap实例,也就是一个数组。数组中的将会存放对不同threadLocal实例和我们需要的value的引用。这里对threadLocal实例的引用采用了弱引用的设计,具体定义如下源码所示:
static class Entry extends WeakReference<ThreadLocal<?>> {
// 我们需要的value
Object value;
Entry(ThreadLocal<?> k, Object v) {
// k为实例化的tl
super(k);
value = v;
}
}
弱引用的特性是,假如一个实例只存在弱引用,那么它一定会在下次gc时被回收。网络上好些文章讲的是正是由于这个弱引用的设计,在tl被gc回收后,而value还在,而存在内存泄漏的风险。那么,接下来,我们看看具体的情况是如何的呢。
三、是否内存泄漏
现假设对ThreadLocal的使用类似上述示例的方式,即声明一个局部变量及创建一个tl实例并set赋值,然后再在后续逻辑中get使用。代码示例如下:
ThreadLocal<Integer> tl= ThreadLocal.withInitial(()->null);
tl.set(1);
// 其他逻辑。。。
Integer num = tl.get();
// 其他逻辑。。。
return;
那么这种使用方式存在两种情况,
1、线程为线程池中的线程,一直存活;
2、线程为独立创建的线程,正常执行完后终止。
情况1:
线程一直存活,那么该线程每次执行此段代码后,都会创建一个新的tl实例,并在自己的localMap中新增一个Entry,也即其底层数组会一直膨胀扩容。当发生gc后,由于之前创建的tl只存在entry对其的弱引用,那么这些tl将会被回收,那么就会出现entry.key=null而entry.value!=null的情况。而在ThreadLocal的set和get方法中都有对这种entry进行的清除操作,不过这种清除操作需要在一定条件下才会触发且不能完全清除。那么这就会有内存泄漏的风险。set方法的清除操作请看如下代码注释:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
// 检查hash冲突
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
// 这里就是上述描述的情况,出现hash冲突且之前的tl被回收后,将会进行无效entry的清除操作
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
// 如果没有hash冲突,这里会尝试清除部分无效entry
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
get方法的清除操作请看如下代码注释:
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
// 如果出现hash冲突或真的不存在有该tl引用的entry,那么这里会遍历i后面的entry,并且在遍历过程中清除部分无效的entry
return getEntryAfterMiss(key, i, e);
}
情况2:
线程执行完后销毁。那么这种情况下,线程都销毁了,线程里面的数组也将被销毁,对tl的引用和value的引用也将不复存在。这就完全没有内存泄漏的风险。
综上,情况1会出现内存泄漏的风险,那么在情况1下,在使用完tl后调用remove方法会不会消除这种风险呢?答案是肯定的。具体如下面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) {
// 解除entry对tl的引用关系
e.clear();
// 解除 localMap对 entry的引用关系,顺带清除一下附近的无效entry
expungeStaleEntry(i);
return;
}
}
}
但是,上述对ThreadLocal的用法示例是我们平常所推荐的使用方式吗?应该不是吧,至少我还没有在阅读过的代码中看到过。日常接触到的使用方式是拿它来做上下文承载工具。就拿我使用的方式举例,代码如下:
public class BizContext {
private static final ThreadLocal<BizContext> HOLDER= ThreadLocal.withInitial(BizContext::new);
/**
* 初始化
* @param pin
* @return
*/
public static void init(String pin){
BizContext context = new BizContext(pin);
HOLDER.set(context);
}
/**
* 用户pin
*/
private String pin;
/**
* 获取pin
* @return
*/
public static String getPin() {
return HOLDER.get().pin;
}
}
如上所示,如果将ThreadLocal声明为一个静态不可变常量,那么无论是上述的情况1还是情况2,在不调用remove的情况下,都不会发生内存泄漏(因为静态不可变常量会一直持有对该tl的强引用),除非你说我只在一直存活的线程中对该tl只执行一次set,get操作,那我无话可说。
四、总结
上述内容如有不当之处,欢迎指正,欢迎交流,感谢~