浅谈ThreadLocal实现原理

2023年 10月 16日 60.9k 0

ThreadLocal是什么?

This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable. ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).

翻译如下:

此类提供线程局部变量。这些变量与普通变量的不同之处在于,每个访问一个变量(通过其get或set方法)的线程都有自己的、独立初始化的变量副本。ThreadLocal实例通常是类中的私有静态字段,这些字段希望将状态与线程(例如,用户ID或事务ID)相关联。

简单来说,ThreadLocal可以实现无锁同步,实现每个线程都独享一份的数据副本

ThreadLocal的工作原理是什么?

在Thread中,会有一个ThreadLocalMap的成员变量,这个ThreadLocalMap中有一个Entry数组,这个Entry数组中存放的Entry就是我们要存放的value

他们之间的层级关系图如下:

ThreadLocal原理.png

所以说ThreadLocal其实就是这个Entry里面的key

接下来看下ThreadLocal最常见的get和set的实现原理

get

// ThreadLocal.get
public T get() {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // getMap返回的就是t的ThreadLocalMap,这个ThreadLocalMap是每个线程独自一份的
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 通过传入ThreadLocal本身来获取Entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            // 返回entry中的value
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

从map中获取entry的逻辑是在getEntry中,直接看ThreadLocalMap是怎么获取entry的

// ThreadLocalMap.getEntry
private Entry getEntry(ThreadLocal key) {
    // 这里的table指的就是ThreadLocalMap的Entry数组,table的大小也限制了必须是2的n次方
    // 所以这里的意思就是获取threadLocal的threadLocalHashCode,然后根据table的大小算出key的下标
    int i = key.threadLocalHashCode & (table.length - 1);
    // 获取这个下标的entry
    Entry e = table[i];
    // 如果entry不是空,且entry的弱引用指向的ThreadLocal正好等于key,那么就可以返回entry了
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}

这里解释一下最后面的这个e.get()是什么意思,这个e.get()其实是Reference的get方法

在看e.get之前,先看看Entry的结构

通过源码可以知道,Entry中的ThreadLocal属性并不是像value一样用强引用指向的,而是使用了super将ThreadLocal传进去的

// ThreadLocal.ThreadLocalMap.Entry
static class Entry extends WeakReference k = e.get();
	    // 如果key存在,那么修改Entry的value
        if (k == key) {
            e.value = value;
            return;
        }

        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
	// 放入entry对象到table中
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

ThreadLocal是怎么解决哈希冲突的?

在往下研究之前,一定要先看看ThreadLocal是怎么解决哈希冲突的,不然会看得一头雾水

与其他的HashMap不一样,ThreadLocal是通过开放寻址法中的线性探测法来解决hash冲突的

还是看看set方法

// ThreadLocal.ThreadLocalMap#set
private void set(ThreadLocal key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    // 计算下标
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i];e != null;e = tab[i = nextIndex(i, len)]) {
        ThreadLocal k = e.get();
        if (k == key) {
            e.value = value;
            return;
        }

        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
	// 放入entry对象到table中
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}
for (Entry e = tab[i];e != null;e = tab[i = nextIndex(i, len)]) 

