ThreadLocal真会内存泄漏?

前言

在讨论ThreadLocal存在内存泄漏问题之前,需要先了解下面几个知识点:

  • 什么是内存泄漏?
  • 什么是ThreadLocal?
  • 为什么需要ThreadLocal?
    • 数据一致性问题
    • 如何解决数据一致性问题?

当我们了解了上面的知识点以后,会带大家一起去了解真相。包括下面几个知识点:

  • 为什么会产生内存泄漏?
  • 实战复现问题
  • 如何解决内存泄漏?
  • 为什么是弱引用?

只有了解上面的知识点,才能更好的理解以及如何解决ThreadLocal内存泄漏问题。下面我们就开始带大家一步一步的去了解。

什么是内存泄漏?

在讨论ThreadLocal存在内存泄漏问题之前,我觉得有必要先了解一下什么是内存泄漏?我们为什么要解决内存泄漏的问题?这里引用一段百度百科对内存泄漏的解释。

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

从Java的内存管理来说,就是ThreadLocal存在无法被GC回收的内存。这些无法被回收的内存,如果随着时间的推移,从而导致超出内存容量「内存溢出」,最终导致程序崩溃「OutOfMemoryError」。所以为了避免我们的Java程序崩溃,我们必须要避免出现内存泄漏的问题。

ThreadLocal

前面讲了什么是内存泄漏,为什么要解决内存泄漏的问题。现在我们来讲讲什么是ThreadLocal?

简单来说,ThreadLocal是一个本地线程副本变量工具类。ThreadLocal让每个线程有自己"独立"的变量,线程之间互不影响。ThreadLocal为每个线程都创建一个副本,每个线程可以访问自己内部的副本变量。

为什么需要ThreadLocal?

现在我们知道了什么是ThreadLocal,接下来我们讲讲为什么需要ThreadLocal在讲为什么需要ThreadLocal之前,我们需要了解一个问题。那就是数据一致性问题。因为ThreadLocal就是解决数据一致性问题的一种方案,只要当我们了解什么是数据一致性问题后,自然就知道为什么需要ThreadLocal了。

什么是一致性问题?

多线程充分利用了多核CPU的能力,为我们程序提供了很高的性能。但是有时候,我们需要多个线程互相协作,这里可能就会涉及到数据一致性的问题。 数据一致性问题指的是:发生在多个主体对同一份数据无法达成共识。

如何解决一致性问题?

  • 「排队」:如果两个人对一个问题的看法不一致,那就排成一队,一个人一个人去修改它,这样后面一个人总是能够得到前面一个人修改后的值,数据也就总是一致的了。Java中的互斥锁等概念,就是利用了排队的思想。排队虽然能够很好的确保数据一致性,但性能非常低。
  • 「投票」:,投票的话,多个人可以同时去做一件决策,或者同时去修改数据,但最终谁修改成功,是用投票来决定的。这个方式很高效,但它也会产生很多问题,比如网络中断、欺诈等等。想要通过投票达到一致性非常复杂,往往需要严格的数学理论来证明,还需要中间有一些"信使"不断来来回回传递消息,这中间也会有一些性能的开销。我们在分布式系统中常见的Paxos和Raft算法,就是使用投票来解决一致性问题的。
  • 「避免」:既然保证数据一致性很难,那我能不能通 过一些手段,去避免多个线程之间产生一致性问题呢?我们熟悉的Git就是这个实现,大家在本地分布式修改同一个文件,通过版本控制和解决冲突去解决这个问题。而ThreadLocal也是使用的这种方式。

为什么会产生内存泄漏?

上面讲清楚了ThreadLocal的基本含义,接下来我们一起看看ThreadLocal常用函数的源码,只有了解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);
}

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

从上面的源码可以看出,当我们调用ThreadLocal对象的set()方法时,其实就是将ThreadLocal对象存入当前线程的ThreadLocalMap集合中,map集合的key为当前ThreadLocal对象,value为set()方法的参数。

static class ThreadLocalMap {
    static class Entry extends WeakReference<ThreadLocal<?>> {

        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }

 private Entry[] table;
}

这是ThreadLocalMap的源码(由于篇幅原因这里我只取了重要的代码),可以看到ThreadLocalMap中使用一个Entry对象来存储数据,而Entry的key则是一个WeakReference弱引用对象。这里我带大家再复习一下Java对象的几种引用。

  • 「强引用」 :java中的引用默认就是强引用,任何一个对象的赋值操作就产生了对这个对象的强引用。如:Object o = new Object(),只要强引用关系还在,对象就永远不会被回收。
  • 「软引用」:java.lang.ref.SoftReference,JVM会在内存溢出前对其进行回收。
  • 「弱引用」:java.lang.ref.WeakReference,不管内存是否够用,下次GC一定回收。
  • 「虚引用」:java.lang.ref.PhantomReference,也称"幽灵引用"、"幻影引用"。虚作用是跟踪垃圾回收器收集对象的活动,在GC的过程中,如果发现有PhantomReference,GC则会将引用放到ReferenceQueue中,由程序员自己处理,当程序员调用ReferenceQueue.pull()方法,将引用出ReferenceQueue移除之后,Reference对象会变成Inactive状态,意味着被引用的对象可以被回收了,虚引用的唯一的目的是对象被回收时会收到一个系统通知。

实战复现问题

上面我们已经了解了ThreadLocal存储数据的set()方法,现在我们来看一段代码,通过代码来分析ThreadLocal为什么会产生内存泄漏。

public class Test {

    @Override
    protected void finalize() throws Throwable {
        System.err.println("对象被回收了");
    }
}

@Test
void test() throws InterruptedException {
    ThreadLocal<Test> local = new ThreadLocal<>();
    local.set(new Test());
    local = null;
    System.gc();
    Thread.sleep(1000000);
}

