ThreadLocal 原来这么简单

862 阅读8分钟
  • 一、简介

  • 二、应用场景

      1. MDC 实现
      1. 传递上下文
      1. 线程安全隔离
  • 三、数据结构

      1. ThreadLocal
      1. ThreadLocalMap

      • 2.1 简介
      • 2.2 hash 冲突处理
      • 2.3 API 实现原理
      • 2.3.1 set()
      • 2.3.2 getEntry()
      • 2.3.3 cleanSomeSlots()
      • 2.3.4 replaceStaleEntry()
      • 2.3.5 getEntryAfterMiss()
      • 2.3.6 expungeStaleEntry()
  • 四、子线程共享 ThreadLocalMap

      1. Thread 的支持
      1. InheritableThreadLocal 实现
  • 五、内存泄漏与引用级别

      1. 快照示例
      1. 内存泄露

一、简介

从 JDK 对其的注释不难了解到, ThreadLocal 是一个用于提供线程私有变量的解决方案,即提供变量的线程间隔离。如今 ThreadLocal 已经被广泛应用于各类开源技术框架和由研发人员自行实现的业务解决方案,对其建立正确的认识有助于我们更加准确、高效的使用它。

这里列举几个相关的类来进行区分并加以说明:Thread、ThreadLocal、ThreadLocalMap。

Thread: 线程对象,每个线程内部都会持有一个初始值为 null 的 ThreadLocalMap 实例。

ThreadLocal: ThreadLocal 本 cal (●ˇ∀ˇ●),其自身并不负责对任何数据的存储,仅仅作为一个 key 来使用。

ThreadLocalMap: 真正用于实现存储的数据结构。

二、应用场景

  1. MDC 实现

MDC 作为线程级数据的持有工具被大量使用在 Slf4j 的各类实现框架中,其内部真正用于存储的数据结构就是 ThreadLocal。基于 ThreadLocal 为 MDC 提供的特性,MDC 也常常被用作各类埋点、链路追踪等场景。

  1. 传递上下文

ThreadLocal 可以很好得作为一个线程相关的、跨方法的存储工具在业务中使用。比如通过切面实现针对入参或 token 的解析来计算用户或业务信息并放入 ThreadLocal 中,这样在当前 request 开启线程中访问的任意位置都可以取出这些信息(通常会基于 ThreadLocal 来封装实现一个 Util 类),避免了大量共用参数通过方法签名层层传递。

  1. 线程安全隔离

针对线程不安全的工具实现,在不能彻底更换为线程安全的依赖时(通常出于存量业务依赖的原因)使用 ThreadLocal 来进行包装通常可以很好的解决线程安全问题,例如 SimpleDateFormat 等。

三、数据结构

1. ThreadLocal

ThreadLocal 自身不负责存储数据,也不会实现过多细节,几乎所有 API 都委托给内部的 ThreadLocalMap 来实现。抛开将自身作为 key 这一点,ThreadLocal 与 ThreadLocalMap 的关系与 ReentrantLock 和 AQS 十分相似。

2. ThreadLocalMap

2.1 简介

内部持有一个 Entry 数组,由 Thread 实例进行管理(这也是实现线程隔离的关键,多个 Thread 之间的 ThreadLocalMap 相互隔离),以 ThreadLocal 为 key,存入为 value。值得一提的是内部的 Entry 实现继承自 WeakReference,其对 key 的引用类型是弱引用。

static class ThreadLocalMap {



    private Entry[] table;



    static class Entry extends WeakReference<ThreadLocal<?>> {

        /** The value associated with this ThreadLocal. */

        Object value;



        Entry(ThreadLocal<?> k, Object v) {

            super(k);

            value = v;

        }

    }

}

2.2 hash 冲突处理

既然是散列表,那存储数据时绝大部分实现都会通过 hashCode & (len - 1) 来确定下标,ThreadLocalMap 也不例外。当发生哈希冲突时,该运算将计算出相同下标,而 ThreadLocalMap 又没有 HashMap 中的链表实现,因此不能通过链表追加节点的方式来解决,而是通过开放地址法,即当前下标已存储某个元素时,顺延至下一位,若下一位也有元素,则继续顺延以此类推。

因此最终决定元素存储索引位的因素有两条:

  • 位运算结果
  • 冲突导致的顺延

2.3 API 实现原理

先贴一张内部 API 之间的调用关系图:

可以看出 ThreadLocalMap 并不是只有在调用 remove() 时才会清理脏 entry,而是将这一动作分散在了所有存取操作中,这种分而治之的思想同样使用在 redis 中的 hashtable 扩容过程当中,可以有效避免清理耗时过长。