在for循环的三句语句里面,描述了三件事情:

  • 找到下标为i的节点e
  • 判断节点e是否为空
  • 下一个循环开始前,把i变成新的下标nextIndex,去下标为新的下标的节点e
  • 所以看下nextIndex是什么意思

    private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
    }
    

    nextIndex方法是在len的范围内返回后一个下标,如果已经是最后一个下标,那么返回0

    另外一个方法,prevIndex同理,是找前一个下标

    所以set方法在计算为下标之后,还是要找到下一个空的槽位,再把新的节点放到这个空槽位上

    这就是典型的线性探测法解决hash冲突

    ThreadLocal什么时候会发生内存泄漏?

    内存泄露好像和ThreadLocal绑定起来了,以至于有些时候一看到ThreadLocal就想到了内存泄露

    当线程的生命周期非常长,比如使用线程池的时候,就可能导致内存泄露,因为一般使用了线程池之后,线程的生命周期和程序的生命周期相同,那么在使用过threadlocal往threadlocalmap中存放value之后,如果使用完没有remove,那么这个value就会一直存在于threadlocalmap里面,无法被释放,即使这个threadlocal再也不会使用了,这就造成了内存泄漏

    我并不认为ThreadLocal的内存泄露跟弱引用有什么关系,单纯就是再也不用了却没有回收内存

    (以上是针对不考虑threadlocal的回收机制而言)

    为什么ThreadLocalMap的Entry对threadlocal的引用要使用弱引用?

    重头戏来了,ThreadLocal为什么是作为弱引用被放到ThreadLocalMap中的呢?

    弱引用key也是ThreadLocal老生常谈的话题,网上对它的文章也很多,甚至有些文章堂而皇之的描述ThreadLocal出现内存泄露的原因是因为弱引用key,对于这种观点,我觉得不然,我认为ThreadLocal的弱引用key恰好是用来缓解内存泄露的手段

    弱引用,是指当下次内存回收时,会回收掉弱引用指向的对象,也就是说只有这个对象只有弱引用指向,而没有其他强引用指向时,它会在下一次gc中被回收

    所以当一个threadlocal的所有强引用都消失时,那么它和它对应的value也应该被回收,因为除了反射强制获取外,我们再也不能通过任何手段访问到这个threadlocal以及它对应的value了,那么它理所应当被回收;这就像在Java里面,没有一个引用指向一个对象时,它就会被标记为一个垃圾对象一样

    假如ThreadLocalMap中的Entry对threadlocal是强引用,那么不管ThreadLocalMap外是否还有对threadlocal的强引用,threadlocal都不会被回收掉,因为它始终有Entry对它的强引用;所以解决的办法很简单,就是对threadlocal使用弱引用,当ThreadLocalMap之外的对threadlocal的所有强引用都消失时,这个threadlocal仅仅只会有一个Entry对它的弱引用,那么在下次gc的时候,就可以回收掉这个threadlocal对象了

    那回收掉threadlocal又如何呢?value还在呀

    还记得之前说过,entry中有个继承了Reference的get方法,如果threadlocal被回收了,那么e.get方法返回的就是null了

    那么是否可以通过这个e.get返回的是null来判定这个entry不再使用应该被清理了呢?我觉得是可以的

    因为与hashmap可以存储key为null的Node不一样,ThreadLocal中不可能存储key为null的Entry,ThreadLocal可不由得使用者这么任性(回忆一下set的过程就知道不可能)

    所以key为null的entry节点是一个需要被回收的节点,在ThreadLocal中,这种entry被称为StaleEntry,过期键值对

    那么断开entry对value的引用,再断开ThreadLocalMap对这个entry的引用,释放entry,就能达到回收这个entry节点的效果了

    最后总结下来,Entry对threadlocal的弱引用能让jvm感知entry对象是否需要被回收,这也方便之后对entry的回收

    ThreadLocal什么时候会清理过期键值对?

    先看看清理过期键值对的方法

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

    大致做了这些事情:

  • 断开entry中对value的引用,断开threadlocalMap对entry的引用,使得gc时可以回收该entry,size减一
  • 接着从下一个位置开始遍历,如果遇到了过期键值对,那么清理,size减一;如果不是过期键值对,那么进行rehash,rehash的原因是把这些entry移到那些原本有哈希冲突的、后来被清理掉的过期键值对的槽上
  • 遇到null,结束循环,返回当前下标
  • 粗略数了一下ThreadLocal有5个时机会清理过期键值对:

  • remove
  • get时,计算出来的下标对应的entry的key不是期望的threadlocal
  • set时,遍历到过期键值对的时候
  • set时,遍历到null的位置时,会清理一些过期键值对
  • 全量rehash
  • 第一种

    // ThreadLocal.remove
    private void remove(ThreadLocal key) {
        Entry[] tab = table;
        int len = tab.length;
        int i = key.threadLocalHashCode & (len-1);
        // (这是一个for循环)
        for (Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
            if (e.get() == key) {
                e.clear();
                expungeStaleEntry(i);
                return;
            }
        }
    }
    

    remove,找到目标entry之后,调用expungeStaleEntry方法清理

    第二种

    // 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) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
    
    // ThreadLocal.ThreadLocalMap#getEntry
    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);
    }
    

    get的时候,第一次没有命中期望的entry,就需要进入getEntryAfterMiss方法

    // ThreadLocal.ThreadLocalMap#getEntryAfterMiss
    private Entry getEntryAfterMiss(ThreadLocal key, int i, Entry e) {
        Entry[] tab = table;
        int len = tab.length;
    
        while (e != null) {
            ThreadLocal k = e.get();
            if (k == key)
                return e;
            if (k == null)
                expungeStaleEntry(i);
            else
                i = nextIndex(i, len);
            e = tab[i];
        }
        return null;
    }
    

    在这个方法里面找到目标entry就返回,找到了过期的entry就要调用expungeStaleEntry进行清理

    第三种&第四种

    // ThreadLocal.ThreadLocalMap#set
    private void set(ThreadLocal key, Object value) {
        Entry[] tab = table;
        int len = tab.length;
        int i = key.threadLocalHashCode & (len-1);
    
        for (Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
            ThreadLocal k = e.get();
            // 第三种
            if (k == key) {
                e.value = value;
                return;
            }
    
            if (k == null) {
                replaceStaleEntry(key, value, i);
                return;
            }
        }
    
        tab[i] = new Entry(key, value);
        int sz = ++size;
        // 第四种
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
    }
    

    在set里面,如果遍历到了过期的entry,会用调用replaceStaleEntry将要set的键值对替换掉过期的键值对

    不过在replaceStaleEntry里面,不仅仅只是替换这么简单,这个方法里面还有着比较复杂的清理机制和调整规则,其中cleanSomeSlots也在这里面,这里不展开讲述了,如果有兴趣可以研究一下

    在set里面,遍历到null时,会退出for循环,放入新的键值对,然后进行cleanSomeSlots

    第五种

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

    在set最后一行中,如果做了一次清理过期键值对之后,entry的数量还是超过了扩容阈值,就要调用rehash方法

    这个rehash方法是全量的rehash,不是上文提到的局部rehash

    private void rehash() {
        expungeStaleEntries();
    
        // Use lower threshold for doubling to avoid hysteresis
        if (size >= threshold - threshold / 4)
            resize();
    }
    
    private void expungeStaleEntries() {
        Entry[] tab = table;
        int len = tab.length;
        for (int j = 0; j < len; j++) {
            Entry e = tab[j];
            if (e != null && e.get() == null)
                expungeStaleEntry(j);
        }
    }
    

    在这个expungeStaleEntries会清理掉ThreadLocalMap中所有过期的键值对

    cleanSomeSlots

    最后还是说一下cleanSomeSlots吧

    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) {
                n = len;
                removed = true;
                i = expungeStaleEntry(i);
            }
        } while ( (n >>>= 1) != 0);
        return removed;
    }
    

    因为这让我想起了redis的过期策略中的定期删除策略:redis在一秒里进行10次检查(次数由hz决定),每次检查会抽查20个key,如果过期了,那么就删除,每次检查如果超过了5个过期的key,那么就会重新进行抽查,每次抽查超过25ms,也会退出循环

    比起redis的定期删除策略,ThreadLocal的删除策略还是算比较简单,大致流程为:每次检查扫描log2 n个键值对,如果在扫描的过程中,发现了过期的键值对,那么重置n并调用expungeStaleEntry清除过期的键值对

    为什么强调使用完ThreadLocal之后要调用remove?

    即使ThreadLocal中有那么多时间点可以回收过期的entry,但是在ThreadLocal的注释中有这么一句话

    ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread 
    

    意思是ThreadLocal实例通常是类中的私有静态字段

    也就是一般来说,ThreadLocal总是会有强引用指向,上面说的那些方法根本不能处理这些被类持有的ThreadLocal对象

    所以在使用完ThreadLocal之后,最好手动调用remove方法,断开entry对value的引用以及threadlocalMap对entry的引用,释放entry

    ThreadLocal可以在什么场景下使用?

    保存用户登陆后的用户信息;注意不是拿来做登录,它可以结合其他的登录方式一起使用,它是用来在登录之后,代替session来存储用户信息,可以拿它来做工具类

    处理连接;比如mvc里面的dao层一般都是单例的,但是这么多线程访问,会造成线程不安全,所以spring在处理与数据库的连接的时候,每个线程会先使用threadlocal获取Connection,如果Connection是空的,那么说明没有连接,然后会进行创建,放到threadlocalmap里面,以此保证每个线程一个Connection,从而保证线程安全

    代替显示传参;把参数放在ThreadLocalMap里面,然后线程跑到其他方法之后,可以通过threadlocal取到参数,从而避免了显示传参

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论