浅谈ThreadLocal实现原理

ThreadLocal是什么?

Java 复制代码
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其实就是这个Entry里面的key

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

get

java 复制代码
// 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的

java 复制代码
// 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传进去的

java 复制代码
// ThreadLocal.ThreadLocalMap.Entry
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,

java 复制代码
// WeakReference
public class WeakReference<T> extends Reference<T> {
    public WeakReference(T referent) {
        super(referent);
    }
}

WeakReference又继承了Reference

所以super调用的是Reference的构造函数

java 复制代码
// Reference
Reference(T referent) {
    this(referent, null);
}

Reference(T referent, ReferenceQueue<? super T> queue) {
    this.referent = referent;
    this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
}

可以看到传入的ThreadLocal对象被赋值给了referent

java 复制代码
private T referent;         /* Treated specially by GC */

Java中有四种引用,强引用,软引用,弱引用,虚引用,这里的ThreadLocal用的就是弱引用

然后回到刚才的e.get(),刚才说了这个e.get调用的是Reference的get

java 复制代码
// Reference.get
public T get() {
    return this.referent;
}

所以最后总结下来,这个e.get返回的就是e中的threadLocal,因为它使用了弱引用,不像value那样有强引用指向,所以只能通过e.get来获取

set

有了上面的基础,再看看set就比较简单了

java 复制代码
// ThreadLocal#set
public void set(T value) {
    // 根据thread的ThreadLocalMap
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    // 当map不为空时,调用map的set方法绑定threadLocal对应的value
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

在set中,调用了map.set方法

java 复制代码
// ThreadLocal.ThreadLocalMap#set
private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    // 计算下标
    int i = key.threadLocalHashCode & (len-1);
	// 找到下标之后还是得遍历,因为ThreadLocal用的是线性探测法来解决hash冲突的,后文会提到
    for (Entry e = tab[i];e != null;e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> 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方法

java 复制代码
// 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();
}
java 复制代码
for (Entry e = tab[i];e != null;e = tab[i = nextIndex(i, len)]) 

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

  1. 找到下标为i的节点e
  2. 判断节点e是否为空
  3. 下一个循环开始前,把i变成新的下标nextIndex,去下标为新的下标的节点e

所以看下nextIndex是什么意思

java 复制代码
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什么时候会清理过期键值对?

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

java 复制代码
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;
}

大致做了这些事情:

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

粗略数了一下ThreadLocal有5个时机会清理过期键值对:

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

第一种

java 复制代码
// 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方法清理

第二种

java 复制代码
// 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();
}
java 复制代码
// 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方法

java 复制代码
// 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进行清理

第三种&第四种

java 复制代码
// 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

第五种

java 复制代码
if (!cleanSomeSlots(i, sz) && sz >= threshold)
    rehash();

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

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

java 复制代码
private void rehash() {
    expungeStaleEntries();

    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
        resize();
}
java 复制代码
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吧

java 复制代码
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的注释中有这么一句话

vbnet 复制代码
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取到参数,从而避免了显示传参

相关推荐
我要学编程(ಥ_ಥ)1 小时前
滑动窗口算法专题(1)
java·数据结构·算法·leetcode
niceffking1 小时前
JVM 一个对象是否已经死亡?
java·jvm·算法
真的很上进1 小时前
【Git必看系列】—— Git巨好用的神器之git stash篇
java·前端·javascript·数据结构·git·react.js
科研小白_d.s1 小时前
intellij-idea创建html项目
java·html·intellij-idea
林太白1 小时前
❤Node09-用户信息token认证
数据库·后端·mysql·node.js
XXXJessie1 小时前
c++249多态
java·c++·servlet
喝旺仔la1 小时前
VSCode的使用
java·开发语言·javascript
骆晨学长2 小时前
基于Springboot的助学金管理系统设计与实现
java·spring boot·后端
尘浮生2 小时前
Java项目实战II基于Java+Spring Boot+MySQL的大型商场应急预案管理系统(源码+数据库+文档)
java·开发语言·数据库·spring boot·spring·maven·intellij-idea
蒙娜丽宁2 小时前
深入理解Go语言中的接口定义与使用
开发语言·后端·golang·go