【并发专题】导致JVM内存泄露的ThreadLocal详解

67 篇文章 3 订阅
16 篇文章 0 订阅

课程内容

一、ThreadLocal简介

ThreadLocal,直译叫做线程本地,但是这个从语义上来说显然是不完整的。所以我们可以叫他:线程本地变量,帮助大家理解。线程本地变量,顾名思义,是线程对象的变量,从对象结构来说,该变量呢,是属于各个线程的,不同线程对象之间是相互隔离的。
见下面的源码,在Thread内部,我们声明了一个ThreadLocal成员变量(准确来说是ThreadLocal.ThreadLocalMap类型的成员变量)。如下图所示:(取自Thread源码)
在这里插入图片描述
为了强化一下,上面提到的【线程之间相互隔离】,再画个图给大伙看看,因为这个很重要(说来这个就跟普通对象里面的成员变量一样,只不过大家【谈线程色变】,会忽略了)
在这里插入图片描述
我希望大家看完这个后,大家能发现并且记住下面几个因素:

  1. 每个线程对象都有一份属于自己的ThreadLocalMap成员变量,所以使用很方便(回自己家拿东西)
  2. ThreadLocalMap就是一个Map,跟我们平时使用的HashMap基本相似
  3. 因为是map,所以一样是<Key, Value>结构。而这个key就是我们使用时声明的ThreadLocal变量,value则是我们要set的值

记住上面三点,那我相信你对于理解ThreadLocal基本没太大问题了。不过我猜大家可能还有个疑问,Thread,ThreadLocal,ThreadLocalMap是什么关系呢?以及为什么要ThreadLocal这个东西。下面为大家一一解答

二、Thread,ThreadLocal以及ThreadLocalMap的关系

这个关系也没大家想象的那么难。首先,我在一开始已经说过了,ThreadLocalMap是Thread对象的一个成员变量,所以,在UML类图里面,他们的逻辑关系就是:(单向)关联关系。

接着我们再说一下ThreadLocal和ThreadLocalMap的关系。首先我们得知道,ThreadLocalMap其实是ThreadLocal的一个静态内部类,那,静态内部类有什么意义呢?这个大家去百度吧。但是个人理解,在这里就只是简单的:把存在一定逻辑关系的类组织在了一起,并且对外界隐藏。
在UML类图里面,他们的逻辑关系就是:依赖关系(没想到吧)。
从代码层面上来说,ThreadLocal对象不直接拥有ThreadLocalMap对象,前者仅仅只是提供了一些对后者的操作能力而已。比如下面的代码:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述通俗易懂,简单明了

三、为什么要有ThreadLocal

重头戏来了,为什么要有这个东西呢?为什么又要单独实现一个ThreadLocal呢,我自己随便搞个HashMap不也是能实现吗?

Q1:为什么要有

大家还记得数据库连接池吗,我们每一次去查数据库的时候,都要先从连接池拿一个连接,然后我们在通过这个连接去做CRUD业务。原生JDBC代码如下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
可以看到,在使用 JDBC 时,我们首先要配置后再拿到 JDBC 连接,然后在增删改查的业务方法中拿到这个连接,并把我们的 SQL 语句交给 JDBC 连接发送到真实的 DB 上执行。
在实际的工作中,我们不会每次执行 SQL 语句时临时去建立连接,而是会借助【数据库连接池】,同时因为实际业务的复杂性,为了保证数据的一致性,我们还会引入事务操作,于是上面的代码就会变成:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
但是上面的代码包含什么样的问题呢?分析代码我们可以发现,执行业务方法 business 时,为了启用事务,我们从数据库连接池中拿了一个连接,但是在具体的 insert 方法和 getAll 方法中,在执行具体的 SQL 语句时,我们从数据库连接池中拿一个连接,这就说执行事务和执行 SQL 语句完全是不同的数据库连接,这会导致什么问题?事务失效了!!数据库执行事务时,事务的开启和提交、语句
的执行等都是必须在一个连接中的。实际上,上面的代码要保证数据的一致性,就必须要启用分布式事务(但是你不可能为了实现一个简单的业务就搞个分布式事务进来吧)
怎么解决这个问题呢?有一个解决思路是,把数据库连接作为方法的参数,在方法之间进行传递,比如下面这样:
在这里插入图片描述在这里插入图片描述
在这里插入图片描述
但是我们分析平时我们使用 SSM 的代码,你会这么做吗?我们在编写数据访问相关代码的时候从来没有把数据库连接作为方法参数进行传递吧。这意味着,对 Spring来说,在帮我们进行事务托管的时候,会遇到同样的问题,那么 Spring 是如何解决这个问题的?
其实稍微分析下 Spring 的事务管理器的代码就能发现端倪,在org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin 中,我们会看到如下代码:
在这里插入图片描述
上面的注释已经很清楚了说明“绑定连接到这个线程”,如何绑定的?继续深入看看
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
看来,Spring 是使用一个 ThreadLocal 来实现“绑定连接到线程”的。

Q2:为什么单独搞,我自己实现一个Map不行吗

