Java - ThreadLocal原理

2022-11-20 12:06:35

前言

Java开发者想必有一个类听得比较多,也就是ThreadLocal。关于它的话题也是比较多的:

  • 内存泄漏。
  • 线性安全。
  • 弱引用。

那么本文就来探究一下。

一. ThreadLocal的原理

要知道ThreadLocal的原理,我们首先应该去了解它的一个简单的用法。ThreadLocal用起来并不复杂,就是一个get、set罢了。

1.1 ThreadLocal 案例

我们看一个案例:

publicclassTest{publicstaticfinalThreadLocal<String> THREAD_LOCAL=newThreadLocal<>();@org.junit.Testpublicvoidtest(){
        THREAD_LOCAL.set("Hello");String s= THREAD_LOCAL.get();System.out.println(Thread.currentThread().getName()+", "+ THREAD_LOCAL.get());newThread(()->{
            THREAD_LOCAL.set("Hello2");
            THREAD_LOCAL.set("Hello3");
            THREAD_LOCAL.set("Hello4");System.out.println(Thread.currentThread().getName()+", "+ THREAD_LOCAL.get());}).start();newThread(()->{System.out.println(Thread.currentThread().getName()+", "+ THREAD_LOCAL.get());}).start();}}

程序运行结果如下:
在这里插入图片描述

从这个结果来看,我们可以暂时做出以下结论:

  • 同一个ThreadLocal可以被多个线程使用。并且存储的对象和当前线程绑定。互相不干扰。
  • ThreadLocal在同一个线程里面只会存储一个对象。

1.2 ThreadLocal 元素插入源码分析

我们从set 函数开始分析:

publicclassThreadLocal<T>{publicvoidset(T value){// 拿到当前的线程Thread t=Thread.currentThread();// 根据当前线程拿到一个 ThreadLocalMap 实例ThreadLocalMap map=getMap(t);// 如果ThreadLocalMap实例不为空,塞入一个值if(map!=null)
            map.set(this, value);else// 否则创建一个ThreadLocalMap并将值放入其中createMap(t, value);}ThreadLocalMapgetMap(Thread t){return t.threadLocals;}}

我们可以发现,ThreadLocalMap实例来自于Thread对象中的threadLocals属性。我们来看下:

publicclassThreadimplementsRunnable{ThreadLocal.ThreadLocalMap threadLocals=null;}

好巧不巧的是,从Thread源码中可以发现,ThreadLocalMapThreadLocal的一个内部类:

publicclassThreadLocal<T>{// 无参构造,什么也没有做publicThreadLocal(){}staticclassThreadLocalMap{staticclassEntryextendsWeakReference<ThreadLocal<?>>{/** The value associated with this ThreadLocal. */Object value;Entry(ThreadLocal<?> k,Object v){super(k);
                value= v;}}}privateEntry[] table;}

从源码我们可以知道:

  1. 我们在使用ThreadLocal的时候,肯定会new一个对象出来。但是ThreadLocal的无参构造什么也没有做。
  2. 我们在使用ThreadLocal存储对象的时候,并不是将对象存储在ThreadLocal本身,而是ThreadLocalMap中。
  3. 那么自然而然的,第一次的时候,ThreadLocalMap肯定也是nullThreadLocal构造函数并没有初始化ThreadLocalMap)。因此ThreadLocalMap实例对象需要被创建。因此会走createMap

1.2.1 ThreadLocalMap的创建

我们来继续分析createMap函数:

createMap(t, value);
↓↓↓↓↓voidcreateMap(Thread t,T firstValue){// this就是当前的ThreadLocal对象
    t.threadLocals=newThreadLocalMap(this, firstValue);}

因此,ThreadLocalMap实际上就是当前线程Thread的一个全局变量threadLocals。并且我们可以初步判断出来,ThreadLocal中存储的是当前线程的一个本地变量。

  • 为什么是当前线程?因为set操作的时候,调用了Thread t = Thread.currentThread();函数。
  • 数据存储哪了?数据存储到ThreadLocalMap中,而ThreadLocalMap绑定于当前线程t 中。

ThreadLocalMap的初始化和HashMap的很多地方有几分相似。

// 第一次创建ThreadLocalMap的时候,调用的构造函数ThreadLocalMap(ThreadLocal<?> firstKey,Object firstValue){// 初始化Entry数组,大小16
    table=newEntry[INITIAL_CAPACITY];// 和数组长度取模,计算索引int i= firstKey.threadLocalHashCode&(INITIAL_CAPACITY-1);// 往对应哈希槽中塞数据
    table[i]=newEntry(firstKey, firstValue);// 当前的元素个数是1
    size=1;// 设置阈值为16setThreshold(INITIAL_CAPACITY);}

只不过和HashMap不同的是,Entry类并不具备链表结构。因此它是一个单一的、没有嵌套结构的对象。这也是为什么,在同一个线程下,ThreadLocal中存储的对象只有一个了。