2.3.1 set()
private void set(ThreadLocal<?> key, Object value) {

    // 1. 计算存储位置

    Entry[] tab = table;

    int len = tab.length;

    int i = key.threadLocalHashCode & (len-1);

    // 2. 开放地址法,以计算出的索引为起点寻找遍历,直到可以分配新 entry

    for (Entry e = tab[i];

         e != null;

         e = tab[i = nextIndex(i, len)]) {

        ThreadLocal<?> k = e.get();

        // 2.1 持有同 key 替换 value

        if (k == key) {

            e.value = value;

            return;

        }

        // 2.2 脏 entry 处理

        if (k == null) {

            replaceStaleEntry(key, value, i);

            return;

        }

    }

    tab[i] = new Entry(key, value);

    int sz = ++size;

    // 3. 脏 entry 检测 & 扩容

    if (!cleanSomeSlots(i, sz) && sz >= threshold)

        rehash();

}
2.3.2 getEntry()
private Entry getEntry(ThreadLocal<?> key) {

    // 1. 计算元素位置

    int i = key.threadLocalHashCode & (table.length - 1);

    Entry e = table[i];

    if (e != null && e.get() == key)

        return e;

    else

        // 2. 未命中委托 getEntryAfterMiss()

        return getEntryAfterMiss(key, i, e);

}
2.3.3 cleanSomeSlots()

批量检测并释放脏 entry:

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 释放

        if (e != null && e.get() == null) {

            n = len;

            removed = true;

            i = expungeStaleEntry(i);

        }

        // log2(n) != 0,控制循环次数

    } while ( (n >>>= 1) != 0);

    return removed;

}
2.3.4 replaceStaleEntry()

set() => hash 冲突 => 顺延发现脏 entry => replaceStaleEntry() 复用脏 entry 索引位:

private void replaceStaleEntry(ThreadLocal<?> key, Object value,

                                       int staleSlot) {

    Entry[] tab = table;

    int len = tab.length;

    Entry e;

    int slotToExpunge = staleSlot;

    // 逆向遍历,若发现新的脏 entry 保存索引位

    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 分支

        if (k == key) {

            // 替换,把脏 entry 挪到后面,把后面持有同 key 的 entry 挪到当前索引位

            e.value = value;

            tab[i] = tab[staleSlot];

            tab[staleSlot] = e;

            // 逆向未发现新脏 entry,从当前节点开始检测并释放 entry

            if (slotToExpunge == staleSlot)

                slotToExpunge = i;

            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);

            return;

        }

        if (k == null && slotToExpunge == staleSlot)

            slotToExpunge = i;

    }

    // 正向遍历未找到持有相同 key 的 entry,重置当前索引位的脏 key 并存入新的 key-value

    tab[staleSlot].value = null;

    tab[staleSlot] = new Entry(key, value);

    // 逆向发现新脏 entry,从新节点开始批量检测并释放脏 entry

    if (slotToExpunge != staleSlot)

        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);

}
2.3.5 getEntryAfterMiss()

用于查询 hash 冲突后发生顺延的 entry,顺带清理一波脏 entry:

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {

    Entry[] tab = table;

    int len = tab.length;

    // 以当前 entry 为起点,向后遍历寻找持有相同 key 的 entry 返回

    while (e != null) {

        ThreadLocal<?> k = e.get();

        if (k == key)

            return e;

        if (k == null)

            // 脏 entry,释放

            expungeStaleEntry(i);

        else

            i = nextIndex(i, len);

        e = tab[i];

    }

    return null;

}
2.3.6 expungeStaleEntry()

小批量的脏 entry 处理,小步快跑,分而治之:

private int expungeStaleEntry(int staleSlot) {

    // 将目标索引位上的 entry 释放

    Entry[] tab = table;

    int len = tab.length;

    tab[staleSlot].value = null;

    tab[staleSlot] = null;

    size--;



    // 连带处理一个周期的 entry(以 null 为周期结束标识)

    Entry e;

    int i;

    for (i = nextIndex(staleSlot, len);

         (e = tab[i]) != null;

         i = nextIndex(i, len)) {

        ThreadLocal<?> k = e.get();

        // 脏 entry 释放

        if (k == null) {

            e.value = null;

            tab[i] = null;

            size--;

        } else {

            // rehash

            int h = k.threadLocalHashCode & (len - 1);

            // 新旧索引位不同,说明这个索引位下的 entry 是顺延来的

            if (h != i) {

                // 将当前 entry 挪动到新索引位中,仍然使用开放地址法处理冲突

                tab[i] = null;

                while (tab[h] != null)

                    h = nextIndex(h, len);

                tab[h] = e;

            }

        }

    }

    return i;

}