那我们试试看吧,既然说让每个线程都拥有自己变量的副本,最容易的方式就是用一个Map将线程的副本存放起来,Map 里 key 就是每个线程的唯一性标识,比如线程 ID,value 就是副本值,实现起来也很简单:
在这里插入图片描述
这里考虑到并发安全问题,对数据的存取肯定是要用synchronize 关键字加锁的,但是 DougLee在《并发编程实战》中为我们做过性能测试,对三种不同类型的锁性能结果如下:
在这里插入图片描述
可以看出来,ReentrantLock(可以看作是synchronized)性能远远低于ThreadLocal。就算我们把上面的示例代码中HashMap换成ConcurrentHashMap,也是差不太多的,因为在jdk1.8中ConcurrentHashMap也是采用了【CAS + synchronized】方案解决的,所以性能估计就在上图中的AtomicInteger跟ReentrantLock一样。
所以到了这里你知道为什么要单独设计一个这样的东西了吧?而且这个思路非常经典,既然并发有性能问题,那我就不并发了,自己持有一个,自己玩自己的与其纠结这么做会不会有问题,倒不如换一个方式

四、ThreadLocal引发内存泄漏分析

在分析这个问题之前,我们需要先对【引用】这个概念再声明一下。引用:即Object o = new Object()这样的代码,他在内存中的模型是这样的:(如果你学过JVM那很容易理解)
在这里插入图片描述
当写下 o=null 时,只是表示 o 不再指向堆中 object 的对象实例,不代表这个对象实例不存在了。
另外,在Java中,引用是有等级的,并且他们在【垃圾回收】过程中会有不一样的表现:
强引用就是指在程序代码之中普遍存在的,类似Object obj=new Object()这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象实例
软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象实例列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK1.2 之后,提供了 SoftReference 类来实现软引用
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在 JDK 1.2 之后,提供了 WeakReference 类来实现弱引用
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象实例是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象实例被收集器回收时收到一个系统通知。在 JDK 1.2 之后,提供了PhantomReference 类来实现虚引用

为什么要说到这个概念?因为在ThreadLocalMap中,对Entry的维护用到了弱引用。如下图所示:
在这里插入图片描述
所以,存在下面的场景,可能会导致内存泄漏。

场景模拟

我们模拟一下使用场景,代码示例如下:

public class ThreadLocalMemoryLeak {
    private static final int TASK_LOOP_SIZE = 500;

    /*线程池*/
    final static ThreadPoolExecutor poolExecutor
            = new ThreadPoolExecutor(5, 5, 1,
            TimeUnit.MINUTES,
            new LinkedBlockingQueue<>());

    static class LocalVariable {
        private byte[] a = new byte[1024 * 1024 * 5];/*5M大小的数组*/
    }

    ThreadLocal<LocalVariable> threadLocalLV;

    public static void main(String[] args) throws InterruptedException {
        SleepTools.ms(4000);
        for (int i = 0; i < TASK_LOOP_SIZE; ++i) {
            poolExecutor.execute(new Runnable() {
                public void run() {
                    
                    // 场景1:
//                    {
//                        SleepTools.ms(500);
//                        System.out.println("use local varaible");
//                    }
//
//
//                    // 场景2:
//                    {
//                        SleepTools.ms(500);
//                        LocalVariable localVariable = new LocalVariable();
//                        System.out.println("use local varaible");
//                    }
//
//                    // 场景3:
//                    {
//                        SleepTools.ms(500);
//                        LocalVariable localVariable = new LocalVariable();
//                        ThreadLocalMemoryLeak oom = new ThreadLocalMemoryLeak();
//                        oom.threadLocalLV = new ThreadLocal<>();
//                        oom.threadLocalLV.set(new LocalVariable());
//                        System.out.println("use local varaible");
//                    }
//
//                    // 场景4:
//                    {
//                        SleepTools.ms(500);
//                        LocalVariable localVariable = new LocalVariable();
//                        ThreadLocalMemoryLeak oom = new ThreadLocalMemoryLeak();
//                        oom.threadLocalLV = new ThreadLocal<>();
//                        oom.threadLocalLV.set(new LocalVariable());
//                        System.out.println("use local varaible");
//                        oom.threadLocalLV.remove();
//                    }
                }
            });

            SleepTools.ms(100);
        }
        System.out.println("pool execute over");
    }

}

我们启用一个线程池,大小固定为 5 个线程:
在这里插入图片描述
场景 1,首先任务中不执行任何有意义的代码,当所有的任务提交执行完成后,可以看见,我们这个应用的内存占用基本上为 25M 左右:
在这里插入图片描述
场景 2,然后我们只简单的在每个任务中 new 出一个数组,执行完成后我们可以看见,内存占用基本和场景 1 相同:
在这里插入图片描述
场景 3,当我们启用了 ThreadLocal 以后:(执行完成后我们可以看见,内存占用变为了 100 多 M)
在这里插入图片描述
场景 4,于是,我们加入一行代码,再执行,看看内存情况:
在这里插入图片描述
可以看见,内存占用基本和场景 1 同。这就充分说明,场景 3,当我们启用了 ThreadLocal 以后确实发生了内存泄漏

结果分析

