Java源码探究:ThreadLocal工作原理完全解析

前言

ThreadLocal是一个平时Android开发中并不常见的类,正因为少接触,所以对它的了解并不多。但实际上,它对我们常用的Handler通信机制起着重要的支撑作用。ThreadLocal,顾名思义,线程封闭的变量,也即该变量的作用范围是以当前线程为单位,别的线程不能访问该变量。ThreadLocal<T>对外提供了get和set方法,用于提供线程独占的变量的访问途径。下面我们先从使用方法来了解一下它怎么使用。

简单的例子

public class ThreadLocalPractice {

    public static void main(String args[]){

        ThreadLocal<Integer> integerThreadLocal = new ThreadLocal<>();
        integerThreadLocal.set(1);

        new Thread(() -> {
            integerThreadLocal.set(2);
            System.out.println("Thread ID:" + Thread.currentThread().getId() + ",integerThreadLocal = " + integerThreadLocal.get());
        }).start();

        new Thread(()->{
            //do nothing
            System.out.println("Thread ID:" + Thread.currentThread().getId() + ",integerThreadLocal = " + integerThreadLocal.get());
        }).start();

        System.out.println("Thread ID:" + Thread.currentThread().getId() + ",integerThreadLocal = " + integerThreadLocal.get());
    }
}

运行程序,观察结果如下:

《Java源码探究:ThreadLocal工作原理完全解析》 程序结果1

在线程1中,我们设置了threadlocal的值为1,然后在子线程中设置为2以及不做任何修改,得到的结果分别是1、2和null,这说明了ThreadLocal的作用域限制在了某一线程中,是线程封闭了,一个线程的ThreadLocal的值改变了,并不影响另一条线程的ThreadLocal的值。下面,我们就从源码的角度来分析它的工作原理。

源码分析

注意:下面源码来自JDK-10。
1、几个关键的类或对象
在真正阅读源码之前,笔者先列举出几个关键点,以便分析的时候能更容易理解源码。
①ThreadLocal.ThreadLocalMap 这是ThreadLocal的一个静态内部类,它本质上是一个Hash Map,是专门为维护线程私有的变量而定制的。

public class ThreadLocal {

static class 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;
    }

    private void set(ThreadLocal<?> key, Object value) {
        //...
    }
  
    private Entry getEntry(ThreadLocal<?> key) {
        //...
    }
    }
}

从上面的源码可以看出,ThreadLocalMap与HashMap结构上是相似的,都有初始容量、都用数组来装载value值、都有阈值和负载因子等,它们都是利用了散列算法而做的散列表。不同之处在于ThreadLocalMap是基于线性探测的散列表,而HashMap是基于拉链法的散列表
我们关注上面的Entry[] table这一成员变量,这是一个Entry数组,它保存了一系列的通过调用ThreadLocal#set(T value)方法而传递进来的value值。

②ThreadLocalMap.Entry 这是ThreadLocalMap的一个静态内部类,可以看一下它的源码:

static class Entry extends WeakReference<ThreadLocal<?>> {
       /** The value associated with this ThreadLocal. */
       Object value;

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

它继承自WeakReference,泛型参数是ThreadLocal<?>,同时有一个Object的变量,这说明了该Entry持有一个对ThreadLocal的弱引用,同时把ThreadLocal保存的值放到了这里的Object对象内保存起来。

③Thread类的ThreadLocalMap成员变量:

public class Thread implements Runnable {
     /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    
    //other code...
}

从源码注释可以看出,ThreadLocalMap是属于当前Thread对象的(因为不同线程所对应的Thread对象不同),所以ThreadLocalMap仅在当前Thread内起作用,不同的线程都维护着各自的ThreadLocalMap,相互之间没有联系。

2、解析ThreadLocal.set(T value)方法

public void set(T value) {
    Thread t = Thread.currentThread(); //获取当前的线程  
    ThreadLocalMap map = getMap(t);    //根据当前线程获取对应的Map
    if (map != null)
        map.set(this, value);  //2、把value放进map中
    else
        createMap(t, value);   //1、创建一个Map
}

上面的流程很简单,就是根据线程对象来获取到线程内部维护的ThreadLocalMap对象,然后再把值放到map内部。

2.1、我们先看createMap(t,value)方法:

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

这里把Thread对象的map变量进行了实例化,指向一个ThreadLocalMap对象。这里可以知道线程内部维护的threadLocalMap变量只有在进行第一次保存ThreadLocal变量时才会进行实例化,也即是常说的延迟初始化

我们接着去看看ThreadLocalMap的构造方法做了什么工作:

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    table = new Entry[INITIAL_CAPACITY];
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);  //计算经过散列之后的数组下标
    table[i] = new Entry(firstKey, firstValue); //Entry内部的object保存了value值
    size = 1;
    setThreshold(INITIAL_CAPACITY);
}

