Java内存泄露学习 ThreadLocal真的会内存泄露吗

概述

ThreadLocal提供了线程内存储变量的能力,这些变量不同之处在于每一个线程读取的变量是对应的互相独立的。比如我们熟知的Spring事务管理中就使用了ThreadLocal来保证多线程环境下connection的线程安全问题。再比如我们日常的java web项目开发中,经常使用ThreadLocal来存储一些用户id等信息,在一次request请求中,首先拿到登录的uid,然后放到ThreadLocal上下文中,这样在service、dao层就不需要一直传递uid直接从上下文中获取就可以了。

但是说起ThreadLocal,除了好用我们还经常听到的就是它有内存泄露的风险,那么到底是怎么产生内存泄露的呢?难道jdk设计的类还会内存泄露吗?我们应该怎么样避免内存泄露呢?

ThreadLocal内部设计

关于ThreadLocal的使用说明就不提了,直接引用网上常见的一个图来描述一下对象的引用关系


image.png

简单解释一下这个图,假设有这样几行代码

Thread t = new Thread(()->{
    ThreadLocal tl = new ThreadLocal();
    tl.set(object);
});
t.start();

Thread类定义有一个property叫做ThreadLocal.ThreadLocalMap threadLocals
threadLocals内部是一个hashmap类似的结构,存储着很多Entry
上面的代码操作后的结果就是
Entry的key是 tl, value是object

那么栈上面的引用t代表的就是CurrentThreadRef,指向new Thread这个对象。tl代表的就是ThreadLocalRef,指向的就是new ThreadLocal这个对象。

内存泄露探究

关于ThreadLocal的内存泄露讨论可能是由于ThreadLocal在我们平时代码使用中越来越频繁,又或许是高频面试题的原因被讨论的越来越多。现在网上关于ThreadLocal内存泄露的分析文章非常之多,但是我觉得并不全面,或者仅仅是提了一下弱引用这个问题就完了。
内存泄露通常说的是key被回收后,value无法被访问到但是仍然占用了内存,key的弱引用当然是最核心的点,但是是否内存泄露还跟我们的使用场景有关。通过上面的图解分析我们可以发现:
1、如果Thread生命周期比较长,是线程池场景,比如tomcat worker线程。那么除非ThreadLocal ref强引用被释放掉,gc就会回收ThreadLocal对象,导致ThreadLocalMap中之前该ThreadLocal对应的value无法回收,内存泄露。
2、上一种情况下,ThreadLocal ref强引用什么情况下会释放呢,如果我们平时使用的时候都是将ThreadLocal定义为static的变量,那么强引用是不会被释放的,所以这时候key的弱引用就没有那么重要了。
3、如果Thread本身生命周期结束了,CurrentThread ref强引用释放了,gc以后ThreadLocalMap就完全被回收了,不会产生内存泄露。

场景一
@Controller
@RequestMapping("/myThreadLocal")
public class MyThreadLocalTestController {

    private static ThreadLocal staticThreadLocal = new MyThreadLocal();

