并发编程-2、导致JVM内存泄露的ThreadLocal详解

目录

学习重点:

ThreadLocal的使用

实现解析

实现分析

具体实现

Hash冲突的解决

开放定址法:

链地址法:

再哈希法:

建立公共溢出区

引发的内存泄漏分析

内存泄漏的现象

分析

总结


学习重点:

1.threadLocal的使用

2.threadLocal实现解析

3.hash冲突的解决

4.threadLocal引发的内存泄漏分析总结

ThreadLocal的使用

ThreadLocal类接口很简单,只有4个方法:

• void set(Object value)

设置当前线程的线程局部变量的值。

• public Object get()

该方法返回当前线程所对应的线程局部变量。

• public void remove()

将当前线程局部变量的值删除,目的是为了减少内存的占用。需要指出的是,当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显式调用该方法清除线程的局部变量并不是必须的操作,但它可以加快内存回收的速度。

• protected Object initialValue()

返回该线程局部变量的初始值,该方法是一个protected的方法,显然是为了让子类覆盖而设计的。这个方法是一个延迟调用方法,在线程第1次调用get()或set(Object)时才执行,并且仅执行1次。ThreadLocal中的缺省实现直接返回一个null。

实现解析

实现分析

怎么实现ThreadLocal,既然说让每个线程都拥有自己变量的副本,最容易的方式就是用一个Map将线程的副本存放起来,Map里key就是每个线程的唯一性标识,比如线程ID,value就是副本值,实现起来也很简单:

考虑到并发安全性,对数据的存取用synchronize关键字加锁,但是DougLee在《并发编程实战》中为我们做过性能测试

可以看到ThreadLocal的性能远超类似synchronize的锁实现ReentrantLock,比AtomicInteger也要快很多,即使把Map的实现更换为Java中专为并发设计的ConcurrentHashMap也不太可能达到这么高的性能。

让变量副本跟随着线程本身,而不是将变量副本放在一个地方保存,这样就可以在存取时避开线程之间的竞争,这样可以让ThreadLocal达到高性能。

同时,因为每个线程所拥有的变量的副本数是不定的,有些线程可能有一个,有些线程可能有2个甚至更多,则线程内部存放变量副本需要一个容器,而且容器要支持快速存取,所以在每个线程内部都可以持有一个Map来支持多个变量副本,这个Map被称为ThreadLocalMap。

具体实现

上面先取到当前线程,然后调用getMap方法获取对应的ThreadLocalMap,ThreadLocalMap是一个声明在ThreadLocal的静态内部类,然后Thread类中有一个这样类型成员变量,也就是ThreadLocalMap实例化是在Thread内部,所以getMap是直接返回Thread的这个成员。

看下ThreadLocal的内部类ThreadLocalMap源码,这里其实是个标准的Map实现,内部有一个元素类型为Entry的数组,用以存放线程可能需要的多个副本变量。

红框1.类似于map的结构,key就是ThreadLocal,value就是需要隔离访问的变量

红框2.用数组保存了Entry,因为可能有多个变量需要线程隔离访问

可以看到有个Entry内部静态类,它继承了WeakReference,总之它记录了两个信息,一个是ThreadLocal类型,一个是Object类型的值。getEntry方法则是获取某个ThreadLocal对应的值,set方法就是更新或赋值相应的ThreadLocal对应的值。

get方法,其实就是拿到每个线程独有的ThreadLocalMap

然后再用ThreadLocal的当前实例,拿到Map中的相应的Entry,然后就可以拿到相应的值返回出去。当然,如果Map为空,还会先进行map的创建,初始化等工作。

Hash冲突的解决

什么是Hash,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列值,输入的微小变化会导致输出的巨大变化。所以Hash常用在消息摘要或签名上。

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)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。

建立公共溢出区

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

引发的内存泄漏分析

内存泄漏的现象

执行ThreadLocalMemoryLeak.java,并将堆内存大小设置为-Xmx256m,

启用一个线程池,大小固定为5个线程

场景1,首先任务中不执行任何有意义的代码,当所有的任务提交执行完成后,可以看见,这个应用的内存占用基本上为25M左右

场景2,然后只简单的在每个任务中new出一个数组,执行完成后我们可以看见,内存占用基本和场景1同

场景3,当我们启用了ThreadLocal以后:

执行完成后我们可以看见,内存占用变为了140多M

场景4,于是,我们加入一行代码,再执行,看看内存情况:

可以看见,内存占用基本和场景1同。

这就充分说明,场景3,当我们启用了ThreadLocal以后确实发生了内存泄漏。

分析

根据前面对ThreadLocal的分析,可以知道每个Thread 维护一个 ThreadLocalMap,这个映射表的 key 是 ThreadLocal实例本身,value 是真正需要存储的 Object,也就是说 ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。仔细观察ThreadLocalMap,这个map是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。

因此使用了ThreadLocal后,引用链如图所示

图中的虚线表示弱引用。

所以场景3中,虽然线程池里面的任务执行完毕了,但是线程池里面的5个线程会一直存在直到JVM退出,set了线程的localVariable变量后没有调用localVariable.remove()方法,导致线程池里面的5个线程的threadLocals变量里面的new LocalVariable()实例没有被释放。

其实考察ThreadLocal的实现,可以看见,无论是get()、set()在某些时候,调用了expungeStaleEntry方法用来清除Entry中Key为null的Value,但是这是不及时的,也不是每次都会执行的,所以一些情况下还是会发生内存泄露。只有remove()方法中显式调用了expungeStaleEntry方法。

因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。

总结

JVM利用设置ThreadLocalMap的Key为弱引用,来避免内存泄露。

JVM利用调用remove、get、set方法的时候,回收弱引用。

当ThreadLocal存储很多Key为null的Entry的时候,而不再去调用remove、get、set方法,那么将导致内存泄漏。

使用线程池+ ThreadLocal时要小心,因为这种情况下,线程是一直在不断的重复运行的,从而也就造成了value可能造成累积的情况。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值