从上面的源码可以看出,数组的下标通过散列函数计算来得到,即firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1),这里相当于把哈希值对数组长度取模运算,那么ThreadLocal是怎么确定自身的哈希值的呢?我们循着源码的踪迹继续向前探索,我们来看看threadLocalHashCode到底是何方神圣:

public class ThreadLocal<T> {
    //成员变量,声明为final域,一旦赋值便不能修改
    private final int threadLocalHashCode = nextHashCode();

    private static AtomicInteger nextHashCode =
        new AtomicInteger();

    private static final int HASH_INCREMENT = 0x61c88647;

    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT); //不断自增HASH_INCREMENT大小
    }

    //other code...
}

观察上面的源码,threadLocalHashCode是ThreadLocal<T>的一个成员变量,被声明为final域,表示它一旦赋值之后便不能修改了,同时它指向nextHashCode()方法的返回值。当ThreadLocal<T>被实例化的同时,会触发成员变量的初始化,也即是调用nextHashCode()方法来获取一个当前ThreadLocal对象的哈希值。

我们把关注点放在nextHashCode()方法,它是一个静态方法,与它相关的一个变量是nextHashCode,这是一个AtomicInteger,即原子变量,它也是一个静态变量。我们知道,静态变量是属于类所有的,与类的某一对象实例无关,所以通过不断的实例化ThreadLocal类,它的静态变量nextHashCode静态变量就会不断地自增,并且每次都自增0x61c88647。通过这种方法,不同的ThreadLocal实例便获得了一个独特的哈希值(注:由于采用了原子变量,在多线程环境下也能获得正确的取值)。

2.1-小结:上面分析了createMap(t,value)方法,通过该方法,实例化了一个与当前线程有关的ThreadLocalMap实例,并且通过ThreadLocal实例化时获得的一个哈希值与Entry[]数组的长度进行与运算来算出下标i,并把value保存到Entry[]数组的该下标位置处。

2.2、我们继续来分析map.set(this, value)
现在,让我们把目光放回刚才的set()方法上,当map不是空值时,会调用map.set()函数进行插入操作。下面,我们来看看map.set()方法做了什么工作:

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

    // We don't use a fast path as with get() because it is at
    // least as common to use set() to create new entries as
    // it is to replace existing ones, in which case, a fast
    // path would fail more often than not.

    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);  //根据threadLocal的hashcode来获取散列后的下标i

    //在i的基础上,不断向前探测,即线性探测法。探查是否已经存在相应的key,如果存在旧替换。
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {  //nextIndex()的作用在于自增i值,如果超过数组长度就变成0,相当于把数组看出环形数组
        ThreadLocal<?> k = e.get();         //获取Entry持有的ThreadLocal弱引用

        if (k == key) {         //如果两个ThreadLocal相等,表示需要更新该key对应的value值
            e.value = value;
            return;
        }

        if (k == null) {       //如果k为null,表示Entry持有的弱引用已经过期,即ThreadLocal对象被GC回收了
            replaceStaleEntry(key, value, i);   //此时更新旧的Entry值
            return;
        }
    }

    tab[i] = new Entry(key, value);    //如果走到了这里,表示插入的key-value是新值
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)  //当ThreadLocalMap达到了一定的容量时,需要进行扩容
        rehash();
}

上面的注释已经说得很清楚了,主要流程就是先通过散列找到应该存放的数组下标index,然后利用线性探测的方法逐步增大index,观察对应Entry[]位置是否存在Entry对象,然后选择替换或者实例化一个新的Entry对象。

