ThreadLocal到底有没有内存泄漏?从源码角度来剖析一波

1. 前言

ThreadLocal 也是一个使用频率较高的类,在框架中也经常见到,比如 Spring。

有关 ThreadLocal 源码分析的文章不少,其中有个问题常被提及:ThreadLocal 是否存在内存泄漏?

不少文章对此讲述比较模糊,经常让人看完脑子还是一头雾水,我也有此困惑。因此找时间跟小伙伴讨论了一番,总算对这个问题有了一定的理解,这里记录和分享一下,希望对有同样困惑的朋友们有所帮助。当然,若有理解不当的地方也欢迎指正。

啰嗦就到这里,下面先从 ThreadLocal 的一个应用场景开始分析吧。

2. 应用场景

ThreadLocal 的应用场景不少,这里举个简单的栗子:单点登录拦截。

也就是在处理一个 HTTP 请求之前,判断用户是否登录:

  • 若未登录,跳转到登录页面;
  • 若已登录,获取并保存用户的登录信息。

先定义一个 UserInfoHolder 类保存用户的登录信息,其内部用 ThreadLocal 存储,示例如下:

    public static void set(Map<String, String> map) {
        USER_INFO_THREAD_LOCAL.set(map);
    }

    public static Map<String, String> get() {
        return USER_INFO_THREAD_LOCAL.get();
    }

    public static void clear() {
        USER_INFO_THREAD_LOCAL.remove();
    }

    // ...
}` 

通过 UserInfoHolder 可以存储和获取用户的登录信息,以便在业务中使用。

Spring 项目中,如果我们想在处理一个 HTTP 请求之前或之后做些额外的处理,通常定义一个类继承 HandlerInterceptorAdapter,然后重写它的一些方法。举例如下(仅供参考,省略了一些代码):

`public class LoginInterceptor extends HandlerInterceptorAdapter {

    // ...

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {

        // ...

        // 请求执行前,获取用户登录信息并保存
        Map<String, String> userInfoMap = getUserInfo();

        UserInfoHolder.set(userInfoMap);

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 请求执行后,清理掉用户信息
        UserInfoHolder.clear();
    }
}` 

在本例中,我们在处理一个请求之前获取用户的信息,在处理完请求之后,将用户信息清空。应该有朋友在框架或者自己的项目中见过类似代码。

下面我们深入 ThreadLocal 的内部,来分析这些方法做了些什么,跟内存泄漏又是怎么扯上关系的。

3. 源码剖析

3.1 类签名

先从头开始,也就是类签名:

`public class ThreadLocal<T> {
}`

可见它就是一个普通的类,并没有实现任何接口、也无父类继承。

3.2 构造器

ThreadLocal 只有一个无参构造器:

public ThreadLocal() {
}` 

此外,JDK 1.8 引入了一个使用 lambda 表达式初始化的静态方法 withInitial,如下:

`public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
    return new SuppliedThreadLocal<>(supplier);
}` 

该方法也可以初始化一个对象,和构造器也比较接近。

3.3 ThreadLocalMap

3.3.1 主要代码

ThreadLocalMap 是 ThreadLocal 的一个内部嵌套类。

由于 ThreadLocal 的主要操作实际都是通过 ThreadLocalMap 的方法实现的,因此先分析 ThreadLocalMap 的主要代码:

`public class ThreadLocal<T> {
    // 生成 ThreadLocal 的哈希码,用于计算在 Entry 数组中的位置
    private final int threadLocalHashCode = nextHashCode();

    private static final int HASH_INCREMENT = 0x61c88647;

    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

    // ...

    static class ThreadLocalMap {

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

        // 初始容量,必须是 2 的次幂
        private static final int INITIAL_CAPACITY = 16;

        // 存储数据的数组
        private Entry[] table;

        // table 中的 Entry 数量
        private int size = 0;

        // 扩容的阈值
        private int threshold; // Default to 0

        // 设置扩容阈值
        private void setThreshold(int len) {
            threshold = len * 2 / 3;
        }    

        // 第一次添加元素使用的构造器
        ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            table = new Entry[INITIAL_CAPACITY];
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
            table[i] = new Entry(firstKey, firstValue);
            size = 1;
            setThreshold(INITIAL_CAPACITY);
        }

        // ...
    }
}` 

ThreadLocalMap 的内部结构其实跟 HashMap 很类似,可以对比前面「JDK源码分析-HashMap(1)」对 HashMap 的分析。

