ThreadLocal · 源码解读

        第一次知道ThreadLocal是在看Looper源码的时候知道的,那时候只知道它的作用是让数据在各个线程单独保持一份,互不干扰,也一直没有去研究它的具体实现。昨天下班前粗略地看了一遍,我心里想的是“这玩意儿真的是太麻烦了,要是我的话,直接在线程里维护一个Object数组就能实现这个功能啊”。然后下了班回到家,我又仔仔细细的看了一遍,果然大佬还是你大佬,我还是太天真了。

ThreadLocal · 源码解读_第1张图片

        在正式读代码前先简单介绍ThreadLocal的实现方式。每个线程都会有一个ThreadLocalMap,只有在使用到ThreadLocal的时候才会初始化ThreadLocalMap。需要存储的对象T会被放到Entry里面存储在ThreadLocalMap的数组中,Entry是一个键值对的数据结构,ThreadLocal实例为key,T为value。在使用的过程中,ThreadLocal会先找到当前线程的ThreadLocalMap,根据ThreadLocal的散列值找到存储的位置执行get方法或者set方法。

        下面我画了一张图来说明。ThreadLocal本身不是用来存放数据的,真正用来存储的是线程内部的ThreadLocalMap,而ThreadLocal只是作为ThreadLocalMap中的key。

ThreadLocal · 源码解读_第2张图片

        老样子,还是由一段简单的代码开始深入源码

final ThreadLocal threadLocal = new ThreadLocal<>();
threadLocal.set("你好");
Log.d("mark", "mark_1:" + threadLocal.get());
new Thread(new Runnable() {
    @Override
    public void run() {
        Log.d("mark", "mark_2:" + threadLocal.get());
        threadLocal.set("很高兴见到你");
        Log.d("mark", "mark_3:" + threadLocal.get());
    }
}).start();
05-31 16:58:30.878 19235-19235/com.newhongbin.lalala D/mark: mark_1:你好
05-31 16:58:30.879 19235-19626/com.newhongbin.lalala D/mark: mark_2:null
05-31 16:58:30.879 19235-19626/com.newhongbin.lalala D/mark: mark_3:很高兴见到你

        首先在主线程中创建ThreadLocal对象,并set“你好”,在主线程中get,可以看到取到的就是刚才set的字符串;然后开启一个子线程,这时候子线程中还没有set过,所以取出来的是null,在子线程set过之后,就能够成功取出相应的字符串了。虽然是同一个ThreadLocal对象,但是在不同的线程中get到的数据是不一样的。


set

        顺着set方法一探究竟:

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

        逻辑很简单,取到当前线程的ThreadLocalMap,如果没有初始化过,就调用createMap初始化。初始化过程就是调用ThreadLocalMap的其中一个构造方法,我们来看看这个构造方法。

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);
}

        构造方法中会定义一个Entry数组,数组初始化容量为16,扩容因子为2/3,每次扩容为原来的2倍。Entry是WeakReference的子类,因此不会影响ThreadLocal对象的生命周期以及内存回收。Entry实现了键值对存储的功能,当前ThreadLocal对象为key,需要存储的对象为value。