根据我们前面对 ThreadLocal 的分析,我们可以知道每个 Thread 维护一个ThreadLocalMap,这个映射表的 key 是 ThreadLocal 实例本身,value 是真正需要存储的 Object,也就是说 ThreadLocal 本身并不存储值,它只是作为一个 key来让线程从 ThreadLocalMap 获取 value。仔细观察 ThreadLocalMap,这个 map是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收,因此使用了 ThreadLocal 后,引用链如图所示:
在这里插入图片描述
这样,当把threadlocal变量置为null以后,没有任何强引用指向threadlocal实例,所以threadlocal将会被gc回收。这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value,而这块value永远不会被访问到了,所以存在着内存泄露
只有当前thread结束以后,current thread就不会存在栈中,强引用断开,Current Thread、Map value将全部被GC回收。最好的做法是不在需要使用ThreadLocal变量后,都调用它的remove()方法,清除数据。
所以回到我们前面的实验场景,场景3中,虽然线程池里面的任务执行完毕了,但是线程池里面的5个线程会一直存在直到JVM退出,我们set了线程的localVariable变量后没有调用localVariable.remove()方法,导致线程池里面的5个线程的threadLocals变量里面的new LocalVariable()实例没有被释放。
从表面上看内存泄漏的根源在于使用了弱引用,但是另一个问题也同样值得思考:为什么使用弱引用而不是强引用?

  • key 使用强引用:对ThreadLocal对象实例的引用被置为null了,但是ThreadLocalMap还持有这个ThreadLocal对象实例的强引用,如果没有手动删除,ThreadLocal的对象实例不会被回收,导致Entry内存泄漏。
  • key 使用弱引用:对ThreadLocal对象实例的引用被被置为null了,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal的对象实例也会被回收。value在下一次ThreadLocalMap调用set,get,remove都有机会被回收。

比较两种情况,我们可以发现:由于ThreadLocalMap的生命周期跟Thread一样长,如果都没有手动删除对应key,都会导致内存泄漏,但是使用弱引用可以多一层保障。
因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。

使用注意事项

  1. JVM利用设置ThreadLocalMap的Key为弱引用,来避免内存泄露。
  2. JVM利用调用remove、get、set方法的时候,回收弱引用。
  3. 当ThreadLocal存储很多Key为null的Entry的时候,而不再去调用remove、get、set方法,那么将导致内存泄漏。
  4. 使用线程池+ ThreadLocal时要小心,因为这种情况下,线程是一直在不断的重复运行的,从而也就造成了value可能造成累积的情况。

错误使用ThreadLocal导致线程不安全

参见代码:
在这里插入图片描述
上述代码,运行后的结果为:
在这里插入图片描述
什么每个线程都输出115?难道他们没有独自保存自己的Number副本吗?为什么其他线程还是能够修改这个值?仔细考察ThreadLocal和Thead的代码,我们发现ThreadLocalMap中保存的其实是同一个对象的引用,这个对象是static的。这样的话,当有其他线程对这个引用指向的对象实例做修改时,其实也同时影响了所有的线程持有的对象引用所指向的同一个对象实例。这也就是为什么上面的程序为什么会输出一样的结果。
而上面的程序要正常的工作,应该的用法是让每个线程中的ThreadLocal都应该持有一个新的Number对象。
在这里插入图片描述

五、ThreadLocalMap的哈希冲突

什么是Hash,就是把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值,输入的微小变化会导致输出的巨大变化。所以Hash常用在消息摘要或签名上,常用hash消息摘要算法有:(1)MD4(2) MD5它对输入仍以512位分组,其输出是4个32位字的级联(3)SHA-1及其他。
Hash转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。比如有10000个数放到100个桶里,不管怎么放,有个桶里数字个数一定是大于2的。
所以Hash简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。常用HASH函数:直接取余法、乘法取整法、平方取中法。 Java里的HashMap用的就是直接取余法。
我们已经知道Hash属于压缩映射,一定能会产生多个实际值映射为一个Hash值的情况,这就产生了冲突,常见处理Hash冲突方法:

开放定址法

基本思想是,出现冲突后按照一定算法查找一个空位置存放,根据算法的不同又可以分为线性探测再散列、二次探测再散列、伪随机探测再散列。
线性探测再散列即依次向后查找,二次探测再散列,即依次向前后查找,增量为1、2、3的二次方,伪随机,顾名思义就是随机产生一个增量位移。
ThreadLocal里用的则是线性探测再散列
在这里插入图片描述

链地址法

这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。Java里的HashMap用的就是链地址法,为了避免hash 洪水攻击,1.8版本开始还引入了红黑树。

再哈希法

这种方法是同时构造多个不同的哈希函数:Hi=RH1(key) i=1,2,…,k当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。

建立公共溢出区

这种方法的基本思想是:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。

学习总结

  1. 学习了ThreadLocal的基本使用
  2. 学习了ThreadLocalMap,并且知悉它与ThreadLocal和Thread的关系
  3. 学习了为什么需要ThreadLocalMap
  4. 学习了ThreadLocalMap使用中,容易导致JVM溢出的场景
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

验证码有毒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值