ThreadLocal系列之——内存泄露剖析(二)

回顾

前文,介绍了ThreadLocal的使用姿势,并分享业务实战场景,其中提到了一个重要的点:每次请求结束后需要清理ThreadLocal,避免内存泄露

前文由于篇幅原因并未铺张开来细聊,故此本文将围绕ThreadLocal内存泄露这个点进行详细的剖析,旨在锻炼表达能力,同时分享给各位

正文

本文所有源码基于JDK 1.8.0_192,不同版本之间实现方式或细节或有出入,请以手中的源码为准

ThreadLocal数据结构

要理解ThreadLocal内存泄露的原因,首先需要了解TheadLocal其数据结构、数据模型,并在脑子中描绘一个具象化图案,这样,就能将ThreadLocal的使用过程动态化,更易于理解

先看ThreadLocal#set方法,即赋值方法,一言以蔽之:从当前线程中取出ThreadLocalMap,并将{自己, 值}这样一组键值对放进去,就完成了赋值操作

// java.lang.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);
}

接着看ThreadLocal#get方法,即取值方法,一言以蔽之:从当前线程中取出ThreadLocalMap,并将(自己)做为key从map中取出值,就完成了取值操作

// java.lang.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();
}

由上可知,setget是一组对称方法,以自身对象为Key,联通赋值与取值的过程,而值就存储在ThreadLocalMap里

接下来看看ThreadLocalMap是如何联通该过程的

public class Thread implements Runnable {
    
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
}


// java.lang.ThreadLocal#createMap
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

由上可知,Thread类(即当前线程)有一个ThreadLocalMap类型的成员变量,该成员变量被ThreadLocal维护着(创建、销毁,往里放值,从中取值)。换句话说,操作ThreadLocal本质上是在操作当前线程的ThreadLocalMap

接着看ThreadLocalMap的类定义

Javadoc:

ThreadLocalMap is a customized hash map suitable only for maintaining thread local values. No operations are exported outside of the ThreadLocal class. The class is package private to
allow declaration of fields in class Thread. To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys. However, since reference queues are not used, stale entries are guaranteed to be removed only when the table starts running out of space.

简单翻译一下:

  1. ThreadLocalMap是一个自定义的 hash map,只用在维护thread local 值的场景,与java.util.HashMap不是一个东西
  2. ThreadLocalMap只会被ThreadLocal维护,自己就不要想直接操作它了,是不被允许的
  3. ThreadLocalMap真正存数据的是Entry,且Entry的key使用的是弱引用(WeakReferences) -> 很重要
// java.lang.ThreadLocal.ThreadLocalMap
static class ThreadLocalMap {
    // ...(省略)
    /**
     * The initial capacity -- MUST be a power of two.
     */
    private static final int INITIAL_CAPACITY = 16;

    /**
     * The table, resized as necessary.
     * table.length MUST always be a power of two.
     */
    private Entry[] table;

    /**
     * The number of entries in the table.
     */
    private int size = 0;

    /**
     * The next size value at which to resize.
     */
    private int threshold; // Default to 0
}

看到这几个变量,相信读过HashMap源码的同学应该都会倍感熟悉:存储健值对的Entry数组,数组的大小,数组扩容的阈值(知识的迁移)。通过阅读Java doc还知道,它的Entry使用了弱引用做为key:

Javadoc:

The entries in this hash map extend WeakReference, using its main ref field as the key (which is always a ThreadLocal object). Note that null keys (i.e. entry.get() == null) mean that the key is no longer referenced, so the entry can be expunged from table. Such entries are referred to as "stale entries" in the code that follows.

简单翻译一下:

  1. Entry的key必须是ThreadLocal类型引用,且是一个弱引用(WeakReference)
  2. 如果entry.get() == null ,就意味着某Entry的key指向的对象已经被GC,因此该entry就可以从table中回收,此时该entry在table中被称为旧Entry(stale entry)

阅读源码也能知道Entry使用了弱引用做为key:

// java.lang.ThreadLocal.ThreadLocalMap.Entry
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

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

简单解释一下弱引用(WeakReference)的特征:如果某个对象剩下弱引用指向它,那么下一次GC的时候该对象就会被回收掉。请记住这个特征,下文分析内存泄露时会用上

具体解释可参看:What is a Weak Reference?

行文至此,出现了几个关键类:Thread、ThreadLocal、ThreadLocalMap、Entry,下面用一张图来表示这几个类的关系

image

如上图所示,每个Thread(线程)内部都有一个ThreadLocalMap,ThreadLocalMap里包含一个Entry数组用于存放一个个的键值对,其中键是指向TheadLocal对象本身的弱引用,值就是ThreadLocal#set时的值

用一个简单的小案例演示如下:

定义了两个TheadLocal变量(tlString, tlInteger):在线程1中将tlString赋值为foo,tlInteger赋值为1;在线程2中将tlString赋值为bar,tlInteger赋值为2

