ThreadLocal分析其弱引用和可能引起的内存泄漏

ThreadLocal大家都不陌生,字面意思是线程本地副本,可在多线程环境下,为每个线程创建独立的副本保证线程安全,在需要线程隔离的场合应用很广泛,但是关于ThreadLocal,总是有两个疑惑:

  1. 听说ThreadLocal中有有使用弱引用,为什么要用弱引用?用弱引用,发生一次gc后,set进去的值再get就是null了吗?
  2. 听说ThreadLocal可能引起内存泄露?啥场景会内存泄露?为何使用了弱引用依然可能发生内存泄露?怎么避免?

首先先来一段代码,看下最基本的使用:我们声明两个线程,将线程的名字通过ThreadLocal保存,然后再通过ThreadLocal取出,看一下每个线程获取到的线程名字

public class TestThreadLocal {

    final static ThreadLocal LOCAL = new ThreadLocal();

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        // 线程1
        executorService.execute(() -> {
            // 存值
            LOCAL.set(Thread.currentThread().getName());
            // 获取值
            System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
        });
        // 线程2
        executorService.execute(() -> {
            LOCAL.set(Thread.currentThread().getName());
            System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
        });
        executorService.shutdown();
    }
}

运行结果

pool-1-thread-1-->pool-1-thread-1
pool-1-thread-2-->pool-1-thread-2

结果没有什么悬念,每一个线程都获取到了与自己相对于的名字。
现在我们就点源码,看下它内部是怎么存储和获取数据的(源码基于jdk1.8,不同版本的jdk实现方式可能稍有不同)

首先看下ThreadLocal的set()

public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

源码短短几行,首先获取当前线程,然后调用getMap(),返回一个ThreadLocalMap,暂且不管这个ThreadLocalMap是什么,通过名字我们简单猜测,就是一个map,我们继续往下看,如果map不为空直接保存数据,map为空则创建然后再保存数据,而保存数据的方法,key传入的this,也就是当前的ThreadLocal对象,value是我们要保存的值(所以注意了,我们不能说ThreadLocal能保存线程独享的变量,而是保存数据的钥匙,通过它操作ThreadLocalMap)。

我们一直在说ThreadLocalMap,现在回过头来,看看ThreadLocalMap是什么,怎么来的吧。首先看看它的由来:ThreadLocalMap map = getMap(t),点进去,很简单,获取了当前线程的成员变量:ThreadLocal.ThreadLocalMap threadLocals,我们可以理解为,每个线程在实例化的时候,都会创建一个ThreadLocalMap实例,保存线程独享的数据。
然后我们在看看ThreadLocalMap吧,该类的源码在ThreadLocal类中,是一个静态的class,简单看一下ThreadLocalMap的实现

static class ThreadLocalMap {
        static class Entry extends WeakReference> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal k, Object v) {
                super(k);
                value = v;
            }
        }
    ...
}

大致看下实现,不要恋战,我们不难看出2点:

  1. 虽然它的名字叫Map,但并没有实现java.util.Map接口,而是自己单独实现的。
  2. 同大多数的Map的实现类似,其内部也是维护了一个Entry存储数据,Entry里有key和value,其中的value在Entry里声明,但是key却并没有直接在Entry里声明,而是继承WeakReference,是一个弱引用,在WeakReference的父类Reference里,声明了
    T referent,即为该map的key