我们创建一个测试类,并重写finalize()方法,当对象被回收时会打印消息在控制台方便我们测试观察对象是否被回收。

从代码可以看到,我们创建了一个ThreadLocal对象,然后往对象里面设置了一个new Test对象,然后我们将变量local赋值为null,最后手动触发一下gc。大家可以猜猜,控制台会打印出对象被回收了的消息吗?建议大家动手试试,增加一下理解。

在告诉大家答案之前我们先来分析一下上面的一个引用关系:

示例中local = null这行代码会将强引用2断掉,这样new ThreadLocal对象就只有一个弱引用4了,根据弱引用的特点在下次GC的时候new ThreadLocal对象就会被回收。那么new Test对象就成了一个永远无法访问的对象,但是又存在一条强引用链thread→Thread对象→ThreadLocalMap→Entry→new Test,如果这条引用链一直存在就会导致new Test对象永远不会被回收。因为现在大多时候都是使用线程池,而线程池会复用线程,就很容易导致引用链一直存在,从而导致new Test对象无法被回收,一旦这样的情况随着时间的推移而大量存在就容易引发内存泄漏。

如何解决内存泄漏?

我们已经知道了造成内存泄漏的原因,那么要解决问题就很简单了。

上面造成内存泄漏的第一点就是Entry的key也就是new ThreadLocal对象的强引用被断开了,我们就可以想办法让这条强引用无法断开,比如将ThreadLocal对象设置为private static 保证任何时候都能访问new ThreadLocal对象同时避免其他地方将其赋值为null。

还有一种办法就是想办法将new Test对象回收,从根本上解决问题。下面我们一起看看ThreadLocal为我们提供的方法。

remove()方法

public void remove() {
   ThreadLocalMap m = getMap(Thread.currentThread());
   if (m != null)
       m.remove(this);
}
private void remove(ThreadLocal<?> key) {
  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)]) {
      if (e.get() == key) {
          e.clear();
          expungeStaleEntry(i);
          return;
      }
  }
}
private int expungeStaleEntry(int staleSlot) {
      Entry[] tab = table;
      int len = tab.length;

      // expunge entry at staleSlot
      tab[staleSlot].value = null;
      tab[staleSlot] = null;
      size--;
   // 省略代码...感兴趣可以去看看源码
      return i;
  }

该方法的逻辑是,将entry里value的强引用3和key的弱引用4置为null。这样new Test对象和Entry对象就都能被GC回收。

因此,只要调用了expungeStaleEntry() 就能将无用 Entry 回收清除掉。

但是该方法为private故无法直接调用,但是ThreadLocalMap中remove()方法直接调用了该方法,因此只要当我们使完ThreadLocal对象后调用一下remove()方法就能避免出现内存泄漏了。

综上所述:针对ThreadLocal 内存泄露的原因,我们可以从两方面去考虑:

  1. 删除无用 Entry 对象。即 用完ThreadLocal后手动调用remove()方法。
  2. 可以让ThreadLocal对象的强引用一直存在,保证任何时候都可以访问到 Entry的 value值。即 将ThreadLocal 变量定义为 private static。

为什么是弱引用?

不知道大家有没有想过一个问题,既然是弱引用导致的内存泄漏,那么为什么JDK还要使用弱引用。难道是bug吗?大家再看一下下面这段代码。

@Test
void test() throws InterruptedException {
    ThreadLocal<Test> local = new ThreadLocal<>();
    local.set(new Test());
    local = null;
    System.gc();
    Thread.sleep(1000000);
}

我们假设Entrykey使用强引用,那么引用图就是如下

当代码local = null断掉强引用2的时候,new ThreadLocal对象就是只存在一条强引用4,那么由于强引用的关系GC无法回收new ThreadLocal对象。所以就造成了Entry的key和value都无法访问无法回收了,内存泄漏就加倍了。

同理也不能将Entry的value设置为弱引用,因为Entry对象的value即new Test对象只有一个引用,如果使用弱引用,在GC的时候会导致new Test对象被回收,导致数据丢失。

将Entry的key设置为弱引用还有一个好处就是,当强引用2断掉且弱引用4被GC回收后,ThreadLocal会通过key.get() == null识别出无用Entry从而将Entry的key和value置为null以便被GC回收。具体代码如下

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {
        ThreadLocal<?> k = e.get();
        if (k == key)
            return e;
        if (k == null)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}

所以,Entry key使用弱引用并不是一个bug,而是ThreadLocal的开发人员在尽力的帮助我们避免造成内存泄漏。

彩蛋

@Test
void test2() throws InterruptedException {
    ThreadLocal<Test> local = new ThreadLocal<>();
  local.set(new Test());
    local = null;
    System.gc();
   for (int i = 0; i < 9; i++) {
        new ThreadLocal<>().get();
    }
    System.gc();
    Thread.sleep(1000000);
}

感兴趣的同学可以尝试运行上面的代码,你会发现惊喜的!至于结果大家自己动手去获取吧!。下面我们再来看一个ThreadLocal常用的方法。

get()方法

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
  Entry[] tab = table;
  int len = tab.length;

  while (e != null) {
      ThreadLocal<?> k = e.get();
      if (k == key)
          return e;
      if (k == null)
          expungeStaleEntry(i);
      else
      i = nextIndex(i, len);
      e = tab[i];
  }
  return null;
}

从上面的代码你会惊奇的发现,get方法也会调用expungeStaleEntry()方法,当然不是每次get都会调用。逻辑大家可以去看源码慢慢理。这里再提一下,可以顺便看看完整的set方法,你还会发现秘密。

本文使用 markdown.com.cn 排版