二者都是「键-值对」构成的数组,对哈希冲突的处理方式不同,导致了它们在结构上产生了一些区别:

  1. HashMap 处理哈希冲突使用的「链表法」。也就是当产生冲突时拉出一个链表,而且 JDK 1.8 进一步引入了红黑树进行优化。
  2. ThreadLocalMap 则使用了「开放寻址法」中的「线性探测」。即,当某个位置出现冲突时,从当前位置往后查找,直到找到一个空闲位置。

其它部分大体是类似的。

3.3.2 注意事项
  • 弱引用

有个值得注意的地方是:ThreadLocalMap 的 Entry 继承了 WeakReference 类,也就是弱引用类型。

跟进 Entry 的父类,可以看到 ThreadLocal 最终赋值给了 WeakReference 的父类 Reference 的 referent 属性。即,可以认为 Entry 持有了两个对象的引用:ThreadLocal 类型的「弱引用」和 Object 类型的「强引用」,其中 ThreadLocal 为 key,Object 为 value。如图所示:

image

ThreadLocal 在某些情况可能产生的「内存泄漏」就跟这个「弱引用」有关,后面再展开分析。

  • 寻址

Entry 的 key 是 ThreadLocal 类型的,它是如何在数组中散列的呢?

ThreadLocal 有个 threadLocalHashCode 变量,每次创建 ThreadLocal 对象时,这个变量都会增加一个固定的值 HASH_INCREMENT,即 0x61c88647,这个数字似乎跟黄金分割、斐波那契数有关,但这不是重点,有兴趣的朋友可以去深入研究下,这里我们知道它的目的就行了。与 HashMap 的 hash 算法的目的近似,就是为了散列的更均匀。

下面分析 ThreadLocal 的主要方法实现。

3.4 主要方法

ThreadLocal 主要有三个方法:set、get 和 remove,下面分别介绍。

3.4.1 set 方法
  • set 方法:新增/更新 Entry
public void set(T value) {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 从 Thread 中获取 ThreadLocalMap
    ThreadLocalMap map = getMap(t);

    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

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

threadLocals 是 Thread 持有的一个 ThreadLocalMap 引用,默认是 null:

`public class Thread implements Runnable {
    // 其他代码...
    ThreadLocal.ThreadLocalMap threadLocals = null;
}` </pre>
  • 执行流程

若从当前 Thread 拿到的 ThreadLocalMap 为空,表示该属性并未初始化,执行 createMap 初始化:

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}` 

若已存在,则调用 ThreadLocalMap 的 set 方法:

private void set(ThreadLocal<?> key, Object value) {    
    Entry[] tab = table;
    int len = tab.length;
    // 1. 计算 key 在数组中的下标 i
    int i = key.threadLocalHashCode & (len-1);

    // 1.1 若数组下标为 i 的位置有元素
    // 判断 i 位置的 Entry 是否为空;不为空则从 i 开始向后遍历数组
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        // 索引为 i 的元素就是要查找的元素,用新值覆盖旧值,到此返回
        if (k == key) {
            e.value = value;
            return;
        }

        // 索引为 i 的元素并非要查找的元素,且该位置中 Entry 的 Key 已经是 null
        // Key 为 null 表明该 Entry 已经过期了,此时用新值来替换这个位置的过期值
        if (k == null) {
            // 替换过期的 Entry,
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    // 1.2 若数组下标为 i 的位置为空,将要存储的元素放到 i 的位置
    tab[i] = new Entry(key, value);
    int sz = ++size;
    // 若未清理过期的 Entry,且数组的大小达到阈值,执行 rehash 操作
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}` 

先总结下 set 方法主要流程:

首先根据 key 的 threadLocalHashCode 计算它的数组下标:

  1. 如果数组下标的 Entry 不为空,表示该位置已经有元素。由于可能存在哈希冲突,因此这个位置的元素可能并不是要找的元素,所以遍历数组去比较

  2. 如果找到等于当前 key 的 Entry,则用新值替换旧值,返回。

  3. 如果遍历过程中,遇到 Entry 不为空、但是 Entry 的 key 为空的情况,则会做一些清理工作。

  4. 如果数组下标的 Entry 为空,直接将元素放到这里,必要时进行扩容。

  • replaceStaleEntry:替换过期的值,并清理一些过期的 Entry
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    // 从 staleSlot 开始向前遍历,若遇到过期的槽(Entry 的 key 为空),更新 slotToExpunge
    // 直到 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 相等的 Entry,更新旧值,并将二者换位置
    // 目的是把它放到「应该」在的位置
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

        if (k == key) {
            // 更新旧值
            e.value = value;

            // 换位置
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            // Start expunge at preceding stale entry if it exists
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    // If key not found, put new entry in stale slot
    // 若未找到 key,说明 Entry 此前并不存在,新增
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // If there are any other stale entries in run, expunge them
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}` 

replaceStaleEntry 的主要执行流程如下:

  1. 从 staleSlot 向前遍历数组,直到 Entry 为空时停止遍历。这一步的主要目的是查找 staleSlot 前面过期的 Entry 的数组下标 slotToExpunge。

  2. 从 staleSlot 向后遍历数组

  3. 若 Entry 的 key 与给定的 key 相等,将该 Entry 与 staleSlot 下标的 Entry 互换位置。目的是为了让新增的 Entry 放到它「应该」在的位置。

  4. 若找不到相等的 key,说明该 key 对应的 Entry 不在数组中,将新值放到 staleSlot 位置。该操作其实就是处理哈希冲突的「线性探测」方法:当某个位置已被占用,向后探测下一个位置。

  5. 若 staleSlot 前面存在过期的 Entry,则执行清理操作。

PS: 所谓 Entry「应该」在的位置,就是根据 key 的 threadLocalHashCode 与数组长度取余计算出来的位置,即 k.threadLocalHashCode & (len - 1) ,或者哈希冲突之后的位置,这里只是为了方便描述。

  • expungeStaleEntry:清理过期的 Entry
`// staleSlot 表示过期的槽位(即 Entry 数组的下标)
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // 1. 将给定位置的 Entry 置为 null
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // Rehash until we encounter null
    Entry e;
    int i;
    // 遍历数组
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        // 获取 Entry 的 key
        ThreadLocal<?> k = e.get();
        if (k == null) {
            // 若 key 为 null,表示 Entry 过期,将 Entry 置空
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            // key 不为空,表示 Entry 未过期
            // 计算 key 的位置,若 Entry 不在它「应该」在的位置,把它移到「应该」在的位置
            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;
}` 

该方法主要做了哪些工作呢?

  1. 清空给定位置的 Entry

  2. 从给定位置的下一个开始向后遍历数组

  3. 若遇到 Entry 为 null,结束遍历

  4. 若遇到 key 为空的 Entry(即过期的),就将该 Entry 置空

  5. 若遇到 key 不为空的 Entry,而且经过计算,该 Entry 并不在它「应该」在的位置,则将其移动到它「应该」在的位置

  6. 返回 staleSlot 后面的、Entry 为 null 的索引下标

  • cleanSomeSlots:清理一些槽(Slot)
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];
        // Entry 不为空、key 为空,即 Entry 过期
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            // 清理 i 后面连续过期的 Entry,直到 Entry 为 null,返回该 Entry 的下标
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}` 

该方法做了什么呢?从给定位置的下一个开始扫描数组,若遇到 key 为空的 Entry(过期的),则清理该位置及其后面过期的槽。

值得注意的是,该方法循环执行的次数为 log(n)。由于该方法是在 set 方法内部被调用的,也就是新增/更新时:

  1. 如果不扫描和清理,set 方法执行速度很快,但是会存在一些垃圾(过期的 Entry);
  2. 如果每次都扫描清理,不会存在垃圾,但是插入性能会降低到 O(n)。

因此,这个次数其实就一种平衡策略:Entry 数组较小时,就少清理几次;数组较大时,就多清理几次。

  • rehash:调整 Entry 数组
`private void rehash() {
    // 清理数组中过期的 Entry
    expungeStaleEntries();
    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
        resize();
}

// 从头开始清理整个 Entry 数组
private void expungeStaleEntries() {
    Entry[] tab = table;
    int len = tab.length;
    for (int j = 0; j < len; j++) {
        Entry e = tab[j];
        if (e != null && e.get() == null)
            expungeStaleEntry(j);
    }
}` 

该方法主要作用:

  1. 清理数组中过期的 Entry
  2. 若清理后 Entry 的数量大于等于 threshold 的 3/4,则执行 resize 方法进行扩容
  • resize 方法:Entry 数组扩容
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值