好的,还记得刚刚我们看的ThreadLocal的set()吗,先获取ThreadLocalMap实例,然后调用ThreadLocalMap的set(),我们来看一下ThreadLocal的set()吧,我们依旧不要恋战,没必要一行一行的读,我们大致看一下就好了

 /**
         * Set the value associated with key.
         *
         * @param key the thread local object
         * @param value the value to be 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();
        }

这就是个简易版的Map的put存放数据的方法,相信大家都知道HashMap的实现,对此应该很清楚,大体上就是根据当前哈希桶容量和key的哈希值,计算一个存放角标,将存值的时候,没有当前key,直接新增一个Entry(上文说过,Entry的key是弱引用哦),有当前key,替换掉其value。
但说明一点,与HashMap这种哈希链表存储不同的是,在寻址冲突时,ThreadLocalMap并没有使用链表或红黑树等方式链地址来解决,而是当前地址不可用,就在当前map的数据数组中继续查找下一个可用的地址,有兴趣的可以仔细看下。

兜了一圈,一句话总结这个ThreadLocal的set(T value),就是在当前线程的ThreadLocalMap里存放了数据,key是使用弱引用的ThreadLocal,value就是我们set进去的value

ThreadLocal的获取值等其他方法就不做过多分析了,下面重点分析下开始时抛出的问题一:关于弱引用的问题。
弱引用,在经历一次gc后,不管当前内存是否足够,都会被清除,我们把开始的代码修改一下,在通过ThreadLocal保存数据后,停顿一秒,然后在main线程中触发一次gc,然后在在线程中通过ThreadLocal获取数据,看会不会被清除。为了确认到底有没有发生gc,在启动时我们加入参数
-XX:+PrintGCDetails

public class TestThreadLocal {

   static ThreadLocal LOCAL = new ThreadLocal();

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        executorService.execute(() -> {
            // 存值
            LOCAL.set(Thread.currentThread().getName());
            try {
                // 停顿一秒,以便先在gc,再get
                Thread.sleep(1000l);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 获取值
            System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
        });
        // 线程二
        executorService.execute(() -> {
            LOCAL.set(Thread.currentThread().getName());
            try {
                Thread.sleep(1000l);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
        });
        // 主线程中触发gc
        System.gc();
        executorService.shutdown();
    }
}

结果如下,如旧成功获取了数据

[GC (System.gc()) [PSYoungGen: 5243K->784K(76288K)] 5243K->792K(251392K), 0.0028957 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
[Full GC (System.gc()) [PSYoungGen: 784K->0K(76288K)] [ParOldGen: 8K->597K(175104K)] 792K->597K(251392K), [Metaspace: 3724K->3724K(1056768K)], 0.0119867 secs] [Times: user=0.06 sys=0.00, real=0.01 secs] 
pool-1-thread-1-->pool-1-thread-1
pool-1-thread-2-->pool-1-thread-2

可见ThreadLocal的使用没有受到gc的影响,原因何在?
我们先分析一下里面的引用链,其中实线为强引用,虚线为弱引用


image.png

可见,现在的ThreadLocal,是有两条引用链的,一条是当前线程中的,由线程指向ThreadLocalMap,通过Map指向Entry,而Entry指向key;另一条引用链则是当前执行的测试类的成员变量:TestThreadLocal#LOCAL,且为强引用,所以目前来说并不会受到gc影响。

我们再来看下问题二,内存泄露的问题,还是来段代码跑跑再说,这段代码,主要做的就是,分别通过new Thread()和线程池的方式开100个线程,每个线程都向ThreadLocal存入1M大小的对象,为了尽快实验出效果,我们把最大堆内存调小点
-Xmx50m -XX:+PrintGCDetails

public class TestThreadLocalLeak {
    final static ThreadLocal LOCAL = new ThreadLocal();
    final static int _1M = 1024 * 1024;

    public static void main(String[] args) {
        //testUseThread();
        testUseThreadPool();
    }

    /**
     * 使用线程
     */
    private static void testUseThread() {
        for (int i = 0; i < 100; i++) {
            new Thread(() ->
                    LOCAL.set(new byte[_1M])
            ).start();
        }
    }

    /**
     * 使用线程池
     */
    private static void testUseThreadPool() {
        ExecutorService executorService = Executors.newFixedThreadPool(100);
        for (int i = 0; i < 100; i++) {
            executorService.execute(() ->
                    LOCAL.set(new byte[_1M])
            );
        }
        executorService.shutdown();
    }
}

使用线程打印结果(部分日志)

