ThreadLocal源码分析

1.前言

1.1 ThreadLocal基本原理

ThreadLocal 是 Java 中的一个非常有用的类,它提供了一种线程局部变量,即每个线程都可以访问到自己独立初始化过的变量副本,这个变量对其他线程是不可见的。最常见的用法就是用户请求携带用户ID请求某个接口的时候,在整个链路中需要用户信息的时候,通过AOP将用户信息查出来放到ThreadLocal当中。

其本质上是将共享变量放到每个线程的ThreadLocalMap成员变量中,更直白的说就是把这个变量的副本存到了Thread中。ThreadLocalMap是一个重写的HashMap,重写目的在于将Map的Key设置为弱引用(当发生GC时,不管内存空间是否充足,都会对弱引用的对象进行回收),方便垃圾回收。后面我们会具体讲到。

1.2 有关Thread的前置知识

java 复制代码
// 与此线程相关的 ThreadLocal 值
ThreadLocal.ThreadLocalMap threadLocals = null;

// 与此线程相关的 InheritableThreadLocal 值
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

这里我们可以看到Thread类实际上是有两个ThreadLocalMap类型的成员变量的。其中inheritableThreadLocals的主要作用是用于父子线程之间的共享变量传递。

当我们创建Thread的时候,通过其构造函数,最终会执行到Thread的init()方法:

java 复制代码
public Thread(Runnable target) {
    init(null, target, "Thread-" + nextThreadNum(), 0);
}
java 复制代码
private void init(ThreadGroup g, Runnable target, String name,
                  long stackSize, AccessControlContext acc,
                  boolean inheritThreadLocals) {
    ...
    
    // 如果允许子线程共享父线程的变量副本,并且父线程的变量副本集合不为空,那么子线程将复制一份父线程的变量副本集合
    if (inheritThreadLocals && parent.inheritableThreadLocals != null)
        this.inheritableThreadLocals =
            ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);

    ...
}

这里我们可以看到,子线程被创建的时候,会判断父线程的inheritThreadLocals是否为null,如果不为null会复制一份。复制一份的目的就是为了能将父线程的变量副本传递给子线程。

2.ThreadLocal

2.1 ThreadLocal成员变量

java 复制代码
// ThreadLocal的哈希值
private final int threadLocalHashCode = nextHashCode();

// 要给出的下一个哈希代码。以原子方式更新。从零开始。
private static AtomicInteger nextHashCode = new AtomicInteger();

// HASH_INCREMENT: 表示hash值的增量
// 每创建一个ThreadLocal对象,ThreadLocal.nextHashCode的值就会增长HASH_INCREMENT(0x61c88647)
// 这个值很特殊,它是斐波那契数也叫黄金分割数。hash增量为这个数字,带来的好处就是hash分布非常均匀。
private static final int HASH_INCREMENT = 0x61c88647;
java 复制代码
/**
 * 返回下一个Hash Code
 */
private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

前面我们也讲过了,共享变量实际上是存放在Thread的ThreadLocalMap中,并且这个Map的Key是ThreadLocal。

这里我们可以看到会调用nextHashCode()方法生成ThreadLocal的哈希值,nextHashCode()方法通过名为nextHashCodeAtomicInteger类型变量自增获取下一个HashCode。可以注意到这里nextHashCode被static修饰,意味着是一个类变量。有关于AtomicInteger大家可以去看看相关博客,其本质上是通过CAS实现自旋更新保证线程安全,避免了加互斥锁导致的资源开销。

2.2 关键源码

set()
java 复制代码
public void set(T value) {

    // 获取当前线程
    Thread t = Thread.currentThread();

    // 获取线程本地的ThreadLocalMap
    ThreadLocalMap map = getMap(t);

    
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}
java 复制代码
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}
java 复制代码
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    
    // 初始化table,默认长度是16
    table = new Entry[INITIAL_CAPACITY];
    
    // 通过ThreadLocal对象的hashCode与INITIAL_CAPACITY-1进行与运算得出其应该存放元素的下标
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    
    // 将值存入散列表table中并更新size为1
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    
    // 设置扩容阈值,初始值为len * 2 / 3
    setThreshold(INITIAL_CAPACITY);
}

private void setThreshold(int len) {
    threshold = len * 2 / 3;
}