3、解析ThreadLocal.get()方法
上面讲述了set()方法,那么当我们调用get()方法来获取一个值的时候,背后所作的工作又是怎样的呢?

public T get() {
    Thread t = Thread.currentThread();      //获取当前线程对象
    ThreadLocalMap map = getMap(t);         //根据线程对象获取其内部的Map
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();               //如果Map尚未初始化,则初始化
}

逻辑很简单,就是获取到当前线程所维护的一个ThreadLocalMap,然后以当前ThreadLocal对象作为key来获取一个Entry,具体逻辑我们看map.getEntry(this)

/**
 * Get the entry associated with key.  This method
 * itself handles only the fast path: a direct hit of existing
 * key. It otherwise relays to getEntryAfterMiss.  This is
 * designed to maximize performance for direct hits, in part
 * by making this method readily inlinable.
 *
 * @param  key the thread local object
 * @return the entry associated with key, or null if no such
 */
private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);   //通过散列函数计算数组下标
    Entry e = table[i];
    if (e != null && e.get() == key)                        //如果直接命中,则返回
        return e;
    else
        return getEntryAfterMiss(key, i, e);                
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {                //利用线性探测法来寻找key所在的位置  
        ThreadLocal<?> k = e.get();
        if (k == key)
            return e;
        if (k == null)                 //如果当前遍历到的key已经被回收了,那么进行清理
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);     //利用环形数组的原理来变化i值
        e = tab[i];
    }
    return null;
}

逻辑还是很清晰的,如果通过散列函数得到的数组下标直接命中key值,那么可以直接返回,否则进一步调用getEntryAfterMiss(key,e)方法来进行线性探测查找key。
值得注意的是,这里把查找过程分成了两个方法来处理,为什么要这样做?从源码的注释可以看出,这样设计的目的是最大限度提高getEntry(key)方法的性能,也即是提高直接命中时的返回结果的效率。这是因为JVM在运行的过程中,如果一些短函数被频繁的调用,那么JVM会把它优化成内联函数,即直接把函数的代码融合进调用方的代码里面,这样省掉了函数的调用过程,效率也会得到提高。

4、ThreadLocalMap处理已失效的Key的过程
ThreadLocalMap是ThreadLocal的核心部分,其中大量逻辑都是在ThreadLocalMap中完成的,所以其重要性不言而喻。因此值得我们来继续学习它的优秀思路。
我们通过上面的学习,知道了Entry持有对ThreadLocal的弱引用,但同时它也持有一个对Object的强引用,前者是key,后者是value。那么随着系统的运行,ThreadLocal可能会被GC回收了,那么此时Entry持有的key值就变成了失效的值。因此,在get()和set()的过程中,ThreadLocalMap可能会触发对已失效key的处理,以回收空间。
4.1、我们来看看ThreadLocalMap.expungeStaleEntry(int)的源码:

private int expungeStaleEntry(int staleSlot) {  //这里传入的staleSlot表示这个下标位置的Entry是失效的
    Entry[] tab = table;
    int len = tab.length;

    //把当前位置Entry的value值置空,同时也把Entry[staleSlot]置空,便于GC回收
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    //线性探测法进行环形探测,回收失效的key值及Entry,对于没失效的Entry进行ReHash得到h,
    //再把该Entry放到对h线性探测的下一个为空的位置
    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;
}

经过调用一次i = expungeStaleEntry(staleSlot)后,staleSlot到i之间的无效值都被清理了,并且在这其中的有效Entry也被再散列到了相应的位置。

4.2、在理解了expungeStaleEntry(staleSlot)的作用之后,我们接下来看看与之关系密切的另一个方法ThreadLocalMap.cleanSomeSlots(int i, int n)方法:

/**
 * 启发式地对Entry[]进行扫描,并清理无效的slot.
 * 从下面的while循环表达式可以知道,第一次扫描的单元是i ~ i+log2(n),
 * 如果在这期间发现了无效slot,那么把n变大到数组的长度,此时扫描单元数为log2(length)。
 * 即,在扫描的期间,如果发现了无效slot,就不断增大扫描范围。因此称之为启发式扫描。
 *
 * @param i 无效slot所在的位置 
 * @param n 控制扫描的数组单元数的参数
 */
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];
        if (e != null && e.get() == null) { //如果找到一个无效Entry(Key被回收)
            n = len;            //设置n为Entry[]的长度,以增加扫描单元数
            removed = true;
            i = expungeStaleEntry(i);   //调用清理函数,i就是下一次向前探测的初始位置,
                                        //因为在[旧i,新i]之间的无效slot都被清理了
        }
    } while ( (n >>>= 1) != 0); // n >>>= 1 表示 n = n >>> 1,>>>表示无符号右移
    return removed;
}

代码给出了详细的注释,cleanSomeSlots(int,int)就是一个启发式的过程,在给定范围内如果没有找到失效的Entry,那么就停止搜索,否则会不断增大搜索范围。该方法避免了对Entry[]的全部扫描,是时间效率和存在无效slot之间的一个折衷方案。

4.3、让我们回到2.2的代码处,在set(key,value)方法内当扫描到的key是null时,会调用replaceStaleEntry(key, value, i)方法进行替换,这时候未免产生了一个疑问:在当前位置进行替换,如果后面已经有相同的key但还没扫描到怎么办?其实,replaceStaleEntry(key, value, i)方法已经帮我们解决了这个问题,我们来查看该方法的源码:

/**
 * 在已知存在失效slot的情况下,插入一个key-value值。
 * 该方法会触发启发式扫描,清理失效slot。
 *
 * @param  key ThreadLocal实例
 * @param  value ThreadLocal实例需要保存的值
 * @param  staleSlot 一个失效Entry.key所在的下标
 */
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    //向前扫描,寻找一个失效的slot,直到数组元素为null
    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;

    //向后扫描,直到找到一个key与参数的key相等的位置,
    //或者遇到数组元素为null停止扫描
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

        //如果找到相同的key,把i位置的Entry与staleSlot位置的Entry交换位置
        //经过这一步骤,失效的slot被移到了i位置
        if (k == key) {
            e.value = value;

            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            //从slotToExpunge位置开始启发式清理过程,该位置根据在前向扫描过程中
            //是否找到另一个失效slot来决定,如果找到,则从该位置开始清理;
            //否则,从i位置开始清理,即上面被交换了位置的slot。
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        //如果前向扫描没有失效slot,并且在后向扫描的过程中遇到了第一个失效slot,记录下该位置
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    //在staleSlot位置插入新值
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    //从失效slot位置进行启发式清理
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

该方法的核心就在于寻找一个合适的位置来放入给定的value值,并寻找合适位置来执行cleanSomeSlots(int,int)方法。而扫描位置取决于前向扫描是否找到一个失效slot和后向扫描是否找到一个相等的key或者一个失效的slot。

4-4小结:经过上面的流程梳理以及源码分析,我们可以得知,触发cleanSomeSlots(int i, int n)启发式清理有两个场景:①新的Entry被添加到数组中。②把失效key所在的slot替换成新的Entry。启发式清理的过程是在发现了失效slot的情况下会逐渐增大扫描单元,以获得较好的时间复杂度。expungeStaleEntry(staleSlotIndex)则是关键的清理函数,它向前环形遍历,不断地清理失效key的Entry,置为null同时断开强引用,把有效的Entry再散列到别的位置,直到遇到null值。replaceStaleEntry(key,value,i)则是在要替换Entry[]的某一元素时被调用,它会在i位置前后扫描查看是否有失效key的Entry,以触发一次启发式清理的过程。

总结

经过上面的学习,我们可以总结出下面的ThreadLocal UML类图如下:

《Java源码探究:ThreadLocal工作原理完全解析》 ThreadLocal类图

本文主要探究了ThreadLocal和ThreadLocalMap的原理,以及ThreadLocalMap的清理失效Entry的算法,其中ThreadLocalMap是使用线性探测解决碰撞的哈希表的一个优秀实现例子,我们可以借鉴它的实现方法,让我们对哈希表的理解更加深入。

好了,本文到这里结束,谢谢你们的阅读!如果可以的话,点个赞再走吧~

    原文作者:Android
    原文地址: https://www.jianshu.com/p/79bf14ae1905
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