ThreadLocal源码分析

ThreadLocal是为线程提供局部变量的一种方式。是线程内部的数据存储类,线程可以存储私有数据,在该线程存活和ThreadLocal实例能访问的时候,保存了对这个变量副本的引用。由于数据是线程私有的,就不会出现并发争抢资源的情况发生。当线程消失的时候所有的本地实例都会被GC。

使用示例:

public class TreadLocalTest {
    private static ThreadLocal<Integer> addNumThreadLocal = new ThreadLocal<>();

    public static int add(int num) {
        addNumThreadLocal.set(num);
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return addNumThreadLocal.get() + 10;
    }

    public static void main(String[] args) {

        ExecutorService service = Executors.newFixedThreadPool(20);

        for (int i = 0; i < 20; i++) {
            int num = i;
            service.execute(()->{
                System.out.println(num + " " +  TreadLocalTest.add(num));
            });
        }
        service.shutdown();
    }
}

整体实现思路:

1. 每个Thread都有一个属于自己的ThreadLocalMap,这个ThreadLocalMap是ThreadLocal的一个内部类,类似于一个HashMap集合

2. 每次存取值都是通过ThreadLocalMap存取,key值为ThreadLocal实例,value是需要存放的值

ThreadLocal中的嵌套内部类ThreadLocalMap,这个类本质上是一个map,和HashMap之类的实现相似,依然是key-value的形式,其中有一个内部类Entry,其中key可以看做是ThreadLocal实例,但是其本质是持有ThreadLocal实例的弱引用。而线程本身Tread中会有threadLocals这个局部变量,这个局部变量就是ThreadLocalMap的实例存放数据是需要先初始化这个变量每次保存时都是以一个ThreadLocal实例作为key值,存放的数据作为value值进行存放。一个ThreadLocal只能存放一种指定的类型的数据。其所有功能都是通过ThreadLocalMap这个内部类实现的。下面我们将重点分析ThreadLocalMap的源码

源码分析:

  1. 构造函数:Thread只有一个无参的构造函数
public ThreadLocal() {
    }

2. ThreadLocal中的set()方法(赋值方法)

    public void set(T value) {
        //获取当前执行线程
        Thread t = Thread.currentThread();
        //获取该线程的内部变量ThreadLocalMap,下面会讲解getMap(t)方法
        ThreadLocalMap map = getMap(t);
        if (map != null)
            //调用ThreadLocalMap中的set方法将ThreadLocal作为key值,进行保存参数
            map.set(this, value);
        else
            //如果当前线程Thread类为空,那么创建一个ThreadLocalMap并赋值给该线程的变量
            createMap(t, value);
    }
    
    //这个getMap(t)方法很简单就是返回Thread类中的局部变量threadLocals,就是我们所说的ThreadLocalMap
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    
    //ThreadLocalMap采用的是延迟初始化的方式,只要第一次调用get或者set方法时才会进行初始化
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

ThreadLocal的set()方法很简单

(1) 获取当前线程,并获取当前线程的ThreadLocalMap实例

(2)如果获取到的map实例不为空,调用map.set()方法,否则调用构造函数 ThreadLocal.ThreadLocalMap(this, firstValue)实例化map。

3. ThreadLocalMap源码分析

成员变量

   /**
     * 初始容量 —— 必须是2的幂,这个初始值其实就是下面的Entry[] table数组的初始值
     * 实例化ThreadLocalMap后会个这个table数组一个初始值
     */
    private static final int INITIAL_CAPACITY = 16;

    /**
     * 存放数据的table,Entry是真正存放数据的类,key值为上面所说的ThreadLocal,value值就是传递进来所需要保存的
     * 由于一个Thread中可能需要保存多个值,即有多个ThreadLocal作为key值,所以一个ThreadLocal作为key/value保存后
     * 会放在数组中进行保存,取值也是从这个数组中进行取值。这个数组其实是一个循环数组,下面会进行讲解
     */
    private Entry[] table;

    /**
     * 数组里面entrys的个数,可以用于判断table当前使用量是否超过负因子。
     */
    private int size = 0;

    /**
     * 进行扩容的阈值,表使用量大于它的时候进行扩容。
     */
    private int threshold; // Default to 0
    
    /**
     * 定义为长度的2/3
     */
    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }

存储结构——Entry

