作者:​​半身风雪​

上篇:​​Java 天生就是多线程​

系列文章简介:​​上一节我们都明白了为什么Java 天生就是多线程,这一节我们一起来学习ThreadLocal进阶解析。​【Java 线程系列】ThreadLocal进阶解析_强引用


@​​TOC​



一、ThreadLocal 辨析

        ThreadLocal 和 Synchonized 都用于解决多线程并发訪问。可是 ThreadLocal 与 synchronized 有本质的差别。synchronized 是利用锁的机制,使变量或代码块 在某一时该仅仅能被一个线程访问。而 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

public final static ThreadLocal<String> RESOURCE = new ThreadLocal<String>();

        RESOURCE代表一个能够存放String类型的ThreadLocal对象。 此时不论什么一个线程能够并发访问这个变量,对它进行写入、读取操作,都是 线程安全的。

三、ThreadLocal 解析

【Java 线程系列】ThreadLocal进阶解析_弱引用_02

先来看一下源码:

【Java 线程系列】ThreadLocal进阶解析_内存泄漏_03【Java 线程系列】ThreadLocal进阶解析_内存泄漏_04【Java 线程系列】ThreadLocal进阶解析_强引用_05

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

看下 ThreadLocal 的内部类 ThreadLocalMap 源码:

【Java 线程系列】ThreadLocal进阶解析_强引用_06

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

        回顾我们的 get 方法,其实就是​​拿到每个线程独有的 ThreadLocalMap​​ 然后再用 ThreadLocal 的当前实例,拿到 Map 中的相应的 Entry,然后就可 以拿到相应的值返回出去。当然,如果 Map 为空,还会先进行 map 的创建,初始化等工作。


四、引发的内存泄漏分析

【Java 线程系列】ThreadLocal进阶解析_内存泄漏_07

        上图中的 o,我们可以称之为对象引用,而 new Object()我们可以称之为在内存 中产生了一个对象实例。         当写下 o=null 时,只是表示 o 不再指向堆中 object 的对象实例,不代表这 个对象实例不存在了。

4.1、 强引用

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


4.2、 软引用

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

4.3、 弱引用

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

4.4、 虚引用

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


下面我们来写一段代码,展示内存泄漏的现象:

public class ThreadLocalOOM {

private static final int TASK_LOOP_SIZE = 500;

// 这里创建了5个线程池,大小固定为5 个线程,不明白没关系,关注我,后期会讲解
final static ThreadPoolExecutor poolExecutor = new
ThreadPoolExecutor(5, 5, 1,
TimeUnit.MINUTES, new LinkedBlockingDeque<>());

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

final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<>();

public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < TASK_LOOP_SIZE; i++) {
poolExecutor.execute(new Runnable() {
@Override
public void run() {
new LocalVariable();
System.out.println("use local variable");
}
});

Thread.sleep(100);
}
}
}

运行上面代码,并将堆内存大小设 置为-Xmx256m。

       可以看到内存的实际使用控制在 25M 左右:因为每个任务中会不断 new 出 一个 5M 的数组,5*5=25M,这是很合理的。

【Java 线程系列】ThreadLocal进阶解析_内存泄漏_08

当我们启用了 ThreadLocal 以后:

【Java 线程系列】ThreadLocal进阶解析_弱引用_09【Java 线程系列】ThreadLocal进阶解析_内存泄漏_10

内存占用最高升至 150M,一般情况下稳定在 90M 左右,那么加入一个 ThreadLocal 后,内存的占用真的会这么多?

于是,我们加入一行代码:

【Java 线程系列】ThreadLocal进阶解析_强引用_11

再执行,看看内存情况:

【Java 线程系列】ThreadLocal进阶解析_内存泄漏_12

       可以看见最高峰的内存占用也在 25M 左右,完全和我们不加 ThreadLocal 表 现一样。这就充分说明,确实发生了内存泄漏。

五、 分析

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

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

【Java 线程系列】ThreadLocal进阶解析_弱引用_13

图中的虚线表示弱引用。

  • 这样,当把 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()方法,清除数据。
  • 其实考察 ThreadLocal 的实现,我们可以看见,无论是 get()、set()在某些时候,调用了expungeStaleEntry方法用来清除 Entry 中 Key 为 null 的 Value,但是这是不及时的,也不是每次都会执行的,所以一些情况下还是会发生内存泄露。 只有 remove()方法中显式调用了 expungeStaleEntry 方法。
  • 从表面上看内存泄漏的根源在于使用了弱引用,但是另一个问题也同样值得思考:为什么使用弱引用而不是强引用?

下面我们分两种情况讨论:

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

比较两种情况,我们可以发现:

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

总结

JVM 利用设置 ThreadLocalMap 的 Key 为弱引用,来避免内存泄露。 JVM 利用调用 remove、get、set方法的时候,回收弱引用。

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

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

【Java 线程系列】ThreadLocal进阶解析_强引用_14