这里我们可以看到,ThreadLocal的set方法实际上是获取了Thread本地的ThreadLocalMap,然后对这个map进行操作。这里如果判断Thread本地的ThreadLocalMap还没有进行初始化,那么就将进行初始化。

get()
java 复制代码
public T get() {
    
    // 获取当前线程
    Thread t = Thread.currentThread();
    
    // 得到当前线程的ThreadLocalMap,底层由哈希表实现
    ThreadLocalMap map = getMap(t);
    
    // (1)如果map已经初始化,则进行读操作
    if (map != null) {
        // 获取键值对对象
        ThreadLocalMap.Entry e = map.getEntry(this);
        
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // (2)如果map没有进行初始化则调用setInitialValue方法初始化map并返回null作为结果
    return setInitialValue();
}
java 复制代码
private T setInitialValue() {
    
    // 获取初始值null
    T value = initialValue();
    
    // 获取当前线程的ThreadLocalMap
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    
    // 判断map是否已经初始化,未初始化则进行初始化
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    
    return value;
}

3.ThreadLocalMap

前面已经提到了,ThreadLocalMap是一个重写的HashMap,目的就是为了将Key设置为弱引用,方便进行垃圾回收。我们可以看一下ThreadLocalMap的内部类Entry,Entry继承了WeakReference,简单来说就是Entry弱引用于ThreadLocal对象。

java 复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

ThreadLocalMap在进行set/map操作以后会触发清除操作,ThreadLocalMap中的清除操作具体可以分为启发式清除和探测式清除,这个我们后面也会细讲。

3.1 核心成员变量

java 复制代码
// 初始化当前map内部散列表数组的初始长度 16
private static final int INITIAL_CAPACITY = 16;

// threadLocalMap 内部散列表数组的引用,数组的长度必须是2的次方数
private Entry[] table;

//当前散列表数组占用情况,存放多少个Entry。
private int size = 0;

/**
 * 扩容触发阈值,初始值为: len * 2/3
 * 触发后调用 rehash() 方法。
 * rehash() 方法先做一次全量检查全局过期数据,把散列表中所有过期的entry移除。
 * 如果移除之后,当前散列表中的entry个数仍然达到(threshold - threshold/4),
 * 即,当前threshold阈值的3/4就进行扩容。
 */
private int threshold; 

3.2 nextIndex()与prevIndex()

在接下来的源码分析中,你会看到很多个循环,每个循环中的下标偏移基本上都是通过这两个方法完成的。提前了解这两个方法有助于我们更好的理解代码。

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

3.3 探测式清除与启发式清除

探测式清除:expungeStaleEntry()

探测式清除将遍历散列数组,从开始位置向后探测清理过期数据。

  • 沿途遍历中如果遇到过期数据(key为null的Entry),则将其清空也就是将该槽位置为null
  • 沿途遍历中如果遇到未过期数据则进行rehash,将其放到其应该存放的位置,如果遇到哈希冲突则将其放到最近的一个空槽中

这里我们要尤其注意,ThreadLocalMap采用开放地址法解决哈希冲突,删除的过期元素是多个冲突元素重的一个,删除以后需要将冲突的元素向前挪动,这么做的目的是为了寻找元素的时候,避免遇到null就停止寻找了,前面key=null的Entry已经被置为null,如果不移动的话后面因为开放地址法偏移的元素就无法被访问。

你可以结合这些图来理解下探测式清除

第一步 ,清除掉沿途中过期的Entry

第二步 ,将偏离正确槽的Entry进行rehash,让它离正确槽更近一些

最后直到遇到为null的Entry停止探测式清除。

java 复制代码
private int expungeStaleEntry(int staleSlot) {

    Entry[] tab = table;
    int len = tab.length;

    // 将staleSlot位置的过期Entry置为null
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    Entry e;
    int i;
    // 以staleSlot为起始下标,向后扫描,直到遇到为null的Entry停止扫描
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        
        ThreadLocal<?> k = e.get();
        
        // (1)如果遇到过期Entry,将其置为null
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        // (2)如果未过期的Entry,将其进行哈希计算,如果是因为开放地址法向后移动的Entry,则将其重新放回对应的位置
        } else {
            // ThreadLocalMap采用开放地址法解决哈希冲突,删除的过期元素是多个冲突元素重的一个,删除以后需要将冲突的元素向前挪动,
            // 这么做的目的是通过开放地址法寻找元素的时候,避免遇到null就停止寻找了,前面key=null的Entry已经被置为null,如果不移动的话后面的元素就无法被访问
            int h = k.threadLocalHashCode & (len - 1);
            // 不相等说明hash是有冲突的
            if (h != i) {
                tab[i] = null;

                while (tab[h] != null)
                    h = nextIndex(h, len);
                
                tab[h] = e;
            }
        }
    }
    return i;
}
启发式清除:cleanSomeSlots()