/**
     * Entry继承WeakReference,并且用ThreadLocal作为key.如果key为null
     * (entry.get() == null)表示key不再被引用,表示ThreadLocal对象被回收
     * 因此这时候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;
        }
    }

Entry继承WeakReference,使用弱引用,可以将ThreadLocal对象的生命周期和线程生命周期解绑,持有对ThreadLocal的弱引用,可以使得ThreadLocal在没有其他强引用的时候被回收掉,这样可以避免因为线程得不到销毁导致ThreadLocal对象无法被回收。java的弱引用其实针对的是垃圾回收机制,当执行垃圾回收时遇到弱引用的数据就会进行回收

构造函数:

    ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        //初始化table,就是上面所说的成员变量值
        table = new ThreadLocal.ThreadLocalMap.Entry[INITIAL_CAPACITY];
        //计算索引,每个ThreadLocal保存在table数组中需要对应数组下标
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
        //设置值
        table[i] = new ThreadLocal.ThreadLocalMap.Entry(firstKey, firstValue);
        size = 1;
        //设置阈值
        setThreshold(INITIAL_CAPACITY);
    }

索引的计算方式firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)关于& (INITIAL_CAPACITY - 1),这是取模的一种方式,对于2的幂作为模数取模,用此代替%(2^n),这也就是为啥容量必须为2的幂,在这个地方也得到了解答,至于为什么可以这样这里不过多解释,原理很简单。

关于firstKey.threadLocalHashCode:

private final int threadLocalHashCode = nextHashCode();
    
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
    private static AtomicInteger nextHashCode =
            new AtomicInteger();
            
    private static final int HASH_INCREMENT = 0x61c88647;

原理如下:定义了一个AtomicInteger类型,每次获取当前值并加上HASH_INCREMENT,HASH_INCREMENT = 0x61c88647,关于这个值和斐波那契散列有关,其原理这里不再深究,感兴趣可自行搜索,其主要目的就是为了让哈希码能均匀的分布在2的n次方的数组里, 也就是Entry[] table中。

ThreadLocalMap中的数据存储table[]讲解

上面我们提到过ThreadLocalMap存储数据是放在table[]数组中的,key值是ThreadLocal,value是传递进来的数据。存储时先将ThreadLocal进行hash计算出一个索引值,放到数组对应的位置。当两个ThreadLocal计算出来的索引值相同时就会出现哈希冲突。ThreadLocalMap使用线性探测法来解决哈希冲突,线性探测法的地址增量di = 1, 2, ... , m-1,其中,i为探测次数。该方法一次探测下一个地址,直到有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。假设当前table长度为16,也就是说如果计算出来key的hash值为14,如果table[14]上已经有值,并且其key与当前key不一致,那么就发生了hash冲突,这个时候将14加1得到15,取table[15]进行判断,这个时候如果还是冲突会回到0,取table[0],以此类推,直到可以插入。

    按照上面的描述可以把table看成一个环形数组
    先看一下线性探测相关的代码从中也可以看出来table实际是一个环
    /**java
    /**
     * 获取环形数组的下一个索引
     */
    private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
    }

    /**
     * 获取环形数组的上一个索引
     */
    private static int prevIndex(int i, int len) {
        return ((i - 1 >= 0) ? i - 1 : len - 1);
    }

ThreadLocalMap的set()及其set()相关代码如下:

    private void set(ThreadLocal<?> key, Object value) {
        
        //首先获取当前保存的数据table数组
        Entry[] tab = table;
        //获取数组的长度
        int len = tab.length;
        //将需要保存的ThreadLocal进行哈希计算出索引,上面提到过
        int i = key.threadLocalHashCode & (len-1);
        
        //上面所讲的线性探测法进行存储数据
        //先看看这个for循环进入和跳出的条件是什么,进入for循环的条件是获取的e不为空,不为空后进入for循环判断取出的key
        //是否和当前的ThreadLocal是否相同如果相同,那么就重新赋值并返回。如果不相同判断下k值是否存在,如果不存在但是table[i]还有值
        //只有一种情况那就是这个table[i]保存的ThreadLocal被垃圾回收掉了,前面提到过Entry是弱引用,当不存在使用时会被回收掉,那么就进行
        //替换并赋值
        //跳出for循环的条件是获取的tab[i]为空值,如果为空那么直接就赋值就可以了,这个条件很重要,后面的操作都是因为这个条件才进行的
        for (Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
            
            ThreadLocal<?> k = e.get();
            //取出的Entry有值,判断保存的ThreadLocal和将要保存的ThreadLocal是否相同
            //如果相同直接赋值返回
            if (k == key) {
                e.value = value;
                return;
            }
            //如果取出的Entry为空值进行赋值并返回
            //这个时候说明改table[i]可以重新使用,用新的key-value将其替换,并删除其他无效的entry
            if (k == null) {
                //key值替换成新的ThreadLocal值,下面会进行讲解
                replaceStaleEntry(key, value, i);
                return;
            }
        }
        //跳出for循环,说明tab[i]为空值,赋值就可以了
        tab[i] = new Entry(key, value);
        //当前的table大小加1
        int sz = ++size;
        //cleanSomeSlots用于清除那些e.get()==null,也就是table[index] != null && table[index].get()==null
        //之前提到过,这种数据key关联的对象已经被回收,所以这个Entry(table[index])可以被置null。
        //如果没有清除任何entry,并且当前使用量达到了负载因子所定义(长度的2/3),那么进行rehash()
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
    }

