美文网首页程序员
面试又被问懵了吗?不如把ThreadLocal拆开了揉碎看看

面试又被问懵了吗?不如把ThreadLocal拆开了揉碎看看

作者: 程序花生 | 来源:发表于2021-02-21 16:35 被阅读0次

    前言

    1.为什么用 ThreadLocal?

    所谓并发,就是有限资源需要应对远超资源的访问。解决问题的方法,要么增加资源应对访问;要么增加资源的利用率。 所以,相信这年头做开发的多多少少,都会那么几个“线程二三招”、“用锁五六式”。 那所带来的就是多线程访问下的并发安全问题。 共享变量的访问域跨越了原始的单线程,进入了千家万户的线程眼里。谁都可以用,谁都可以改,那不就打起来了吗? 因此,防止并发问题的最好办法,就是不要多线程访问(这科技水平倒退二十年~)。ThreadLocal 顾名思义,将一个变量限制为“线程封闭”:对象只被一个线程持有、访问、修改。

    2.那到底什么是 ThreadLocal?

    ThreadLocal 如果做到线程封闭,那固然是独木难支。它必然携手 Thread 为广大 Javaer 带来福音。 ThreadLocal 自己不是存储者,它只是 Thread 的搬运工。独有变量必然是存在 Thread 中的。一般项目中多定义多个 ThreadLocal,那相应的 Thread 必然也需要存储那么多独有变量。 既然解决了线程之间的访问干扰,那一个线程的访问干扰自然就不在话下了。Thread 维护了一个 ThreadLocalMap,以“key-value”的形式存储了独有变量;以 ThreadLocal 实例为 key,精准获取。

    3.ThreadLocal 需要考虑哪些问题?

    如果线程死亡了,那 ThreadLocalMap、ThreadLocal 及独有变量都会被销毁。

    但是现在避免线程的重复创建与销毁,线程使用完都是放回线程池。而如果没有手动移除 ThreadLocalMap 的元素,即使当前线程退出,ThreadLocal 已不被线程方法栈持有,也依然无法被回收,从而造成内存泄漏。 所以 ThreadLocalMap.Entry 的 key(也就是 ThreadLocal)实际是弱引用。当没有其他强引用时,只要发生 GC,就会被回收,相当于这个时候 key 为 null。

    这又产生了一个问题,key 被回收了, entry 和 value 可还是强引用呢,怎么办? ThreadLocalMap 已经考虑了这种情况,再调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。 所以人家设计是没有问题的,如果发生内存泄漏都是用的不对。 建议使用完 ThreadLocal方法后,最好手动调用remove()方法。

    4.ThreadLocal 还需要考虑哪些问题?

    随着业务场景的复杂化,变量的线程封闭固然解决了访问的问题,但是也给线程传递带来了难度。 线程之间的协作,带来了变量在两个线程之间安全传递的需要。需要人为处理这种传递,需要三个步骤:

    • 线程 1 取出变量;
    • 线程 1 安全传递变量、ThreadLocal(其实一般选择共享)给线程 2,当心逃逸。
    • 线程 2 放到当前线程的 ThreadLocal。 这个步骤是通用的,只要存在使用 hreadLocal并且需要线程传递时,必然少不了这三步。 JDK 为我们提供了“线程 2 是线程 1 创建出来时,独有变量传递给线程 2”的解决方法:InheritableThreadLocal,Thread 中也有专门为其服务的 ThreadLocalMap。

    那我们明白,在线程池化的世面下,不会经常存在创建的场景,更多的是与已有线程的协作。 各家公司,其实也会为相关业务的 ThreadLocal 自研类库,去做到传递。 市面上解决通用场景的线程传递的类库就是 TransmittableThreadLocal。

    源码解析

    Thread

    public Class Thread implements Runnable {
    
        //与此线程有关的 ThreadLocal 值。由 ThreadLocal 类维护
        ThreadLocal.ThreadLocalMap threadLocals = null;
        // 与此线程有关的 InheritableThreadLocal 值。由 InheritableThreadLocal 类维护
        ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    

    ThreadLocalMap 是 ThreadLocal 的内部类,是定制的 Map 实现。 初始值都为 null,只有当第一次调用对应ThreadLocal的 get 或 set 时,才会初始化。

    ThreadLcoal

    ThreadLcoal 只有一个默认的无参构造函数。实际的初始化逻辑,都在第一次调用 get 或 set 时。

    get()

    由于是类似懒加载的形式,所以 get 中涉及到ThreadLocalMap的创建以及初始值设置。

    public T get() {
        Thread t = Thread.currentThread();
        // 获取线程的 map, 为啥要抽取方法呢?就是为了扩展之前提到的 InheritableThreadLocal
        ThreadLocalMap map = getMap(t);
        if (map != null) {
        // 已经 set 过
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
        @SuppressWarnings("unchecked")
        // 走到这里没有 Entry 的情况:remove 以后
        T result = (T)e.value;
        return result;
        }
        }
        // 未 set 过的第一次 get (map == null)
        // 或 set 过, 但是 remove 了 (map != null && e == null)
        return setInitialValue();
    }
    
    private T setInitialValue() {
        // 获取指定初始值, 默认是 null
        // 可以通过 withInitial(Supplier<? extends S> supplier) 工厂方法来创建指定初始化值的 ThreadLocal
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            map.set(this, value);
        } else {
            // ThreadLocalMap 未初始化
            createMap(t, value);
        }
        if (this instanceof TerminatingThreadLocal) {
            // 处理一个特殊子类的逻辑
            TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
        }
        return value;
    }   
    
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    

    指定初始值的工厂构造方法

    // 如果以下情况下的第一次 get, 判断 map 的 entry 为 null下
    // 1.从未 set 过;
    // 2.remove 过后
    protected T initialValue() {
        return null;
    }
    

    默认初始值是 null。 可以通过以下工厂方法,获取一个指定初始化逻辑的 ThreadLocal。

    public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
        return new SuppliedThreadLocal<>(supplier);
    }
    
    static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {
    
        private final Supplier<? extends T> supplier;
    
        SuppliedThreadLocal(Supplier<? extends T> supplier) {
            this.supplier = Objects.requireNonNull(supplier);
        }
    
        @Override
        protected T initialValue() {
            return supplier.get();
        }
    }
    

    set() & remove()

    set() 有点像 setInitialValue(),只不过一个是初始值,一个是指定值。

    两个方法其实本身都简单,主要依赖于 ThreadLocalMap的操作。

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value);
        }
    }
    public void remove() {
        ThreadLocalMap m = getMap(Thread.currentThread());
        if (m != null) {
            m.remove(this);
        }
    }
    

    ThreadLocalMap

    • 这个类是 ThreadLocal 的内部类,是包私有的。
    • key 的 hashcode 是自定义的增长值。
    • key 是 WeakReference 的。

    Entry

    可以看到 key 就是 ThreadLocal,肯定不为空,但也是弱引用的。

    也就是说,当 key 为 null 时,说明 ThreadLocal 已经被回收了,对应的 Entry 就应该被清除了。

    static class Entry extends WeakReference<ThreadLocal<?>> {
        /** The value associated with this ThreadLocal. */
        Object value;
    
        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    

    预设值

    • 初始容量为 16,扩容翻倍。所以容量一定为 2 的 n 次幂。
    • 负载因子是 2/3。
    • 初始化时,应该是第一次设置值,或来源于 ThreadLocalMap。所以算得上饿汉式加载。
    private static final int INITIAL_CAPACITY = 16;
    private Entry[] table;
    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);
    }
    
    ThreadLocal {
        /**
         * 人为设置的 hash code 分布. 对于在相同线程中使用连续构造的 ThreadLocal, 可以有效避免冲突.
         * 因为是可以预见的场景, 仅在 ThreadLocalMap 中使用.
         */
        private final int threadLocalHashCode = nextHashCode();
    
        /**
         * The next hash code to be given out. Updated atomically. Starts at
         * zero.
         */
        private static AtomicInteger nextHashCode =  new AtomicInteger();
    
        private static final int HASH_INCREMENT = 0x61c88647;
        private static int nextHashCode() {
            return nextHashCode.getAndAdd(HASH_INCREMENT);
        }
    }
    

    每个 ThreadLocal 对象都有一个 hash 值 threadLocalHashCode,每初始化一个 ThreadLocal 对象,hash 值就增加一个固定的大小 0x61c88647。这个东西比较讲究,有兴趣可以自行研究一下。

    set()

    private void set(ThreadLocal<?> key, Object value) {
    
        Entry[] tab = table;
        int len = tab.length;
        int i = key.threadLocalHashCode & (len-1);
    
        for (Entry e = tab[i];
             e != null;
             // 开放定址法: 索引位置 + 1
             e = tab[i = nextIndex(i, len)]) {
            ThreadLocal<?> k = e.get();
    
            if (k == key) {
                e.value = value;
                return;
            }
    
            if (k == null) {
                // key 为空, 说明 对应的 ThreadLocal 已经回收了.
                // 可以复用当前位置.
                // 有两种情况:1\. entry 存在, 在这个过时位置的后面. 所以需要置换到这个位置
                // 2.不存在, 直接放到这个位置
                replaceStaleEntry(key, value, i);
                // 因为是替换, 所以size 要么不变,要么减少。
                return;
            }
        }
    
        // 没找到已存在的, 也没找到可以替换的过时. 则直接新建
        tab[i] = new Entry(key, value);
        int sz = ++size;
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            // 如果没有清除过时 entry, 并且超过阈值. 则进行先尝试缩小,不行则扩容
            rehash();
    }
    

    类中定义了两个方法用于开放定址法的查找:增量为 1。

    private static int prevIndex(int i, int len) {
        return ((i - 1 >= 0) ? i - 1 : len - 1);
    }
    
    private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
    }
    

    replaceStaleEntry()

    replaceStaleEntry() 比较复杂。一是需要清除过时 entry,二是开放定址法要保证所计算出的索引值后面的元素连续性。

    所以,replaceStaleEntry() 会检查当前可替换位置的前后最近的两个空档之间所有的过时 entry。

    其次,如果是 key 已存在过时位置的后面,那原有位置替换后会留出空档,需要后面的 entry 都往前挪一位(空档前的)。

    private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                   int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
        Entry e;
    
        int slotToExpunge = staleSlot;
        // 1.往前查找第一个空档后的最小过时
        for (int i = prevIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = prevIndex(i, len))
            if (e.get() == null)
                slotToExpunge = i;
    
        // 往前查找第一个空档前的 key 或 最大过时
        for (int i = nextIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();
    
            // 找到对应的 entry
            if (k == key) {
                e.value = value;
                // 2.将key 与原位置的过时替换
                tab[i] = tab[staleSlot];
                tab[staleSlot] = e;
    
                if (slotToExpunge == staleSlot)
                    // 3.如果前面都没有过时的话,那这个区间的第一个过时就是原来的staleSlot, 现在的 i
                    slotToExpunge = i;
                // 4.清理过时, 挪移 entry
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                return;
            }
    
            // 5.如果前面没有空槽, 且有新的过时, 则重新标记第一个过时.(因为staleSlot一定会被替换成不过时的,到时候就不是第一个过时点了)
            if (k == null && slotToExpunge == staleSlot)
                slotToExpunge = i;
        }
        // 6.直接替换
        tab[staleSlot].value = null;
        tab[staleSlot] = new Entry(key, value);
    
        // slotToExpunge == staleSlot, 说明当前区间只有这个过时, 已经被替换了, 所以不需要再进行清除
        if (slotToExpunge != staleSlot)
            // key 本不在, 且前或后存在其他的过时
            // 7.清理过时, 挪移 entry
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
    }
    

    5、7 由于是清理过时,后面再详细说明。

    区间是当前过时位置staleSlot前后第一个空位所组成的范围,即下图两个空白格子之间。

    我们根据区间的不同情况,做了图例说明。

    key 存在:

    key 不存在:

    rehash()

    当 set() 完,数量到达阈值,是先尝试能不能删掉一些过时的。如果删无可删,或者删完之后达不到标准,则扩容。

    注意的是,这个标准不是之前的 threshold,而是 3/4 threshold,避免滞后性。

    private void rehash() {
        // 对整个数组进行扫描,清理.
        // 而不像替换那步, 只扫描区间
        expungeStaleEntries();
    
        // Use lower threshold for doubling to avoid hysteresis
        if (size >= threshold - threshold / 4)
            resize();
    }
    
    private void resize() {
        Entry[] oldTab = table;
        int oldLen = oldTab.length;
                // 翻倍扩容
        int newLen = oldLen * 2;
        Entry[] newTab = new Entry[newLen];
        int count = 0;
    
        for (Entry e : oldTab) {
            if (e != null) {
                ThreadLocal<?> k = e.get();
                if (k == null) {
                    // 发现过时, 则抛弃
                    e.value = null; // Help the GC
                } else {
                    // 重新 hash
                    int h = k.threadLocalHashCode & (newLen - 1);
                    while (newTab[h] != null)
                        h = nextIndex(h, newLen);
                    newTab[h] = e;
                    count++;
                }
            }
        }
    
        setThreshold(newLen);
        size = count;
        table = newTab;
    }
    
    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);
        }
    }
    

    expungeStaleEntry()

    从上面的分析可以看到,该方法应用在 replaceStaleEntry 和 expungeStaleEntries。

    replaceStaleEntry是对区间进行处理, expungeStaleEntries是对全数组。所以expungeStaleEntry(int)就是上述处理的一个子集。这样理解下来,就是清理指定位置到下一个空位之间的过时 entry,包含指定位置:[index, indexOf(first null))。

    • index 一定是一个过时元素的位置。

    • 既然过时的会被清除,那中间就会留出空位。开放定址法是要求连续的,所以重新计算索引来放置。

    • 注意:保留的 key 是重新计算索引, 而不是简单地往前挪一位。

    • 这是因为清除区间的过时,是在某个 key 与运算出的起始索引之前。

    • 而 key 刚好在这个索引上,简单往前挪一位,下次查找可能就找不到了。

    • 因为要求连续性地从头遍历到尾,一旦中间出现空位,就找不到了。

    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;
        // 开始遍历直到遇到第一个空位
        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;
    }
    

    cleanSomeSlots()

    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];
            // 没扫到任何过时,共扫描 log2(n) 个槽;
            if (e != null && e.get() == null) {
                // 上述期间扫到过时,则将该区间遍历:
                // 然后基于区间终点,重新扫描 log2(length);
                // 如果扫到,重复上面;
                // 如果一直重复,最终扫描了全数组。
                n = len;
                removed = true;
                i = expungeStaleEntry(i);
            }
        } while ( (n >>>= 1) != 0);
        return removed;
    }
    

    cleanSomeSlots 一般在新增一个元素或删除另一个旧元素(不是 remove,而是 set 时刚好删掉另一个过时的后),进行扫描或清除。

    起始位置是一个元素不是过时的索引,是扫描完一个区间后的终点(空位)或新增元素的位置。

    终点的话,因为使用的是对数扫描,是两个极端情况的平衡:

    • 没扫到任何过时,共扫描 log2(n) 个槽;

    • 上述期间扫到过时,则将该区间遍历;

    • 然后基于区间终点,重新扫描 log2(length);

    • 如果扫到,重复上面;

    • 如果一直重复,最终扫描了全数组。

    get & remove

    get:

    • 如果直接找到,则返回;
    • 如果没有,在开放定址法的增量下,遍历查找。而这个过程,还需要兼职清除区间内的过时(expungeStaleEntry(int))。

    remove:

    找到指定的 key, 清除完,同样兼职清除一下区间内的。

    内存泄漏

    经过上述的分析,由于 key 也就是 ThreadLocal 在 Entry 中是 WeakReference 的。

    ThreadLocal 在没有外部强引用时,发生 GC 的话,ThreadLocalMap的弱引用将不会影响回收。

    那相当于 Entry 中的 key = null,可是 Entry 和 Value 都是强引用,是无法跟随着 key 一起被销毁的。

    想想 ThreadLocal 的作用,当 ThreadLocal 都被销毁了,那 key-value 的存储就没有意义了。

    如果等到兼职任务去清除过时,也是存在时间差的,在 value 是大对象的时候,也是较为麻烦的。

    所以建议

    当使用完退出时,最好使用ThreadLocal.remove()方法将该变量主动移除。

    InheritableThreadLocal

    当线程 2 是从 线程 1 创建的时候,可以指定是否从线程 1 继承 ThreadLocal。当然,前提是线程 1 使用了可以被继承的 InheritableThreadLocal。

    private Thread(ThreadGroup g, Runnable target, String name,
                   long stackSize, AccessControlContext acc,
                   boolean inheritThreadLocals) {
        ...... 省略
        Thread parent = currentThread();
        // parent.inheritableThreadLocals 不为空, 要当前线程必须使用 InheritableThreadLocal 
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            this.inheritableThreadLocals =
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        ...... 省略
    }
    

    ThreadLocal :

    static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
        return new ThreadLocalMap(parentMap);
    }
    // 只用于 createInheritedMap
    private ThreadLocalMap(ThreadLocalMap parentMap) {
        Entry[] parentTable = parentMap.table;
        int len = parentTable.length;
        setThreshold(len);
        table = new Entry[len];
    
        for (Entry e : parentTable) {
            if (e != null) {
                ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
                if (key != null) {
                    // 从父类那计算子类值, 默认是一样的
                    Object value = key.childValue(e.value);
                    Entry c = new Entry(key, value);
                    int h = key.threadLocalHashCode & (len - 1);
                    while (table[h] != null)
                        // hash 冲突处理方法是,开放定址法
                        h = nextIndex(h, len);
                    table[h] = c;
                    size++;
                }
            }
        }
    }
    

    InheritableThreadLocal:

    可以看到使用 InheritableThreadLocal,操作的 Thread的变量是不同于 ThreadLocal。

    刚好对应了上面创建 Thread,继承父线程的 inheritableThreadLocals。

    public class InheritableThreadLocal<T> extends ThreadLocal<T> {
    
        protected T childValue(T parentValue) {
            // 从父类值计算子类值, 可以重写
            return parentValue;
        }
    
        ThreadLocalMap getMap(Thread t) {
            // 获取的 map 不同
           return t.inheritableThreadLocals;
        }
    
        void createMap(Thread t, T firstValue) {
            // 使用的 map 不同
            t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
        }
    }
    

    作者:SaltyFishInJiang
    链接:https://juejin.cn/post/6931230924549914637
    来源:掘金

    相关文章

      网友评论

        本文标题:面试又被问懵了吗?不如把ThreadLocal拆开了揉碎看看

        本文链接:https://www.haomeiwen.com/subject/tmyqfltx.html