探测式清理是以当前Entry 往后清理,遇到值为null则结束清理,属于线性探测清理。

而启发式清除则是非线性的扫描部分槽位,检测是否有过期数据。实际上他还是调用的探测式清除,只不过针对于探测式清除,这个起始下标并不是连续的。

java 复制代码
private boolean cleanSomeSlots(int i, int n) {
    
    // 标记是否有过期Entry被清除
    boolean removed = false;
    
    Entry[] tab = table;
    int len = tab.length;
    
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];

        // 有过期数据,则开始进行探测式清除,并且将清除标识removed置为true
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    
    return removed;
}

3.4 set()

前面讲了那么多,现在才是本文的重点。ThreadLocal的set/get方法实际上都是调用了ThreadLocalMap的set/get方法。既然是哈希表,就会遇到哈希冲突,ThreadLocalMap通过开发地址法来解决哈希冲突。

整体的代码逻辑实际上分为四种情况

  1. 通过哈希计算后的槽位对应的Entry为空
  2. 通过哈希计算后的槽位对应的Entry不为空,并且key相等
  3. 通过哈希计算后的槽位对应的Entry不为空,但是该Entry的key为null
  4. 通过哈希计算后的槽位对应的Entry不为空,并且该Entry的key不相等,通过线性探测法一直向后扫描一直没有找到所需要的Entry,直到遇到为null的Entry停下

了解了这四种情况,可以结合着这张图去理解这个逻辑:

这里假如有一个数据为27,经过哈希寻址,它将被存放在下标为4的槽中。

  • 第一种情况和第四种情况将会直接创建新的Entry放到对应位置,并且会进行一次启发式清除,如果启发式清除没法扫描到过期元素,并且当前Entry数组容量超过了扩容阈值,则执行rehash()函数。
  • 第二种情况直接进行值覆盖操作然后返回。
  • 第三种情况则是调用replaceStaleEntry进行清除操作。