replaceStaleEntry方法详解

根据上面描述举个例子比如说,我们的关键字集合为{12,33,4,5,15,25},表长为10。我们用散列函数f(key) = key mod l0。当计算前S个数{12,33,4,5,15,25}时,并且此时key=33,k=5 已经过期了(蓝色代表为空的,可以存放数据,红色代表key 过期,过期的key为null):

这时候来了一个新的数据,key=15,value=new,通过计算f(15)=5,此时5已经过期,进入到下面这个if 语句

if (k == null) {
    //key值替换成新的ThreadLocal值,下面会进行讲解
    replaceStaleEntry(key, value, i);
    return;
}


    private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                   int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
        Entry e;

        //这里采用的是从当前的staleSlot 位置向前面遍历,i--,
        //这样的话是为了把前面所有的的已经被垃圾回收的也一起释放空间出来
        //(注意这里只是key 被回收,value还没被回收,entry更加没回收,所以需要让他们回收)
        //同时也避免这样存在很多过期的对象的占用,导致这个时候刚好来了一个新的元素达到阀值而触发一次新的rehash
        int slotToExpunge = staleSlot;
        for (int i = prevIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = prevIndex(i, len))
            if (e.get() == null)
                //slotToExpunge 记录staleSlot左手边第一个空的entry 到staleSlot 之间key过期最小的index
                slotToExpunge = i;

        // 这个时候是从数组下标小的往下标大的方向遍历,i++,刚好跟上面相反。
        //这两个遍历就是为了在左边遇到的第一个空的entry到右边遇到的第一空的 entry之间查询所有过期的对象。
        //注意:在右边如果找到需要设置值的key(这个例子是key=15)相同的时候就开始清理,然后返回,不再继续遍历下去了
        for (int i = nextIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();

            //说明之前已经存在相同的key,所以需要替换旧的值并且和前面那个过期的对象的进行交换位置,
            //交换的目的下面会解释
            if (k == key) {
                e.value = value;

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

                //这里的意思就是前面的第一个for 循环(i--)往前查找的时候没有找到过期的,只有staleSlot
                // 这个过期,由于前面过期的对象已经通过交换位置的方式放到index=i上了,
                // 所以需要清理的位置是i,而不是传过来的staleSlot
                if (slotToExpunge == staleSlot)
                    slotToExpunge = i;
                //进行清理过期数据
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                return;
            }

            // 如果我们在第一个for 循环(i--) 向前遍历的时候没有找到任何过期的对象
            // 那么我们需要把slotToExpunge 设置为向后遍历(i++) 的第一个过期对象的位置
            // 因为如果整个数组都没有找到要设置的key 的时候,该key 会设置在该staleSlot的位置上
            //如果数组中存在要设置的key,那么上面也会通过交换位置的时候把有效值移到staleSlot位置上
            //综上所述,staleSlot位置上不管怎么样,存放的都是有效的值,所以不需要清理的
            if (k == null && slotToExpunge == staleSlot)
                slotToExpunge = i;
        }

        // 如果key 在数组中没有存在,那么直接新建一个新的放进去就可以
        tab[staleSlot].value = null;
        tab[staleSlot] = new Entry(key, value);

        // 如果有其他已经过期的对象,那么需要清理他
        if (slotToExpunge != staleSlot)
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
    }

第一个for 循环是向前遍历数据的,直到遍历到空的entry 就停止(这个是根据开放地址的线性探测法),这里的例子就是遍历到index=1就停止了。向前遍历的过程同时会找出过期的key,这个时候找到的是下标index=3 的为过期,进入到

    if (e.get() == null)
    slotToExpunge = i;

注意此时slotToExpunge=3,staleSlot=5

第二个for 循环是从index=staleSlot开始,向后编列的,找出是否有和当前匹配的key,有的话进行清理过期的对象和重新设置当前的值。这个例子遍历到index=6 的时候,匹配到key=15的值,进入如下代码:

    if (k == key) {
        e.value = value;

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

        // Start expunge at preceding stale entry if it exists
        if (slotToExpunge == staleSlot)
            slotToExpunge = i;
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        return;
    }

