为什么 ThreadLocal 可能会导致内存泄漏?

ThreadLocal 让我们非常方便的管理和使用各线程独独有的局部变量,但是如果使用不当,会导致内存泄漏。这篇文章将详细的介绍为什么 ThreadLocal 会发生内存泄漏,以及如何避免内存泄漏的发生。

内存泄漏

内存泄漏:占有内存空间,但是存储的数据无法被程序使用和释放,随着时间的推移,内存占用量越来越多,最终导致内存溢出。

Java的四种引用类型

  • 强引用:在任何时候,都不会被垃圾回收器回收,我们正常的 = 赋值就是强引用
  • 软引用:当内存不足的时候,会被垃圾回收器回收
  • 弱引用:任何时候,只要发生了 GC,内存都会被回收
  • 虚引用:最弱的引用类型,软引用和弱饮用好歹还可以使用 get 方法获取到对应的对象,虚引用什么也获取不到。主要的作用就是跟踪对象被垃圾回收器回收的活动。

详细解释

相关源码

public class Thread implements Runnable {

    // ......

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
  	// 默认的访问权限:只有处于同一个包中的类可以访问 threadLocals 属性
    ThreadLocal.ThreadLocalMap threadLocals = null;

    /*
     * InheritableThreadLocal values pertaining to this thread. This map is
     * maintained by the InheritableThreadLocal class.
     */
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

    // ......
}

static class ThreadLocalMap {

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

          Entry(ThreadLocal<?> k, Object v) {
              super(k);
              value = v;
          }
      }
  
      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 void set(ThreadLocal<?> key, Object value) {

          // We don't use a fast path as with get() because it is at
          // least as common to use set() to create new entries as
          // it is to replace existing ones, in which case, a fast
          // path would fail more often than not.

          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)]) {
              ThreadLocal<?> k = e.get();

              if (k == key) {
                  e.value = value;
                  return;
              }

              if (k == null) {
                  replaceStaleEntry(key, value, i);
                  return;
              }
          }

          tab[i] = new Entry(key, value);
          int sz = ++size;
          if (!cleanSomeSlots(i, sz) && sz >= threshold)
              rehash();
      }
}

ThreadLocal 本身并不存储数据,只负责管理线程的局部变量。

ThreadLocal.ThreadLocalMap threadLocals = null;

Thread 类中 threadLocals 属性的访问权限是 default. 也就是说只有与 Thread 类处于同一个包中的类才可以访问 threadLocals 属性。并且 Thread 类本身也没有提供任何方法来操作 threadLocals 属性。要操作 threadLocals,只能够通过 ThreadLocal 类。

threadLocals 的类型是 ThreadLocalMap. 它通过 Entry[] 数组来存储键值对。但是 Entry 类的定义比较有意思:

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

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

Entry 继承了 WeakReference, 并根据构造函数可以知道:Entry 中的 key 属性是一个弱引用。当我们通过 ThreadLocal 操作 threadLocalMap 是,内存示意图如下:

image-20250705152750279

根据强引用和弱引用的定义,如果 userThreadLocal 等变量不再指向 ThreadLocal, 那么 ThreadLocal 就只有 Entry 中的弱引用 key 指向 ThreadLocal 对象:

image-20250706112510126

如果发生 GC,ThreadLocal 对象就会垃圾回收器回收占用的内存:

image-20250706113343175

ThreadLocal 对象被回收后,ThreadLocalMap 不会被回收,因为有强引用在使用该对象,但是根据 threadLocals 的定义:

public class Thread implements Runnable {

    // ......

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
  	// 默认的访问权限:只有处于同一个包中的类可以访问 threadLocals 属性
    ThreadLocal.ThreadLocalMap threadLocals = null;

    /*
     * InheritableThreadLocal values pertaining to this thread. This map is
     * maintained by the InheritableThreadLocal class.
     */
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

    // ......
}

只有与 Thread 处于同一个包中的类才可访问 threadLocals 属性,并且源码注释也说明了 threadLocals 属性是交给 ThreadLocal 类来维护的。如果 ThreadLocal 对象不存在了,就没有办法访问 ThradLocalMap 中的元素了。ThreadLocal 虽然被回收了,但是 ThreadLocalMap 却不会被回收,因为有强引用指向它。无法被访问,但是内存又没法被回收,这不就是内存泄漏吗?

知道了为什么会导致内存泄漏,那该如何避免呢?

  1. 在 ThreadLocal 内存被回收之前手动调用 remove() 方法
  2. 避免频繁的创建和删除 ThreadLocal 对象。
  3. 内存池中的线程使用 ThreadLocal 一定要注意以上两点。因为线程池中的线程长时间存活的,而 ThreadLocalMap 的生命周期是与 Thread 绑定在一起的。

为什么要使用弱引用?

如果 Key 使用强引用,开发人员在使用不当的情况下,内存泄漏会更为严重,因为栈中的引用不再指向 ThreadLocal 对象时,Entry 中的 Key 是强引用,堆中的 ThreadLocal 对象永远都无法被回收:

image-20250706121738104

使用弱引用时,如果外部没有强引用指向 ThreadLocal,那么 ThreadLocal 对象会被回收,因此 ThreadLocalMap 可以检查 Entry 中的 key 是否为 null 来判断 Entry 是否过期,从而释放因使用不当造成的内存泄漏。

ThreadLocal 如何尽可能的减少内存泄漏?

  1. 调用 set() 方法时,清除过期元素
  2. 调用 get() 方法时,清除过期元素
  3. 调用 remove() 方法时,清除过期元素

只要在访问 ThreadLocal 的过程过程中, 遍历了底层的 Entry 数组,ThreadLocal 都会进行清除过期元素的操作,将清除元素的操作平坦到每一次基础的操作中。

// set 方法的逻辑:
// 1. 通过 key 找到 Entry 在数组中的位置 i
// 2. 从数组第 i 个位置开始遍历,直到找到 key 对应的元素或者找到已过期的元素
// 		2.1 如果找到 key 对应的元素,直接设置值,然后返回
// 		2.2 如果找到过期的元素,将过期的元素删除,并将过期元素所在的位置设置成 set 方法传递过来的 key 和 value
private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = tableh;
    int i = key.threadLocalHashCode & (len-1);

    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]
        ) {
        ThreadLocal<?> k = e.get();

        if (k == key) {
            e.value = value;
            return;
        }

        // 如果 key 为 null,说明元素已经过期,进行删除
        if (k == null) {
            // 替换过期的元素
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}


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;
        }
    }
}

// key 对应的元素存在且没有发生 hash 冲突,直接返回对应的值
// key 不存在或者是发生了 hash 冲突,遍历数组并清理过期元素
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;
}

// staleSlot 表示过期插槽的位置
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                       int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    // slotToExpunge 变量用于存储第一个需要被清除的元素
  	int slotToExpunge = staleSlot;
  
  	// 1. 向前遍历,直到找到第一个没有存放 Entry 的 slot
  	// 2. 记录在遍历过程中最后遇到的应该清除的 slot 的位置
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;

  	
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
      
          ThreadLocal<?> k = e.get();

          // 向后遍历时,如果发现 key 对应的 entry 存在,重新设置 value 的值
          // 并将 key 对应的 entry 迁移到方法参数传递过来的已过期元素的位置上
          if (k == key) {
                e.value = value;

                tab[i] = tab[staleSlot];
                tab[staleSlot] = e;

                // 当前循环的作用是在数组 staleSlot 位置向后查找过期的元素,
                // slotToExpunge 变量的定义是:第一个需要被清除的元素
                // slotToExpunge == staleSlot 表明数组 staleSlot 位置前面没有过期的元素
                // 前面没有需要被清除的元素,第 i 个位置就是第一个需要被清除的元素,因此需要将 i 的值赋值给 slotToExpunge
                if (slotToExpunge == staleSlot)
                    slotToExpunge = i;
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                return;
           }

      			// k == null 表示第 i 个元素需要被清除
      			// slotToExpunge == staleSlot 表示 staleSlot 前面没有需要被清除的元素,第 i 个位置就是第一个需要被清除的元素
      			// 因此需要将 i 的值赋值给 slotToExpunge
            if (k == null && slotToExpunge == staleSlot)
                slotToExpunge = i;
    }

  	// key 在数组中不存在,则把 key, value 放在 staleSlot 位置上
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

  	// 如果有过期的元素,则清除过期元素
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

// 清除元素,并重新计算未过期元素在数组中的位置
// 返回从 staleSlot 向后遍历遇到的第一个没有存放元素的位置
private int expungeStaleEntry(int staleSlot) {
      Entry[] tab = table;
      int len = tab.length;

      // expunge entry at staleSlot
      tab[staleSlot].value = null;
      tab[staleSlot] = null;
      size--;

      // Rehash until we encounter null
      Entry e;
      int i;
  		// 边遍历边删除元素,同时对元素重新 hash,迁移元素到新的位置上
      for (i = nextIndex(staleSlot, len);
           (e = tab[i]) != null;
           i = nextIndex(i, len)) {
          ThreadLocal<?> k = e.get();
          if (k == null) {
              e.value = null;
              tab[i] = null;
              size--;
          } else {
              int h = k.threadLocalHashCode & (len - 1);
              if (h != i) {
                  tab[i] = null;

                  // Unlike Knuth 6.4 Algorithm R, we must scan until
                  // null because multiple entries could have been stale.
                  while (tab[h] != null)
                      h = nextIndex(h, len);
                  tab[h] = e;
              }
          }
      }
      return i;
}

// 时间复杂度 N * logN
// 返回是否清除过元素
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i); // 时间复杂度 N
        }
    } while ( (n >>>= 1) != 0); // 时间复杂度 logN
    return removed;
}
使用 Hugo 构建
主题 StackJimmy 设计