ThreadLocal、ThreadLocalMap源码解析

560 阅读10分钟

1.ThreadLocal

1.1ThreadLocal概述

是一个用来实现存放、获取、删除线程相关数据的工具类,实际的线程私有数据并不是存储在这个对象中的。在ThreadLocal类中有一个静态内部类ThreadLocalMap,这个类是一个用数组实现的Hash表。在Thread类中有一个ThreadLocalMap类型的成员变量。

image.png

实际上在存储数据时,会创建这个ThreadLocalMap对象,并给Thread类中的这个ThreadLocalMap属性赋值。调用ThreadLocal方法存储的数据,实际上都存储到这里面来了。而这个对象又是当前线程的内部对象,那么如果想存储线程私有数据,实际上是存储在当前线程的对象中的,而不是ThreadLocal对象。

1.2 ThreadLocalMap

我们都知道ThreadLocalMap是一个用数组实现的Hash表,那么到底是这么实现的呢?我们来看看它的底层数据结构的实现。

1.2.1ThreadLocalMap的数据结构

image.png

ThreadLocalMap中定义了一个Entry类,在这个类中封装的就是Hash表的keyvalue。我们可以看到,在具体的实现中是把ThreadLocal对象当做key,而且这个ThreadLocal引用还是一个弱引用

ThreadLocalMap底层就是建立一个Entry数组来实现一个Hash表。这个Hash表的初始化容量是16

image.png

扩容阈值是数组长度的2/3

image.png

1)那么为什么要将ThreadLocal设置为弱引用呢?

因为如果设置为强引用,当我们不再使用这个ThreadLocal对象时,即使我们把指向ThreadLocal对象的引用设置为null,我们还是不能回收这个对象。因为在堆中的还有一个Entry中的key引用,作为一个强引用指向这个对象。我们会在ThreadLocalMap中维护一个Entry数组来实现Hash表,而ThreadLocalMap对象的引用又在当期线程对象中。所以,在当前线程被销毁前,这部分数据会一直存在,这样就导致了内存泄露。

2)为什么不把value设置为弱引用呢?

ThreadLocal可以设置为弱引用,因为在使用过程中,堆中或者栈中还是有一个强引用指向ThreadLocal对象。但是如果把value也设置为弱引用,却不一定存在一个指向value的强引用。也就是说,value只有弱引用指向,那么只要一发生GC,即使你还在使用ThreadLocal,那么value也会被回收。

1.2.2 ThreadLocalMap代码分析

了解了上面这些知识,那么我们是怎么从这个Hash表中存取数据的呢?来看看它的get、set方法。

1)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;
//通过数组长度减1和ThreadLocal对象的hashCode进行与运算得到一个数组下标
int i = key.threadLocalHashCode & (len-1);

//从定位到的下标开始遍历数组,直到数组中的entry = null为止
for (Entry e = tab[i];
     e != null;
     e = tab[i = nextIndex(i, len)]) {
    ThreadLocal<?> k = e.get();

    //如果找到key相同的,替换value
    if (k == key) {
        e.value = value;
        return;
    }
	//如果k=null,那么说明这个entry已经是无用数据了
    if (k == null) {
        //将过期的entry替换掉
        replaceStaleEntry(key, value, i);
        return;
    }
}
//如果没有在数组中遍历到,新建一个entry加入到entry = null的位置。
tab[i] = new Entry(key, value);
//数组entry个数加1
int sz = ++size;
/*如果在进行启发式清理的时候没有清理掉过期的entry,并且数组个数已经大于扩容阈值,
那么就进行rehash
*/
if (!cleanSomeSlots(i, sz) && sz >= threshold)
    rehash();
 }
(1)ThreadLocalMap的hashCode

我们看到,ThreadLocalMap是通过获取ThreadLocal的属性threadLocalHashCode再与数组长度减1来得到下标的。那么这个hashCode是怎么实现的?

image.png

如上图,一开始nextHashCode是一个原子类对象,值为0。每次获取threadLocalHashCode时都要调用nextHashCode方法,让这个对象的值加上一个固定的hash增量。这个HASH_INCREMENT值是一个特殊的值,他可以让数据在长度为2n的hash表中均匀分布。这样就尽量减少hash冲突

(2)replaceStaleEntry

我们看到,在我们遍历到一个过期的Entry(即k=null的Entry)时,那么我们就要调用replaceStaleEntry方法来替换掉过期的Entry。 `

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

    /*
    从过期entry的下标向前遍历,直到entry为null的时候停止,记录找的的最后一个过期的entry
    的下标。
    这个prevIndex方法在到达下标0时会跳到 len-1,所以可能找到的最后一个过期entry在staleSlot
    的后面
    */
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
         slotToExpunge = i;

    /*
    从staleSlot开始往后面找,直到遇到一个空entry
    */
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
	
    
        if (k == key) {
        /*
                如果遇到一个相同的key,将其替换,然后交换当前entry和过期的entry的位置
        */
            e.value = value;

            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;
        
            /*
                如果在上一个遍历时没有找到过期entry,那么就将当前的i作为要消除的插槽
                因为当前entry在交换位置有已经是过期entry了。
            */
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            //从当前的过期entry开始探测式清理,再进行启发式清理
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            //在清理过期数据后,执行结束
            return;
        }
        /*
                如果在向后遍历的过程中遇到一个过期entry,但是前面还有过期的,就表示要从前面开始
                清理,而不是从后面
        */
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    //如果没有找到相同的key,在过期位置新new一个entry
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);
    //如果找到了一个新的过期数据,那么就进行清理
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}`
(3)为什么要进行交换?