先进行数据交换,注意此时slotToExpunge=3,staleSlot=5,i=6。这里就是把5 和6 的位置的元素进行交换,并且设置新的value=new,交换后的图是这样的

为什么要交换这里解释下为什么交换,我们先来看看如果不交换的话,经过设置值和清理过期对象,会是以下这张图

这个时候如果我们再一次设置一个key=15,value=new2 的值,通过f(15)=5,这个时候由于上次index=5是过期对象,被清空了,所以可以存在数据,那么就直接存放在这里了

你看,这样整个数组就存在两个key=15 的数据了,这样是不允许的,所以一定要交换数据

expungeStaleEntry方法详解:

    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) {
                //这里设置为null ,方便让GC 回收
                e.value = null;
                tab[i] = null;
                size--;
            } else {
                //这里主要的作用是由于采用了开放地址法,所以删除的元素是多个冲突元素中的一个,需要对后面的元素作
                //处理,可以简单理解就是让后面的元素往前面移动
                //为什么要这样做呢?主要是开放地址寻找元素的时候,遇到null 就停止寻找了,你前面k==null
                //的时候已经设置entry为null了,不移动的话,那么后面的元素就永远访问不了了,下面会画图进行解释说明
                int h = k.threadLocalHashCode & (len - 1);
                //他们不相等,说明是经过hash 是有冲突的
                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;
    }

接下来我们详细模拟下整个过程根据我们的例子,key=5,15,25 都是冲突的,并且k=5的值已经过期,经过replaceStaleEntry 方法,在进入expungeStaleEntry 方法之前,数据结构是这样的

    此时传进来的参数staleSlot=6
    if (k == null) {
        //这里设置为null ,方便让GC 回收
        e.value = null;
        tab[i] = null;
        size--;
    }

这个时候会把index=6设置为null,数据结构变成下面的情况

接下来我们会遍历到i=7,经过int h = k.threadLocalHashCode & (len - 1) (实际上对应我们的举例的函数int h= f(25)); 得到的h=5,而25实际存放在index=7 的位置上,这个时候我们需要从h=5的位置上重新开始编列,直到遇到空的entry 为止

    int h = k.threadLocalHashCode & (len - 1);
    //他们不相等,说明是经过hash 是有冲突的
    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;
    }
    

这个时候h=6,并把k=25 的值移到index=6 的位置上,同时设置index=7 为空,如下图

其实目的跟replaceStaleEntry 交换位置的原理是一样的,为了防止由于回收掉中间那个冲突的值,导致后面冲突的值没办法找到(因为e==null 就跳出循环了)

cleanSomeSlots方法详解:

    //这个方法是从i 开始往后遍历(i++),寻找过期对象进行清除操作
    private boolean cleanSomeSlots(int i, int n) {
        boolean removed = false;
        Entry[] tab = table;
        int len = tab.length;
        // 用do while 语法,保证 do 里面的代码至少被执行一次
        do {
            i = nextIndex(i, len);
            Entry e = tab[i];
            if (e != null && e.get() == null) {
                //如果遇到过期对象的时候,重新赋值n=len 也就是当前数组的长度
                n = len;
                removed = true;
                //在一次调用expungeStaleEntry 来进行垃圾回收(只是帮助垃圾回收)
                i = expungeStaleEntry(i);
            }
        } while ( (n >>>= 1) != 0);//无符号右移动一位,可以简单理解为除以2
        return removed;
    }

经过上面的分析expungeStaleEntry 返回的值i=7,传进来的n 是数组的长度n=10;大家可以看到这个方法的循环结束条件是n>>>1!=0,也就是这个方法在没有遇到过期对象的时候,会执行log2(n)的扫描。这里没有选择扫描全部是为了性能的平衡。由于这里的跳出循环的条件不是遇到空的entry 就停止,那么空entry 后面的过期对象也有机会被清理掉(对应下图的index=9,会被清除),当然i 前面如果有过期对象的时候,这一次是不会被清掉的(对应下图的index=3 这一次不会被清除),但是也不用太担心,由于上面我们提到的神奇魔法数,让哈希码能均匀的分布在2的N次方的数组里,经过多次的set 等方法的时候,也会让key 落到数组下标比较小的地方,这样也会导致前面那些过期的对象会被清除掉

参考文献:吃透ThreadLocal 源码的每一个细节和设计原理_value

未经允许不得转载:开心小站长 » ThreadLocal源码分析

相关推荐