    /**
     * ThreadLocal变量为非静态变量,使用完以后释放掉强引用,
     * 只剩下threadLocalMap中entry的key这个弱引用,gc可以回收掉ThreadLocal对象
     * 但是value My50MB对象不会被回收,除非thread的生命周期结束
     * @return
     */
    @RequestMapping(value = "/nonStaticWithTomcatThread", method = RequestMethod.GET)
    @ResponseBody
    public String nonStaticWithTomcatThread() {
        System.out.println("staticThreadLocal.hashCode=" + staticThreadLocal.hashCode());
        ThreadLocal t = new MyThreadLocal();
        System.out.println("t.hashCode=" + t.hashCode());
        t.set(new My50MB());// 注意禁用jvm逃逸分析的优化 -XX:-DoEscapeAnalysis

        t = null; // 释放掉强引用
        try {
            System.gc();// 提示系统gc
            TimeUnit.SECONDS.sleep(5L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "ok";
    }
}

这种情况下Thread是池化的,但是ThreadLocal ref强引用会被释放。
启动tomcat运行这段代码,我们分3次获取内存dump信息
第一次在tomcat刚启动成功,得到0.hprof
第二次在首次访问/nonStaticWithTomcatThread这个请求后,得到1.hprof
第三次在二次访问/nonStaticWithTomcatThread这个请求后,得到2.hprof

用MAT工具打开3个dump文件,查看Histogram信息,发现byte[]这个内存的占用一次比一次多,每次多出50MB。打开with incoming reference 分别看到如下信息

0.png

0.hprof中看到最大的一个内存占用是静态变量staticThreadLocal中有一个1MB的byte[],这个跟我们的示例没有关系

1.png

1.hprof中看到最大的一个内存占用是My50MB对象内部的一个byte[],My50MB被ThreadLocalMap$Entry引用,所以很明显这个50MB就是那个内存泄露的value。

2.png

2.hprof中看到有2个My50MB对象都引用了一个50MB的byte[],跟1.hprof一模一样只是多了一个,因为我们访问了2次controller。如果我们访问的次数越多,这个内存泄露就越来越明显。

场景二
    @RequestMapping(value = "/staticWithTomcatThread", method = RequestMethod.GET)
    @ResponseBody
    public String staticWithTomcatThread() {
        staticThreadLocal.set(new My50MB());
        /**try {
            // invoke service to do business
        } finally {
            staticThreadLocal.remove();
        }**/
        return "ok";
    }

这种情况下Thread仍然是池化的,ThreadLocal ref强引用是不会被释放的,如果还是调用2次controller方法,打印出来的dump文件是始终只会有一个My50MB存在,前提是2次是同一个线程对象(如果tomcat线程有n个,n个请求同时访问,每一个线程都会存在一个My50MB的对象,不考虑内存溢出的情况下),这里就不拿heap dump分析了。

当然针对这种池化的线程,ThreadLocal就相当于给这个线程增加状态信息,线程复用的情况下容易出现业务逻辑错误。所以我们一般在使用线程处理完业务逻辑后要清理掉线程中的状态信息,也就是加上代码中被注释掉的那段代码。 这样不但能避免逻辑错误,也可以使线程在非活跃状态下系统内存占用的更少,如果不调用remove方法清理其实也是一定程度的内存泄露。

场景三
    @RequestMapping(value = "/staticWithNewThread", method = RequestMethod.GET)
    @ResponseBody
    public String staticWithNewThread() {
        new Thread(()->{
            staticThreadLocal.set(new My50MB());
        }).start();
        return "ok";
    }

这种情况下Thread生命周期在代码执行完毕后就会结束,Thread内部的threadLocalMap就会被内存回收了,所以不存在任何泄露的问题。看起来set了一个对象到staticThreadLocal中,但是其实ThreadLocal只是一个工具,真实存储是在Thread中,不能被表象所迷惑。

ThreadLocal对内存泄露的预防

其实jdk将ThreadLocalMap$Entry的key设计为WeakReference的时候就已经考虑了value的内存泄露问题,我们看看ThreadLocalMap的注释

/**
     * ThreadLocalMap is a customized hash map suitable only for
     * maintaining thread local values. No operations are exported
     * outside of the ThreadLocal class. The class is package private to
     * allow declaration of fields in class Thread.  To help deal with
     * very large and long-lived usages, the hash table entries use
     * WeakReferences for keys. However, since reference queues are not
     * used, stale entries are guaranteed to be removed only when
     * the table starts running out of space.
     */
    static class ThreadLocalMap {

最后一句话的意思大概就是当map的空间占用过大后,那么弱引用的key被回收后,无用的entries就会被清理掉。在get() set()操作的时候都会有一些时机触发,具体可以自行看源码


image.png

总结

1、ThreadLocal只是一个工具,具体的变量存储是放在Thread中的,所以内存泄露很大程度上要看Thread的生命周期
2、ThreadLocalMap$Entry中的key是弱引用,要防止key对象被回收造成value对象的内存泄露
3、ThreadLocal一般都应该定义成static变量
4、如果在线程池场景下使用ThreadLocal一定要记得调用remove

相关阅读:

当ThreadLocal碰上线程池 https://www.jianshu.com/p/85d96fe9358b

你可能感兴趣的:(Java内存泄露学习 ThreadLocal真的会内存泄露吗)