[GC (Allocation Failure) [PSYoungGen: 13819K->1712K(13824K)] 24099K->11992K(48128K), 0.0007287 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 12586K->1280K(14336K)] 22866K->12181K(48640K), 0.0008377 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 12257K->1120K(14336K)] 23158K->12021K(48640K), 0.0006637 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 12191K->1216K(14336K)] 23093K->12117K(48640K), 0.0010607 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 

使用线程池打印结果(部分日志)

[Full GC (Ergonomics) java.lang.OutOfMemoryError: Java heap space
[PSYoungGen: 12800K->2080K(14848K)] [ParOldGen: 33327K->33322K(34304K)] 46127K->35402K(49152K), [Metaspace: 3770K->3770K(1056768K)], 0.0129146 secs] [Times: user=0.06 sys=0.00, real=0.01 secs] 

当调用testUseThread()时,系统在运行时执行了大量YGC,但始终稳定回收,最后正常执行,但是执行testUseThreadPool()时,经历的频繁的Full GC,内存却没有降下去,最终发生了OOM。
我们分析一下,在使用new Thread()的时候,当线程执行完毕时,随着线程的终止,那个这个Thread对象的生命周期也就结束了,此时该线程下的成员变量,ThreadLocalMap是GC Root不可达的,同理,下面的Entry、里面的key、value都会在下一次gc时被回收;而使用线程池后,由于线程执行完一个任务后,不会被回收,而是被放回线程池以便执行后续任务,自然其成员变量ThreadLocalMap不会被回收,最终引起内存泄露直至OOM。至于怎么避免出现内存泄露,就是在使用线程完成任务后,如果保存在ThreadLocalMap中的数据不必留给之后的任务重复使用,就要及时调用ThreadLocal的remove(),这个方法会把ThreadLocalMap中的相关key和value分别置为null,就能在下次GC时回收了。

最后,我们回过头来,再看下问题一中的一个疑问:ThreadLocalMap的Entry的key,为什么使用弱引用?还记得我们说,ThreadLocal是有两条引用链吗?那么我们断掉强引用,看看弱引用的表现吧。
这次来段代码,我们自己debug一下

public class TestThreadLocalLeak {
    static ThreadLocal LOCAL = new ThreadLocal();

    public static void main(String[] args) {
        LOCAL.set("测试ThreadLocalMap弱引用自动回收");
        Thread thread = Thread.currentThread();
        LOCAL = null;
        System.gc();
        System.out.println("");
    }
}

在gc前和gc后打断点,之前我们分析了,之所以ThreadLocal的数据不会被回收,是因为有两个引用链指向ThreadLocal,一个是当前线程的ThreadLocalMap,另一条就是当前类中的成员变量LOCAL,所以我们手动把LOCAL置为null,再次调用System.gc(),看一下弱引用是不是被回收了
System.gc()前


image.png

System.gc()后


image.png

可见,执行完gc后,确实回收了弱引用key,但是value并没有被回收,原因当然是他是强引用。

上面例子都是基于自己的理解自己写的demo,如果理解的不到位或错误之处,欢迎大家不吝赐教,谢谢!


2020-12-22更新
关于ThreadLocal使用的讨论
看到有些编码规范上,对使用ThreadLocal有如下要求和建议:

(强制)在代码逻辑中使用完ThreadLocal,都要调用remove方法,及时清理。
(推荐)尽量不要使用全局的ThreadLocal。

关于强制的要求的解读为:目前我们的项目中使用的线程,通常是对线程池化管理的(不管是我们自定义的线程池或是tomcat的线程池等),核心线程数之内的线程都是长期驻留池内的。如果不能及时调用remove,一方面可能造成数据泄露,另一方面有可能让使用了上次未清除的值,导致严重的业务逻辑问题。所以推荐在ThreadLocal使用前后都调用remove清理,同时针对异常情况也要在finally中清理。

关于推荐不使用全局ThreadLocal,假设我们全局使用了ThreadLocal,那么这个引用可能保留给了多个业务使用,当有某业务线程修改了该ThreadLocal引用的实例后,会造成其他业务线程获不到解决等不符合预期的问题。

你可能感兴趣的:(ThreadLocal分析其弱引用和可能引起的内存泄漏)