java 复制代码
private void set(ThreadLocal<?> key, Object value) {
    
    Entry[] tab = table;
    int len = tab.length;
    
    // 通过哈希计算获取对应槽位的下标
    // 这里为什么是&(len - 1)而不是%len,这个其实是为了提高哈希寻址的效率,感兴趣的同学可以搜搜
    int i = key.threadLocalHashCode & (len-1);
    
    // 1.通过哈希计算后的槽位对应的Entry数据为空,走第四种情况对应的相同逻辑
    for (Entry e = tab[i];
         // 这个循环条件非常关键,一开始没有进入循环和进入循环后不满足条件跳出循环是两种情况
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
        
        // 2.槽位对应的Entry不为空,并且key相等
        if (k == key) {
            e.value = value;
            return;
        }
        
        // 3.槽位对应的Entry不为空,但是该Entry的key为null
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    
    // 4.对应的槽位的Entry不为空,并且该Entry的key不相等,通过线性探测法一直向后扫描,直到遇见Entry为null的槽位停下,创建新的Entry
    tab[i] = new Entry(key, value);
    int sz = ++size;
    
    // 启发式清除过期数据,如果未清理到任何数据则返回false,并判断size是否已经超过了扩容阈值,达到以后则进行一次rehash()
    // reHash实际上还是进行一次探测式清除,并且清除完以后,会Entry数组的size(不是length)判断是否需要进行扩容
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

看完了set方法大概的逻辑我们接着来看replaceStaleEntry()方法的。

在看replaceStaleEntry()方法之前,我们需要明确一个背景,这个方法是在set()方法执行中遇到了过期数据的时候进入的。

replaceStaleEntry()方法我们需要注意两个指针:staleSlotslotToExpunge

  • staleSlot指向本方法进入时的过期数据的位置,从始至终不会改变
  • slotToExpunge则是指明清楚操作的初始下标,这个指针会更新。

整个replaceStaleEntry()方法实际上由两个循环构成:

  1. 循环一:从当前的staleSlot向前遍历,查找其他过期的数据(key为null的Entry),不断地更新过期数据起始扫描下标slotToExpunge,直到遇到为null的Entry停下
  2. 循环二:从当前staleSlot向后查找key值相等的Entry元素。如果找到,则将这个元素放到staleSlot指向的位置

当第二段循环遇到为null的Entry以后,将会跳出循环,意味着没有找到对应的数据,那么将在staleSlot的位置创建一个新的Entry。

在循环二中如果覆盖了值,或者在循环二结束后创建了新的值以后,都会开启一轮扫描清除操作。

具体的代码流程可以结合图片来理解。



如果在循环二中遇到了为null的Entry但是仍然没有找到对应的数据,那么就是下面这种情况:

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

    // 循环一
    // 从当前的staleSlot向前遍历,查找其他过期的数据(key为null的Entry),不断地更新过期数据起始扫描下标slotToExpunge,直到遇到为null的Entry停下
    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len)) {
        if (e.get() == null)
            slotToExpunge = i;
    }


    // 循环二
    // 从当前staleSlot向后查找key值相等的Entry元素
    // (1)如果找到则更新并将其替换到staleSlot的位置,
    // (2)如果直到遇到一个为null的Entry,还没有找到对应的key相等的Entry,则创建一个新的Entry放到staleSlot的位置
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        
        ThreadLocal<?> k = e.get();
        
        // 3.1 如果找到key相等的Entry元素,则覆盖值,并且将该Entry替换到staleSlot的位置
        if (k == key) {
            e.value = value;
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;
            
            // 经历第一个循环以后,如果slotToExpunge == staleSlot仍然成立,那么意味着当前下标staleSlot前后都没有过期的Entry,直接开始清除操作
            // 在这里就直接结束了该方法的执行
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }
        
        // key == null并且slotToExpunge == staleSlot,意味着循环一从staleSlot开始到staleSlot结束并没有找到这个过期数据,这个时候将slotToExpunge指向当前下标
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }
    
    // 3.2 如果上面的循环直到遇到了为null的Entry,仍然没有找到对应的key相等的Entry,意味着以该ThreadLocal为key的Entry不存在于数组中,则新建一个放进去
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);
    
    // 如果有其他已经过期的对象,则清理此过期对象
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

3.5 getEntry()

getEntry()方法的流程还是比较简单的

  1. 通过哈希计算获取正确槽位的下标
    1. 如果有数据并且未过期,则直接返回
    2. 如果没有数据,则调用getEntryAfterMiss()方法进行线性查找
  2. 在调用getEntryAfterMiss()方法进行线性查找的过程中
    1. 如果遇到key相等的则直接返回
    2. 如果遇到过期的Entry则进行一次探测式扫描
    3. 如果遇到未过期的Entry且key不相等则换下一个下标

直到最后遇到为null的Entry跳出循环,意味着没有对应的数据,返回null结束。

java 复制代码
private Entry getEntry(ThreadLocal<?> key) {
    
    int i = key.threadLocalHashCode & (table.length - 1);
    
    Entry e = table[i];

    // (1)获取到结果直接返回
    if (e != null && e.get() == key)
        return e;
    // (2)没有则通getEntryAfterMiss查找与当前Entry相邻的其他Entry
    else
        return getEntryAfterMiss(key, i, e);
}
java 复制代码
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    
    Entry[] tab = table;
    int len = tab.length;
    
    while (e != null) {
        ThreadLocal<?> k = e.get();

        // (1)如果遇到key相等的则直接返回
        if (k == key)
            return e;

        // (2)如果遇到过期的Entry则进行一次探测式扫描
        if (k == null)
            expungeStaleEntry(i);
            
        // (3)如果遇到未过期的Entry且key不相等则换下一个下标
        else
            i = nextIndex(i, len);
        
        e = tab[i];
    }

    // 直到遇到为null的Entry则意味着没找到,返回null结束
    return null;
}

3.6 rehash()

