面试官:了解ThreadLocal的内存泄露问题吗?

655 阅读10分钟

各位读者大家好呀!今天小卡给大家带来的是小卡面试过程中被面试官问到的一个问题~

image.png

ThreadLocal介绍

ThreadLocal 是 Java 中的一个类,用于创建线程局部变量。线程局部变量的作用范围仅限于单个线程,每个线程都有自己的独立副本,互不影响。这对于需要在线程之间隔离状态的多线程编程非常有用,可以避免共享变量导致的线程安全问题。

主要特点

  1. 线程隔离
    • 每个线程都有一个独立的 ThreadLocal 变量副本,这些副本之间不会互相干扰。
    • 这使得 ThreadLocal 成为实现线程安全的一种简单方法,尤其是在处理复杂的多线程应用程序时。
  1. 生命周期
    • ThreadLocal 变量的生命周期与线程的生命周期绑定。当线程结束时,该线程中的所有 ThreadLocal 变量都会被自动清除。
    • 如果线程是长时间运行的(如线程池中的线程),则需要注意手动清理 ThreadLocal 变量,以避免内存泄漏。
  1. 初始化
    • 可以通过 initialValue 方法为 ThreadLocal 变量提供初始值。
    • 如果没有显式地设置初始值,那么默认值为 null

基本用法

以下是一个简单的 ThreadLocal 示例:

public class ThreadLocalExample {

    // 定义一个ThreadLocal变量
    private static final ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        // 在主线程中设置ThreadLocal变量的值
        threadLocal.set("Main Thread Value");

        // 打印主线程中的ThreadLocal变量值
        System.out.println("Main Thread: " + threadLocal.get());

        // 创建并启动一个新的线程
        Thread t1 = new Thread(() -> {
            // 在新线程中设置ThreadLocal变量的值
            threadLocal.set("Thread 1 Value");
            // 打印新线程中的ThreadLocal变量值
            System.out.println("Thread 1: " + threadLocal.get());
        });

        t1.start();

        try {
            t1.join(); // 等待t1线程执行完毕
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 再次打印主线程中的ThreadLocal变量值
        System.out.println("Main Thread after t1: " + threadLocal.get());
    }
}

ThreadLocal是怎么做到线程隔离的?

如果要让某一个变量,具有线程隔离性,一种方法就是让该变量成为线程的私有成员变量,但是该怎么把这个变量放进去呢?

public class RunnableDemo<T> implements Runnable{
    private T value;
    public RunnableDemo(T value){
        this.value = value;
    }
    @Override
    public void run() {
        Thread.currentThread().setValue(value);
    }
}

假设Thread中有这样一个变量专门用于线程隔离的,那么其实我们就可以在run()方法中为这个变量赋值,但是如果只是一个变量局限性就太大了,因为可能不只有一个需要线程隔离的变量,所以最好呀在Thread中有一个Map用于存储需要线程隔离的变量而,ThreadLocal其实也就是这么干的,ThreadLocal中有这样一个静态内部类ThreadLocalMap。

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

而在Thread类内部就持有该Map,也就是说其实我们在调用threadLocal.set()方法设置ThreadLocal变量的值的时候大概率其实是把值存入到了,当前线程所持有的TheadLocalMap中了。那这个变量到底是怎么存储的呢?

ThreadLocal中的一些关键方法

/**
 * 设置当前线程的 ThreadLocal 变量的值。
 * 
 * @param value 要设置的值
 */
public void set(T value) {
    // 获取当前线程
    Thread t = Thread.currentThread();
    
    // 获取当前线程的 ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    
    // 如果 map 不为空,则在 map 中设置当前线程的 ThreadLocal 变量的值
    if (map != null) {
        map.set(this, value);
    } else {
        // 如果 map 为空,则创建一个新的 ThreadLocalMap 并设置值
        createMap(t, value);
    }
}

/**
 * 创建一个新的 ThreadLocalMap 并将其关联到当前线程。
 * 
 * @param t 当前线程
 * @param firstValue 初始值
 */
void createMap(Thread t, T firstValue) {
    // 将新的 ThreadLocalMap 关联到当前线程的 threadLocals 字段
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}
/**
 * 获取当前线程的 ThreadLocal 变量的值。
 * 
 * @return 当前线程的 ThreadLocal 变量的值
 */
public T get() {
    // 获取当前线程
    Thread t = Thread.currentThread();
    
    // 获取当前线程的 ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    
    // 如果 map 不为空,则从 map 中获取当前线程的 ThreadLocal 变量的值
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            // 强制类型转换,获取存储的值
            @SuppressWarnings("unchecked")
            T result = (T) e.value;
            return result;
        }
    }
    
    // 如果 map 为空或未找到对应的 Entry,则返回初始值
    return setInitialValue();
}

/**
 * 获取指定线程的 ThreadLocalMap。
 * 
 * @param t 指定的线程
 * @return 指定线程的 ThreadLocalMap
 */
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

从这两组方法的源码中可以知道,如果在一个线程中第一次调用threadLocal.set()方法,threadLocal就会为当前线程初始化一个theadLocaLMap,然后将真正的线程隔离变量存储到threadLocalMap中。

ThreadLocal为什么会存在内存泄漏问题?

ThreadLocalMap的结构

static class ThreadLocalMap {

        /**
         * 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.
         */
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

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