那么接下来就来看下具体的元素插入动作吧。如果ThreadLocalMap已经被创建出来了,那么就会走ThreadLocalMap.set()函数:

privatevoidset(ThreadLocal<?> key,Object value){Entry[] tab= table;int len= tab.length;// 计算当前ThreadLocal作为key时,应该将元素插入到哪一个槽下int i= key.threadLocalHashCode&(len-1);for(Entry e= tab[i]; e!=null; e= tab[i=nextIndex(i, len)]){ThreadLocal<?> k= e.get();// 如果发现存在相同的ThreadLocal对象,那么将值进行覆盖if(k== key){
            e.value= value;return;}// 如果此时ThreadLocal为null,说明此时的ThreadLocal虚引用可以被GC回收if(k==null){replaceStaleEntry(key, value, i);return;}}// 此时代表下标为i的位置上,没有元素,将新的Entry插入到里面
    tab[i]=newEntry(key, value);int sz=++size;// 超过阈值了,扩容等。if(!cleanSomeSlots(i, sz)&& sz>= threshold)rehash();}

我们分几个点来讲解。

1.2.2 开放地址法

首先第一个就是源码中的for循环,跳出循环有这么几种条件:

  • 如果是相同的ThreadLocal对象,此时旧值被更新为新值。
  • 如果ThreadLocalnull,说明当前对象可被回收。
  • 否则就是不断地在Entry数组中寻找,直到某个下标对应的元素为null。然后跳出循环。

前面我们说过ThreadLocal并不像HashMap那样,为了解决哈希冲突,采用数组+链表的形式来存储。 其次Entry本身就不具备链表的结构。那么在遇到哈希冲突的时候,是如何解决的呢?就是所谓的开放地址法。

注意:这里的哈希冲突,发生在不同的ThreadLocal实例之间,因为相同的ThreadLocal实例,value值直接被替代

遇到哈希冲突了,有两个方向可供选择:

  • 如果对应位置的keynull:说明它处于可被回收的状态,那么进行替换操作。
  • 否则:那就往后遍历其他的Entry元素,直到满足上述循环跳出条件为止,否则一直循环。

第一个我们好理解,竟然这个位置都是null了,那么我们就用它就好了。那么第二点是怎么实现的?我们可以看for循环中的这么一截代码:

e= tab[i=nextIndex(i, len)]
↓ ↓ ↓ ↓ ↓ ↓privatestaticintnextIndex(int i,int len){return((i+1< len)? i+1:0);}

很简单,就是取当前下标的下一个元素,如果超过了数组长度,就继续从头开始找。

那么开放地址法的思路也就比较明确了:一旦发生了哈希冲突,那么就去寻找下一个空的地址。

紧接着,我们再来看下,代码中的替换操作。

1.2.3 元素替换和过期元素清除操作

我们都知道的是,ThreadLocal在进行元素插入的时候,会清除Map中所有Keynull的值。而这部分的核心代码就在本小节讲解。

replaceStaleEntry(key, value, i);
↓ ↓ ↓ ↓ ↓ ↓// 这里的key是新的ThreadLocal实例。value是新的值。staleSlot是待清除的一个元素下标privatevoidreplaceStaleEntry(ThreadLocal<?> key,Object value,int staleSlot){Entry[] tab= table;int len= tab.length;Entry e;// 先以当前待删除元素作为一个中轴int slotToExpunge= staleSlot;// 从当前中轴,往左边遍历,找到最外侧key为null的元素(过期元素)。for(int i=prevIndex(staleSlot, len);(e= tab[i])!=null; i=prevIndex(i, len))if(e.get()==null)
            slotToExpunge= i;// 以当前待删除元素作为中轴,往后遍历,同样也找到最外侧为null的过期元素for(int i=nextIndex(staleSlot, len);(e= tab[i])!=null; i=nextIndex(i, len)){ThreadLocal<?> k= e.get();// 如果碰巧找到与当前新key相同的Entry操作,那么执行清理操作,直接返回。if(k== key){// 新值替换旧值
            e.value= value;
            tab[i]= tab[staleSlot];
            tab[staleSlot]= e;// 意思是,当前待删除元素的左侧,没有需要被清理的元素,那么自然而然的slotToExpunge的值就是staleSlot本身if(slotToExpunge== staleSlot)
                slotToExpunge= i;// 旧数据的清理操作cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);return;}// 往前遍历的时候,找不到旧值,并且有空位置,那么往后遍历尝试找旧值if(k==null&& slotToExpunge== staleSlot)
            slotToExpunge= i;}// 将新的值赋值到当前待清除节点上
    tab[staleSlot].value=null;
    tab[staleSlot]=newEntry(key, value);// 如果有其他过期的对象,需要清理它if(slotToExpunge!= staleSlot)cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);}

总结下就是:

  1. 当前待清理位置下标是staleSlot
  2. staleSlot该位置,分别向前和向后寻找第一个keynull的元素。
  3. 然后进行元素的清理操作。

那么接下来我们需要看下元素的清除操作,我们可以发现,代码里面,有段代码被两个地方同时引用到:

cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);

我们先看下expungeStaleEntry函数:同时我们需要明确的是,传入的参数slotToExpunge下标指的是最左侧的一个过期元素的下标。

privateintexpungeStaleEntry(int staleSlot){Entry[] tab= table;int len= tab.length;// 首先清除该位置上对应的引用关系。
    tab[staleSlot].value=null;
    tab[staleSlot]=null;
    size--;// 数组元素-1// Rehash until we encounter nullEntry e;int i;// 意思就是,从最左侧的过期下标开始,往后一个个遍历,去处理for(i=nextIndex(staleSlot, len);(e= tab[i])!=null; i=nextIndex(i, len)){ThreadLocal<?> k= e.get();// 如果对应key为null,那么直接删除对应槽中的元素,并且元素个数-1if(k==null){
            e.value=null;
            tab[i]=null;
            size--;}else{// 如果不为null,说明对应的元素还没有过期。这里简单来说,就是让后面的元素向前移动int h= k.threadLocalHashCode&(len-1);if(h!= i){
                tab[i]=null;while(tab[h]!=null)
                    h=nextIndex(h, len);
                tab[h]= e;}}}return i;}

这里我们可以看到元素清理的一个过程,其中涉及到两个重要的部分:

  • 从当前位置往后遍历,将对应的待删除元素引用设置为null(删除的方式)。
  • 将后面不为null的元素往前移动,(有一种内存碎片整理的味道)。

那么expungeStaleEntry这个函数就已经是删除的一个实际执行者了,那外层的cleanSomeSlots又是干啥的呢?我们来看下源码:

// 它的返回值是布尔类型。代表旧值的Entry是否被删除privatebooleancleanSomeSlots(int i,int n){boolean removed=false;Entry[] tab= table;int len= tab.length;// log2N的复杂度,清除一些null的Entrydo{
        i=nextIndex(i, len);Entry e= tab[i];if(e!=null&& e.get()==null){
            n= len;
            removed=true;// 关键在这里
            i=expungeStaleEntry(i);}}while((n>>>=1)!=0);return removed;}

这个i = expungeStaleEntry(i);在下面set函数里面的最后部分用到了:

if(!cleanSomeSlots(i, sz)&& sz>= threshold)

结合cleanSomeSlots函数的寓意,就是说此时table数组中基本上不包含过期值了,并且元素数量已经到达了阈值,可以进行rehash操作。有什么好处呢?

避免了table数组由于存在大量过期Entry而导致rehash的情况发生。

1.3 ThreadLocal 元素获取源码分析

publicTget(){// 获取当前线程Thread t=Thread.currentThread();// 根据当前线程拿到一个ThreadLocalMapThreadLocalMap map=getMap(t);if(map!=null){// 因为ThreadLocal是Key,也就是这里的this。根据Key拿到对应的槽位ThreadLocalMap.Entry e= map.getEntry(this);if(e!=null){@SuppressWarnings("unchecked")T result=(T)e.value;return result;}}// 几乎用不到returnsetInitialValue();}

我们看下getEntry的源码:

privateEntrygetEntry(ThreadLocal<?> key){// 计算下标int i= key.threadLocalHashCode&(table.length-1);// 拿到对应的Entry对象Entry e= table[i];// 如果当前下标找到了就直接返回,if(e!=null&& e.get()== key)return e;else// 否则做进一步的寻找操作returngetEntryAfterMiss(key, i, e);}
↓↓↓↓↓privateEntrygetEntryAfterMiss(ThreadLocal<?> key,int i,Entry e){Entry[] tab= table;int len= tab.length;// 就是遍历table数组,看看是否有相同的key,找到了直接返回while(e!=null){ThreadLocal<?> k= e.get();if(k== key)return e;// 如果寻找的途中,发现了Key为null的,那就进行垃圾回收if(k==null)expungeStaleEntry(i);else// 就是下标往后推
            i=nextIndex(i, len);
        e= tab[i];}// 如果实在找不到,返回nullreturnnull;}

总结下就是:

  1. 拿到当前ThreadLocal对应的存储下标。对应如果有值就直接返回
  2. 如果没找到,那么尝试在table数组中继续查找是否有相同的key。即二次查找
  3. 二次查找过程中,遇到垃圾,那么就回收。最后找不到的话就返回null

最后来看下元素的删除操作。

1.4 ThreadLocal 元素删除源码分析

publicvoidremove(){ThreadLocalMap m=getMap(Thread.currentThread());if(m!=null)
       m.remove(this);}privatevoidremove(ThreadLocal<?> key){Entry[] tab= table;int len= tab.length;int i= key.threadLocalHashCode&(len-1);// 遍历table数组,发现相同的key就进行删除操作,并进行垃圾回收。for(Entry e= tab[i]; e!=null; e= tab
  • 作者:Zong_0915
  • 原文链接:https://blog.csdn.net/Zong_0915/article/details/127207806
    更新时间:2022-11-20 12:06:35