ThreadLocal 真的会内存泄漏吗

一、前言

最近做代码评审时,因为有使用到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。具体结构如下图:
结构1
如果在该线程中再实例化一个ThreadLocal并调用set函数,代码示例如下:

ThreadLocal<Integer> tl= ThreadLocal.withInitial(()->null);
tl.set(1);
ThreadLocal<Integer> tl2= ThreadLocal.withInitial(()->null);
tl2.set(1);

那么其结构示意图将会是如下样子:结构2
根据以上,可以简单的总结为,每个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操作,那我无话可说。

四、总结

上述内容如有不当之处,欢迎指正,欢迎交流,感谢~

  • 25
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值