        /**
         * 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;
    ......
}

ThreadLocalMap中维护的是一个Entry[]类型节点数组,一个键值对节点就是一个Entry对象。我们会发现所有的Entry节点key值都是ThreadLocal类型,原理每一个ThreadLocal变量值在ThreadLocalMap中都是以当前threadLocal对象为键,ThreadLocal变量值为值,的键值对形式存储的。并且Entry对象是实继承了WeakReference弱引用类,并且可以看到,在Entry中对key(ThreadLocal)的引用是弱引用,也就说当外界不在对TheadLocal进行引用是(也就说外部不在使用当前threadLocal变量时)那么,GC就会考虑去回收当前threadLocal,

为什么要把Entry中对ThreadLocal引用设置为弱引用呢?

讲到这里我们还需要了解ThreadLocalMap的惰性删除策略,才能真正搞明白为什么要把Entry中的TheadLocal引用设置为弱引用。

ThreadLocalMap处理hash冲突的方法

ThreadLocalMap在遇到hash冲突时使用的使用的是开放地址法(线性探测法),如下图

该方法一次探测下一个地址,直到有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。

举个例子,假设当前table的长度为16,也就是说如果计算出来key的hash值为14,如果table[14]上已经有值,并且其key与当前key不一致,那么就发生了hash冲突,这个时候将14加1得到15,取table[15]进行判断,这个时候如果还是冲突会回到0,取table[0],以此类推,直到可以插入。按照上面的描述,可以把Entry[] table看成一个环形数组。

ThreadLocalMap惰性删除策略

所谓的ThreadLocalMap惰性删除策略就是,在调用set()、get()、rehash()等方法遍历数组时发现key值为空的元素就顺带对其进行清楚。下面笔者将举例讲解set()方法的惰性删除。

/**
 * 返回下一个索引,如果当前索引是最后一个,则返回 0。
 */
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}


private void set(ThreadLocal<?> key, Object value) {
    // 获取当前线程的 ThreadLocalMap
    Entry[] tab = table;
    // 获取数组的长度
    int len = tab.length;
    // 计算 key 的哈希码,并找到对应的索引
    int i = key.threadLocalHashCode & (len - 1);

    // 遍历数组,查找与 key 相匹配的 Entry,如果存在的话就进行替换。
	//其实整个线性探测过程是这样的
	//首先探测的索引位置应该就是通过hash值与hash表掩码计算出来的索引下标。
	//这时候就会存在几种可能1.当前索引下标元素为空的话,就会跳出循环
	//2.当前索引下标元素不为空,并且k==key,那么直接就替换节点中的value值
	//3.当前索引下标元素不为空,但是k==null,那么此时我们不能判断数组中是否存在
	//与key相匹配的Entry,所以需要借助replaceStaleEntry(key, value, i);继续探寻。
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        // 获取当前 Entry 的 key
        ThreadLocal<?> k = e.get();

        // 如果找到了匹配的 key,则更新其值
        if (k == key) {
            e.value = value;
            return;
        }
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    // 如果没有找到匹配的 key,则创建一个新的 Entry 并插入到数组中
    tab[i] = new Entry(key, value);
    // 增加 size 计数器
    int sz = ++size;

    // 尝试清理一些 stale entry,并检查是否需要重新调整数组大小
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}


private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
    // 获取当前线程的 ThreadLocalMap
    Entry[] tab = table;
    // 获取数组的长度
    int len = tab.length;
    Entry e;

    // 从 staleSlot 开始向前查找,找到第一个 stale entry(即 key 为 null 的 entry)
    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len); 
         (e = tab[i]) != null; 
         i = prevIndex(i, len)) {
        if (e.get() == null) {
            slotToExpunge = i;
        }
    }

    // 从 staleSlot 开始向后查找,找到 key 或者遇到第一个 null slot
    for (int i = nextIndex(staleSlot, len); 
         (e = tab[i]) != null; 
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

        // 如果找到了匹配的 key,则交换它与 stale entry 的位置
        if (k == key) {
            e.value = value;

            // 交换两个 entry 的位置
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            // 如果 slotToExpunge 仍然是 staleSlot,更新为当前的 i
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;

            // 清理 stale entry 并可能重新调整数组大小
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        // 如果没有在向前扫描时找到 stale entry,并且当前 entry 为 null,则更新 slotToExpunge
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    // 如果没有找到匹配的 key,则在 staleSlot 创建一个新的 entry
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // 如果在当前 run 中还有其他 stale entry,清理它们
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

所以其实需要被惰性删除的元素应该是那些,key==null,之所以吧Entry中对TheadLocalMap中的的引用设置为弱引用就是为了传递一个信息,那就是外界已经不在使用该ThreadLocal变量了,ThreadLocalMap中存储的键值对可以被清除。

既然有了惰性删除策略ThreadLocal为什还是存在内存泄露问题呢?

其实这个问题,就要结合实际的应用,在大部分的应用场景中,特别是并发编程中,其实我们的线程和任务都是交由线程池进行管理的,以便于线程的复用,避免频繁创建线程找造成的性能浪费。那么线程就会被长期的驻留在线程池中不会被回收,那么Thread中引用的TheadLocalMap也就不会被主动回收,那么一但某个线程不在使用TheadLocal变量,也就不在会调用ThreadLocalMap中的set()、get()等方法,那么惰性删除策略也就失效了,那么如果我们在使用完ThreadLocal变量之后,没有调用remove()方法及时的把ThreadLocalMap中的键值对删除,并且惰性删除策略也没有及时的吧ThreadLocalMap中的键值对清空那么,这些键值对就会随着Thread的存活一直存在,导致大量占用内存,导致内存泄露问题,所以我们在使用ThreadLocal变量时一定要养成良好的习惯,在使用完ThreadLcoal变量后一定要调用remove()方法,删除ThreadLocalMap中不再使用的键值对。

好的本期内容就讲到这里,如果读者们在阅读中有什么不同地意见欢迎到评论区进行指正哈~