导致JVM内存泄漏的ThreadLocal

122 阅读8分钟

首先看下ThreadLocal比较确切的定义

This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable. ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID)翻译过来即: 此类提供线程局部变量。这些变量与普通对应变量的不同之处在于,访问一 个变量的每个线程(通过其 get 或 set 方法)都有自己独立初始化的变量副本。 ThreadLocal实例通常是类中的私有静态字段,希望将状态与线程(例如,用户ID或事务ID)相关联。

也就是说 ThreadLocal 为每个线程都提供了变量的副本,使得每个线程在某 一时间访问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。

ThreadLocal的应用场景

  • 跨方法进行参数的传递
  • 在微服务领域,链路跟踪中的traceID传递也是利用了ThreadLocal

ThreadLocal的使用

ThreadLocal 类接口很简单,只有 4 个方法,我们先来了解一下:

• void set(Object value) 设置当前线程的线程局部变量的值。

• public Object get() 该方法返回当前线程所对应的线程局部变量。

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

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

ThreadLocal实现解析

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

public cladd MyThreadLocal<T>{
   private Map<Thread,T>  threadMap = new HashMap<>();
   public synchronized T get(){
          return threadMap.get(Thread.currentThread());
   }
   public synchronized void set(T t){
       ThreadMap.put(Thread.currentThread(),t);
   }

}

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

image.png

可以看到ThreadLocal的性能远超类似synchronize的锁实现ReentrantLock,(synchonized锁是基于JVM层面的内置锁,ReentrantLock是基于java层面的锁对sychonized锁的一个java层面的实现与扩展) 比我们后面要学的AtomicInteger也要快很多,即使我们把Map的实现更换为Java中专为并发设计的ConcurrentHashMap也不太可能达到这么高的性能。

怎么样设计可以让ThreadLocal达到这么高的性能呢?

最好的办法则是让变量副本跟随着线程本身,而不是将变量副本放在一个地方保存,这样就可以在存取时避开线程之间的竞争。

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

image.png

Hash冲突的解决

什么是Hash,就是把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值,输入的微小变化会导致输出的巨大变化。所以Hash常用在消息摘要或签名上,常用hash消息摘要算法有:(1)MD4(2) MD5它对输入仍以512位分组,其输出是4个32位字的级联(3)SHA-1及其他。

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

建立公共溢出区

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

引发的内存泄漏分析

看这样一个例子:

对于

Object o = new Object();

这个o称之为对象的引用,new Object()我们可以称之为在内存中产生了一个对象实例

image.png

当O = null时,只是表示O不再指向堆中的Object实例,并不代表这个对象实例不存在,只是O的引用为null了。

四种引用

  • 强引用

指的是在程序代码中 普遍存在的,类似“Object O = new Object();"这类的引用,只要强引用存在,垃圾收集器永远不会回收掉被引用的对象实例。

  • 软引用

用来描述一些还有用但是并非必须的对象,这些对象在系统将要发生内存溢出异常之前,将会把这些对象实例列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。

  • 弱引用

也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在JDK 1.2之后,提供了WeakReference类来实现弱引用。

  • 虚引用

也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象实例是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象实例被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。

内存泄漏的现象

我们启动一个线程池,大小固定为5个线程

image.png

场景1

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

image.png

场景2

我们只是简单的在每个任务中new出一个数组,执行完成后我们看到,内存占用基本和场景1相同。

image.png

场景3

当我们启动ThreadLocal以后,执行完成后我们可以看见,内存占用变为了100多M

image.png

场景4

我们加入一行代码,再执行,看看内存情况

oom.LocalVariable.remove();

image.png

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

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

分析

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