可以看到,我们在碰到一个拥有相同的keyentry的时候,不仅仅进行了值的覆盖,还进行了和过期数据位置的交换,这是为什么?

因为ThreadLocalMap使用的是线性探测法来解决hash碰撞的,如果我们不把他们的位置进行交换,那么就会把这个过期数据清除,这个过期entry就变成的空entry。那么如果下一次插入的entry的key和这次插入的entry的key重复时,那么它就先会通过线性探测法找到这个空的位置,直接插入,那么在hash表中就会存在重复的key了。所以,我们就要对它们的位置进行交换,这是为了保证hash表的key的不重复性的

(4)为什么还要向前找slotToExpunge?

使得后面在进行探测式清理时,探测到尽可能大的范围。

(5)探测式清理

在上面的代码注释中,我们提到了探测性清理,实际上expungeStaleEntry实现的就是探测性清理。 `

 private int expungeStaleEntry(int staleSlot) {
     Entry[] tab = table;
     int len = tab.length;

    // 因为staleSlot位置的entry过期了,所以,把它数据清除
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // Rehash until we encounter null
    Entry e;
    int i;
    //向后遍历知道遇到entry为null
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        //如果key为null,清除
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            //如果不为null,进行重hash,直到找到一个null的位置插入
            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;
    
}`

其实探测式清除的步骤就是:从过期entry开始向后探测并清除过期entry,并对没有过期的entry进行重新hash,进行位置调整,直到探测到entry为null,停止探测。

1.为什么要进行对遍历的不过期的entry调整位置?

其实这也是为了保证Hash表key的不重复性如果不重新进行位置调整,那么在插入的key重复时,那么就会直接占据前面的为null的位置,后面可能还会存在重复的key

2.为什么都把entry = null作为循环停止条件?

因为使用的是线性探测法,并且还会调整entry的位置来保证key的唯一性,如果entry为null,那就意味着后面没有存在hash碰撞的数据了,存在hash碰撞的entry之间,一定没有null。

(6)启发式清理
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        //i向后遍历
        i = nextIndex(i, len);
        Entry e = tab[i];
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
     //n = n>>>1,无符号右移,相当与n = n/2。
    } while ( (n >>>= 1) != 0);
    return removed;
}

通过源码我们可以看到,启发式清理执行的并不是O(n)时间复杂度的清理,而是O(logn)时间复杂度的清理,从传入的i开始,如果找到一个过期的entry,那么就会调用探测式清理进行清理,并且循环n重新赋值为数组长度

(7)rehash

如果插入数据后启发式清理没有进行并且现在元素个数大于扩容阈值,那么就会进行rehash

image.png rehash首先会调用expungeStaleEntries方法,这个方法会将遍历整个hash表,将所有过期的entry全部删除,并把没有过期的数据全部重新hash定位。

image.png

如果在清除所有的过期entry后entry个数还是大于扩容阈值的3/4,那么就需要扩容,扩容为原来的2倍,重新设置扩容阈值

(8)set方法总结

通过上面的分析,我们可以总结出set方法的执行流程:

  1. 先通过ThreadLocal的属性threadLocalHashCode与数组长度减1得到数组下标

  2. 从这个数组下标开始探测

    • 如果最先找到一个重复的key,那么就直接替换这个entry的value,返回执行结束。

    • 如果先找到一个过期的entry,那么就替换这个过期的entry,返回执行结束

      • 如果在找到这个过期的entry后,找到重复的key,那么就覆盖value,交换位置。
      • 如果最后还是找到了entry为null的位置,那么就直接new一个entry,将这个entry放在过期entry的地方。
      • 一般设值完成后都会进行探测式清理和启发式清理
    • 如果先遇到一个entry为null的地方,那么就new一个entry,把数据放在这个地方。

      • 如果插入数据后启发式清理没有进行并且现在元素个数大于扩容阈值,那么就会进行rehash
      • 在rehash时会对数组的所有entry都进行扫描,清理所有的过期entry,所有元素重新定位。
      • 如果发现此时entry个数还是大于扩容阈值的3/4,那么就扩容到原来的两倍。

2)get方法

①也是通过threadLocalHashCode变量得到一个下标,看key是否相等。如果key相等,直接返回这个entry

image.png

②如果key不相等,那么就从当前的下标开始向后找,直到entry为null。

  1. 如果找到key相等,返回entry
  2. 如果找到过期entry,进行探测式清理。
  3. 如果没有找到,返回null。

image.png

3)remove方法

也是通过threadLocalHashCode定位,从这个下标开始遍历,直到entry为null。如果找到目标,将弱引用删除,进行探测式清理。

image.png

1.3 ThreadLocal源码

1.3.1get方法

image.png

①得到当前线程对象,通过当前线程得到ThreadLocalMap对象。

②如果map不为null,通过当前ThreadLocal对象得到entry,得到value返回。

③如果map为null,调用setInitialValue方法,并返回它的返回值。

image.png

①通过initialValue方法给ThreadLocal存放一个初始值。

②得到当前线程对象,得到map,如果map为null,new一个ThreadLocalMap对象并赋值给线程对象中的变量,添加数据。

③如果不为null,设置默认值。

④返回默认值

1.3.2set方法

①得到当前线程对象,判断map是否存在

②不存在,创建map,添加数据

③存在,直接添加数据

image.png

1.3.3remove方法

判断map是否存在,存在就调用ThreadLocalMap的remove,删除数据。

image.png