四、子线程共享 ThreadLocalMap

1. Thread 的支持

其实在 Thread 中,还会同时持有另一个 ThreadLocalMap 引用 inheritableThreadLocals,来对 InheritableThreadLocal 的实现提供支持。

public class Thread implements Runnable {



    ThreadLocal.ThreadLocalMap threadLocals = null;

    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;



}

2. InheritableThreadLocal 实现

如果将 ThreadLocal 视作开发者对于操作当前线程隔离变量的 API 的话,那么想要在子线程中操作父线程的隔离变量要使用另一个 API:InheritableThreadLocal。

public class InheritableThreadLocal<T> extends ThreadLocal<T> {



    protected T childValue(T parentValue) {

        return parentValue;

    }



    ThreadLocalMap getMap(Thread t) {

       return t.inheritableThreadLocals;

    }



    void createMap(Thread t, T firstValue) {

        t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);

    }

}

由于继承了 ThreadLocal,因此 InheritableThreadLocal 将持有 ThreadLocal 的 API 实现,其中包括 set()、get()、remove() 等。在使用 InheritableThreadLocal.set() 时,会优先通过 getMap() 来获取 ThreadLocalMap 实例,InheritableThreadLocal 通过覆写 getMap() 来实现从获取 Thread.threadLocals 到获取 Thread.inheritableThreadLocals 的转变。

当 getMap() 返回的 ThreadLocalMap 实例为空时,调用 createMap() 来进行分配,InheritableThreadLocal 同样覆写了它的实现,调整为了 ThreadLocal 为 InheritableThreadLocal 单独提供的构造函数。

private ThreadLocalMap(ThreadLocalMap parentMap) {

    Entry[] parentTable = parentMap.table;

    int len = parentTable.length;

    setThreshold(len);

    table = new Entry[len];



    for (int j = 0; j < len; j++) {

        Entry e = parentTable[j];

        if (e != null) {

            @SuppressWarnings("unchecked")

            ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();

            if (key != null) {

                // 1

                Object value = key.childValue(e.value);

                Entry c = new Entry(key, value);

                int h = key.threadLocalHashCode & (len - 1);

                while (table[h] != null)

                    h = nextIndex(h, len);

                table[h] = c;

                size++;

            }

        }

    }

}

这个构造函数其实非常简单,就是对 ThreadLocalMap 的一个深拷贝实现,不过其中流程1处为子类留了一个 Hook 的口子(ThreadLocal 对 childValue() 的实现是直接抛出异常,即该 api 仅支持子类覆写使用),也就是说在对键值对进行 copy 的同时,子类可以以一种类似切面的方式对所有的 value 进行一层包装,通过观察源码可以看到 InheritableThreadLocal 没有多余的实现,只是将入参返回。

总结: InheritableThreadLocal 通过覆写父类 API 实现中扩展点的方式(类似于模板模式)使父类的 API 兼容对 ThreadLocalMap 副本(即 inheritableThreadLocals)的操作。但是要注意 InheritableThreadLocal 与 ThreadLocal 本质上操作的是两个不同的 ThreadLocalMap 实例,在堆中是两块相互独立的空间。

五、内存泄漏与引用级别

1. 快照示例

某 JVM 进程中正在使用的一个 ThreadLocal 对象内部引用链示意如下(虚线代表弱引用):

2. 内存泄露

使用 ThreadLocal 存在引发内存泄漏的风险,根本原因在于其内部 Entry 对于 key 和 value 使用了不同的引用级别。因为弱引用与强用的回收时机不同,因此一定会存在某一时刻,某 entry 中 key 被回收,而 value 依然存在。其中 value 的强引用链随着 entry[] 可以一直追溯到属于 GCRoot 的 Thread 对象,因此 value 的生命周期与当前线程保持一致,在使用线程池时线程大概率会被多次复用,这将大大推迟这些“幽灵” value 的回收时间,造成内存泄漏;严重时甚至引发 OOM,释放整条线程的所有资源造成损失。

为了避免可能出现的内存泄漏问题,在一次业务流程结束时应使用 ThreadLocal.remove() 对键值对进行清理:

try{

    threadLocal.set();

} finally {

    threadLocal.remove();

}

参考资料