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

Posted 天道酬勤——傻子王(AME)

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试又被问懵了吗?不如把ThreadLocal拆开了揉碎看看相关的知识,希望对你有一定的参考价值。

前言

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 存在:

image.png

key 不存在:

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

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;
面试官:Hash 碰撞是什么?如何解决?被问懵了……

面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!

面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!

头铁面试官:一个小小的 System.out.println 硬是考了我半个小时,被问懵了。。

头铁面试官:一个小小的 System.out.println 硬是考了我半个小时,被问懵了。。...

面试官:在 Java 中 new 一个对象的流程是怎样的?彻底被问懵了。。