static class Entry extends WeakReference {
    /** 与当前ThreadLocal相关联的值 */
    Object value;
    //ThreadLocal为key,真正需要存储的对象为value
    Entry(ThreadLocal k, Object v) {
        super(k);
        value = v;
    }
}

        初始化完Entry数组之后,需要计算当前ThreadLocal的散列值(hashcode),因为这里是第一个放入的Entry,不可能会发生hash碰撞,所以计算完hashcode之后,就直接把Entry放入数组下标为hashcode的位置上。最后计算出下一次需要扩容的临界值,即 (数组长度*2/3) 。到此为止,第一个值成功set。

        那么问题又来了:

        1、如果一个ThreadLocal在同一个线程中多次set呢?

        2、如果多个ThreadLocal在同一个线程中的hashcode一样怎么办呢?

        OK,回到刚开始的set方法,如果ThreadLocalMap不为null的情况。

    private void set(ThreadLocal key, Object value) {

        Entry[] tab = table;
        int len = tab.length;
        int i = key.threadLocalHashCode & (len-1);
        //发生hash碰撞时如果碰撞的位置上已经有Entry,且原有的key没有被回收,就查找数组下一个位置,如果没有Entry就放入
        for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
            
            ThreadLocal k = e.get();

            if (k == key) {//同一个ThreadLocal多次set,会直接覆盖原来的值
                e.value = value;
                return;
            }

            if (k == null) {//原来的ThreadLocal已经被回收了,就放入新的Entry
                replaceStaleEntry(key, value, i);
                return;
            }
        }
        //在空的位置上放入Entry之前先判断是否需要扩容
        tab[i] = new Entry(key, value);
        int sz = ++size;
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
    }

        set方法中关键的几个步骤我都在源码中加了注释,应该比较容易理解,这样就能回答上面的两个问题:

        1、如果一个ThreadLocal在同一个线程中多次set呢?

             直接覆盖原有的值。

        2、如果多个ThreadLocal在同一个线程中的hashcode一样怎么办呢?

             如果发生碰撞的那个位置上的Entry的ThreadLocal被回收了,就放在碰撞的位置上;如果没有被回收,就寻找Entry下一个位置进行判断,直到找到ThreadLocal被回收的Entry,或者空的位置,放入。


get

        前面的set其实已经把ThreadLocal的基本实现方式全部梳理清楚了,所以接下来的get方法看起来会更容易些。

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null)
            return (T)e.value;
    }
    return setInitialValue();
}

        如果当前线程在get之前已经初始化过ThreadLocalMap,那么就根据hashcode找到指定的Entry,返回value。

        如果当前线程在get之前还未初始化过ThreadLocalMap,那么就返回setInitialValue()。

private T setInitialValue() {
    T value = initialValue();
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

protected T initialValue() {
    return null;
}

        setInitialValue方法很简单,定义一个value指向null,如果ThreadLocalMap不为空,就插入value;如果ThreadLocalMap为空,先调用createMap初始化ThreadLoaclMap,再插入value。最后返回的就是value。


内存泄露

        前面分析完get和set差不多就理解ThreadLocal的实现原理了,当然实际的代码还是比这更复杂一些的,ThreadLocalMap针对Entry数组还有清理方法,擦除方法,替换方法等,不过核心差不多,都是遍历查看Entry的key有效性,做出相应的处理,我就不再把代码展开了。但是可以看到这些方法里面都会清除ThreadLocal已经无效的那个value,这里面涉及到一些内存的问题,这里也来分析一下。

        前面说了ThreadLocal作为Key是以弱引用的方式存储在Entry里面的,一旦发生GC,key就被回收了,那么value就无法被访问了,但是呢,value还有一条引用链,即“Thread->ThreadLocalMap->Entry->value”,所以value无法被回收,却也无法被访问,导致内存的泄露。为了尽量减少这种情况,在get、set等方法里面,都会去处理这一类key被回收的Entry。


resize

        借着ThreadLocalMap也想聊聊扩容这个方法,一般的像HashMap、ThreadLocalMap等以键值对存储的容器类都有一个扩容方法,而且相似的是,容器的初始大小都是2^n,扩容也是2倍,这样设置的作用是啥呢?

        所有的对象都有hashcode,而且一般来说不同的对象hashcode也不同,值的分布会相对比较均匀。那么来看看ThreadLocalMap计算存储下标的算法:

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

hashcode & (2^n-1)

假设n为4,即i = hashcode & 1111,只要hashcode在数组容量以内不相同,计算的数组下标i就不会相同。

换一个情况,如果容量不为2^n,假设为17,i = hashcode & 10000,这种情况下当hashcode大于0小于16,得到的数组下标i都是0,这样的分布是不是想想就觉得可怕。

        ThreadLocal的hashcode也很有意思,第一次类加载的时候会初始化一个静态的AtomicInteger,后续每创建一个ThreadLocal都会改变这个AtomicInteger,这样就能够减少ThreadLocal的hashcode碰撞的概率。


最后

        如有不对,敬请指教。溜了溜了……


你可能感兴趣的:(Java)