set()方法执行的最后一步,进行启发式清除过期数据,如果未清理到任何数据则返回false,并判断size是否已经超过了扩容阈值,如果达到则执行rehash()rehash()实际上还是进行一次探测式清除,并且清除完以后,会以Entry数组的size(不是length)判断是否需要进行扩容。

java 复制代码
private void rehash() {
    expungeStaleEntries();
    
    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);
    }
}

3.7 resize()

扩容的逻辑也非常简单,将原数组扩大两倍,然后将每一个元素重新进行哈希计算将其放到新的Entry数组中。扩大两倍的目的是为了保证Entry数组的大小是2的倍数,这样才能保证i & (len - 1)i & len的结果一样,前面也说过了&的目的就是为了提高寻址效率。

java 复制代码
private void resize() {
    Entry[] oldTab = table;
    
    int oldLen = oldTab.length;
    int newLen = oldLen * 2;
    Entry[] newTab = new Entry[newLen];
    
    int count = 0;
    for (int j = 0; j < oldLen; ++j) {
        Entry e = oldTab[j];
        if (e != null) {
            ThreadLocal<?> k = e.get();
            if (k == null) {
                e.value = null; // Help the GC
            } else {
                int h = k.threadLocalHashCode & (newLen - 1);
                while (newTab[h] != null)
                    h = nextIndex(h, newLen);
                newTab[h] = e;
                count++;
            }
        }
    }
    
    setThreshold(newLen);
    size = count;
    table = newTab;
}

4.更多思考

关于ThreadLocal可能导致的内存泄漏

这是一个很经典的JUC八股问题,网上泛泛而谈弱引用导致内存泄漏,但是都没有细说,我在这里讲一下吧。

在ThreadLocal中,ThreadLocalMap中的Entry弱引用于作为Key的ThreadLocal对象。像我们直接创建一个ThreadLocal类,就是ThreadLocal threadLocal = new ThreadLocal<String>();。下面threadLocal指代变量引用,ThreadLocal指代存放在堆内存中的对象。

这里threadLocal强引用于一个ThreadLocal对象。当垃圾回收时,如果这个强引用还在那么这个ThreadLocal对象就不会被回收。

但是如果threadLocal不再强引用于这个ThreadLocal对象,也就是有关于这个threadLocal变量的代码执行完毕以后,这个时候ThreadLocal对象就只存在Entry对其的弱引用,如果发生垃圾回收那么ThreadLocal对象就会被回收。

但是这个时候Entry中的value并没有被回收,这样就可能导致内存泄漏。

之所以这样设计本身也是为了方便ThreadLocal对象的被回收行为,如果是强引用的话,那么当threadLocal变量不再使用,其原本指向的ThreadLocal对象将迟迟无法被回收。

所以每次在使用完threadlocal时要调用一下remove方法,它会自动把entry移除。

关于InheritableThreadLocal

前面我们提到了InheritableThreadLocal是为了解决父线程的变量副本无法传递给子线程的问题背景产生的。

我们在Thread的init()方法中也看到了,子线程创建的时候会复制父线程不为null的InheritableThreadLocal

InheritableThreadLocal仍然有缺陷,一般我们做异步化处理都是使用的线程池,而InheritableThreadLocal是在new Thread中的init()方法给赋值的,而线程池是线程复用的逻辑,所以这里会存在问题。

参考文章:

相关推荐
阿伟*rui2 小时前
配置管理,雪崩问题分析,sentinel的使用
java·spring boot·sentinel
XiaoLeisj4 小时前
【JavaEE初阶 — 多线程】单例模式 & 指令重排序问题
java·开发语言·java-ee
paopaokaka_luck4 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
dayouziei4 小时前
java的类加载机制的学习
java·学习
Yaml45 小时前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍
小小小妮子~6 小时前
Spring Boot详解:从入门到精通
java·spring boot·后端
hong1616886 小时前
Spring Boot中实现多数据源连接和切换的方案
java·spring boot·后端
aloha_7896 小时前
从零记录搭建一个干净的mybatis环境
java·笔记·spring·spring cloud·maven·mybatis·springboot
记录成长java7 小时前
ServletContext,Cookie,HttpSession的使用
java·开发语言·servlet
睡觉谁叫~~~7 小时前
一文解秘Rust如何与Java互操作
java·开发语言·后端·rust