public static void main(String[] args) {
    ThreadLocal<String> tlString = new ThreadLocal<>();
    ThreadLocal<Integer> tlInteger = new ThreadLocal<>();

    Thread t1 = new Thread(() -> {
        tlString.set("foo");
        tlInteger.set(1);
    });

    Thread t2 = new Thread(() -> {
        tlString.set("bar");
        tlInteger.set(2);
    });

    t1.start();
    t2.start();
}

上述代码在t1、t2线程结束之前,有2个ThreadLocal弱引用,指向2个ThreadLocal对象实例

t1线程视角:Entry中key为tlString,值为foo;key为tlInteger,值为1

t2线程视角:Entry中key为tlString,值为bar;key为tlInteger,值为2

上述代码对应的逻辑概念图如下示:

image

有了上述ThreadLocal相关的数据结构铺垫之后,接下来开始分析内存泄露的原因

示例代码如下示:

public static void main(String[] args) {
    ExecutorService executorService = Executors.newFixedThreadPool(1);

    executorService.execute(() -> {
        ThreadLocal<User> tl = new ThreadLocal<>();
        tl.set(new User());
    });
}

上述代码对应的逻辑图如下(忽略了无关细节):由于线程池里只有一个线程,在此文称之为thread,以与主线程(main)区分开来

image

那么,为什么说每次ThreadLocal使用完毕之后,需要调用ThreadLocal#remove方法来避免内存泄露呢?

接下来将采用反证法进行论述:即如果不调用remove方法,将会造成什么问题

对上面的示例代码做个轻微的改动,如下图示:

image

主线程视角:将任务提交到线程池里异步执行,主线程睡眠等待3秒,确保异线线程中的任务被执行完毕(简单起见,不使用线程通知机制来实现准确等待),之后调用System#gc()主动触发一次GC

thread线程视角:实例化一个ThreadLocal对象,然后调用set方法赋值,接着是使用该对象做业务逻辑(省略),最后给tl引用赋值为空,此后已经没有任何强引用再指向ThreadLocal对象

thread线程的ThreadLocalMap中,Entry的key为WeakReference,且我们在上边提到:如果某个对象剩下弱引用指向它,那么下一次GC的时候该对象就会被回收掉

因此,逻辑图将会变成如下模样:

image
image

请仔细瞧:

  • 堆中有一个User对象实例,同时有一个强引用指向它,意味着它还存活着,不能被GC回收掉
  • 与此同时,没有任何途径能够接触到user对象的强引用ref,因此也不能访问到该User对象实例

如此,内存泄露便产生了!

一个对象如果被强引用指向着,JVM宁愿抛出OOM也不会将对象GC掉,这是基本共识,无需解释

那为什么没有途径接触到user对象的强引用ref呢?因为JDK提供的获取vaue值的方法仅有一个:java.lang.ThreadLocal#get,如下图示,key为ThreadLocal ref,不可能取出key为null的entry

image

要解决内存泄露,一早提过,需要在使用完ThreadLocal之后,调用ThreadLocal#remove方法

image

那为什么执行ThreadLocal#remove方法之后,就不会产生内存泄露呢?

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

一言以蔽之:从当前线程中取出ThreadLocalMap,将this做为key调用remove方法将自己从entry中移除

核心逻辑在于java.lang.ThreadLocal.ThreadLocalMap#remove,该方法的逻辑有两点:

  1. 将tl(ref)的引用置为null,即断掉指向ThreadLocal实例的弱引用
  2. 删除"无用" entry
    • 将entry里值的强引用置为null,即断掉指向值对象的强引用(这样值对象就对被GC回收)
    • 将entry对应引用置为null,即断掉指向Entry对象的强引用(这样Entry就能被GC回收)

执行完remove方法之后,逻辑图如下示:

image
image

下次GC,就会将User对象实例以及Entry实例回收掉

而上图中stack space中的tl(ref)强引用会在异步线程任务执行完后被回收,对应的ThreadLocal对象实例则会在下次GC时被回收掉

因此我们得出了一个简约而不简单的结论:使用ThreadLocal完毕之后,请记住调用remove方法避免内存的泄露。这样的一个使用规则并不会给开发人员造成负担 -> 请把它当成资源(如文件流、网络IO流等)的一种,使用完毕之后在finally中关闭

行文至此,仍有一个疑问:Entry的Key引用类型为什么是弱引用?

那是因为,一旦stack space里的tl(ref)强引用被回收之后,堆里的ThreadLocal对象实例只剩下弱引用对它的指向,因此能够被及时回收掉,避免过多的内存占用

总结

本文分析了TheadLocal的数据结构、数据模型,并以此为根据,分析了ThreadLocal不恰当使用姿势造成的内存泄露问题,提醒大家使用完ThreadLocal须记得调用remove方法及时回收,避免内存泄露