面试官问:ThreadLocal中的键为什么是弱引用?

2024年 3月 13日 18.5k 0

ThreadLocal是一个线程安全的,以线程为单位的数据传递工具。广泛应用于多层级数据传递。

一、应用场景

ThreadLocal主要功能是跨层传递参数,比如,Controller层的数据需要在业务逻辑层使用时,除了利用方法的参数传递之外还可以使用ThreadLocal传递。

有时候我们需要从上层传递一个参数到下层的方法,但是下层的方法新增一个参数的话,会违背开闭原则,如果依赖此方法的上层比较多,那修改此方法必然会牵扯很多其他的代码也要改动(代码中难免会遇到这种不合理的代码)因此我们可以通过ThreadLocal来传递这个参数

另外,ThreadLocal在源码中经常被应用,例如,Spring MVC的RequestContextHolder的实现就是使用了ThreadLocal,cglib动态代理中也应用了ThreadLocal等等。

二、基础应用

public final class OperationInfoRecorder {

private static final ThreadLocal THREAD_LOCAL = new ThreadLocal();

    private OperationInfoRecorder() {
    }
    
    public static OperationInfoDTO get() {
        return THREAD_LOCAL.get();
    }
    
    public static void set(OperationInfoDTO operationInfoDTO) {
        THREAD_LOCAL.set(operationInfoDTO);
    }
    
    public static void remove() {
        THREAD_LOCAL.remove();
    }
    
}

//使用
OperationInfoRecorder.set(operationInfoDTO)
OperationInfoRecorder.get()

日常的代码书写中需要注意两点:

  • static确保全局只有一个保存OperationInfoDTO对象的ThreadLocal实例,并且可避免内存泄露;
  • final确保ThreadLocal的实例不可更改。防止被意外改变,导致放入的值和取出来的不一致。

三、架构设计

先来看看ThreadLocal设计的巧妙之处,通过一段源码深入了解

public static void set(OperationInfoDTO operationInfoDTO) {
        THREAD_LOCAL.set(operationInfoDTO);
    }
public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

跟到这里发现获取当前线程,当前线程参与进来了,进入createMap方法

void createMap(Thread t, T firstValue) {
      t.threadLocals = new ThreadLocalMap(this, firstValue);
}

此处实际上就是创建了一个ThreadLocalMap对象,赋值给当前线程的threadLocals属性。

我们去到Thread类中看看这个属性到底是什么

public class Thread implements Runnable {

ThreadLocal.ThreadLocalMap threadLocals = null;

ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

}

可见每个线程对象中都有两个属性,这两个属性都是ThreadLocalMap类型。

看到这里不难想象,ThreadLocal对外声称的数据线程隔离不过是把数据保存到了当前线程对象里面,自然是线程隔离以及线程安全了。

四、数据结构

那么ThreadLocalMap和ThreadLocal是什么关系呢?

图片图片

如图:

ThreadLocalMap内部有一个Entry数组,这个数组中的每个元素都是一个key-value键值对,value是要存储的值,key是通过WeakReference包装的ThreadLocal对象的弱引用,弱引用会在每次垃圾回收的时候被回收。

在代码结构上ThreadLocalMap是ThreadLocal的静态内部类,真正负责存储数据的是ThreadLocalMap。

在应用上,ThreadLocal为ThreadLocalMap提供了对外访问的api,包括set,get,remove。同时ThreadLocal对象的引用又作为ThreadLocalMap中Entry元素的key。

既然是数组,插入数据的时候是怎么解决hash冲突呢?

ThreadLocalMap采用开放寻址法插入数据。就是如果发现hash冲突,就依次向后面的寻找一个空桶,直到找到为止,然后插入进去。

那么为什么使用开地址法?而不是像hash表一样使用链表法呢?

在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。但是反过看,链表法指针需要额外的空间,故当结点规模较小时,开放寻址法较为节省空间,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放寻址法中的冲突,从而提高平均查找速度。并且使用中很少有大量ThreadLocal对象的场景。

五、源码解析

set方法解析

1.第一次set数据

public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
    
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
    
    ThreadLocalMap(ThreadLocal firstKey, Object firstValue) {
            table = new Entry[INITIAL_CAPACITY];
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
            table[i] = new Entry(firstKey, firstValue);
            size = 1;
            setThreshold(INITIAL_CAPACITY);
        }

第一次set数据比较简单,线程中尚未初始化ThreadLocalMap,需要先初始化,初始化步骤如下:

  • 声明数组
  • 计算下标
  • 给对应数组下标赋值
  • 设置当前数组长度size
  • 数组长度计算扩容因子Threshold

1.非第一次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();
        }

上面的代码步骤如下:

  • 计算下标
  • 如果当前下标无数据,直接进入4。
  • 如果当前下标有数据,则从当前下标开始向后遍历,每遍历一次,i++3.1  如果当前下标桶中的Entry对象的k和需要保存的key相同,直接更新,结束3.2  如果当前下标桶中的Entry对象的k和需要保存的key不相同,且k不为空,不处理3.3  如果当前下标桶中的Entry对象的k为空,说明当前Entry对象已经失效无用,需要进行进一步处理3.4  进入replaceStaleEntry方法,结束
  • 如果到现在没有结束方法,则创建Entry赋值给下标i对应的桶,注意这里的i不一定是最开始值了。
  • private void replaceStaleEntry(ThreadLocal key, Object value,
                                           int staleSlot) {
                Entry[] tab = table;
                int len = tab.length;
                Entry e;
    
                int slotToExpunge = staleSlot;
                for (int i = prevIndex(staleSlot, len);
                     (e = tab[i]) != null;
                     i = prevIndex(i, len)){
                    if (e.get() == null)
                        slotToExpunge = i;
                }
                
                for (int i = nextIndex(staleSlot, len);
                     (e = tab[i]) != null;
                     i = nextIndex(i, len)) {
                    ThreadLocal k = e.get();
                    if (k == key) {
                        e.value = value;
                        tab[i] = tab[staleSlot];
                        tab[staleSlot] = e;
                        if (slotToExpunge == staleSlot)
                            slotToExpunge = i;
                        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                        return;
                    }
                    if (k == null && slotToExpunge == staleSlot)
                        slotToExpunge = i;
                }
                
                tab[staleSlot].value = null;
                tab[staleSlot] = new Entry(key, value);
    
                if (slotToExpunge != staleSlot)
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            }

    replaceStaleEntry方法是对set过程中遇到的失效Entry做进一步处理,replaceStaleEntry代码中执行步骤如下:

    1. 从当前下标为staleSlot的地方向左遍历,直到找到第一个空桶停止遍历,此时slotToExpunge=staleSlot,或者直到找到第一个非空桶且Entry对象的key为空为止,此时slotToExpunge为当前桶下标。此处可能说的有点绕,但是相信自己看代码就能明白。

    2. 从当前下标为staleSlot的地方向右遍历,此遍历的目的是为了查看右侧是否存在key相同的Entry,如果有,就更新value,并且和staleSlot下标对应的桶中的失效Entry交换位置,如果没有就直接更新staleSlot下标的桶。

    这里为什么不直接更新staleSlot下标对应的桶呢?

    因为Entry数组插入的时候如果遇到hash冲突(即两个key计算出的下标相同),直接是依次插到后面一个空桶,如果再后来的数据插入的时候发现对应下标的桶已经被占用,这种情况也是向后一个空桶插入。因此可以知道,不直接更新而是向后遍历查看key是否相等,就类似于hash表插入的时候发生hash冲突后对链表的遍历查找。只不过多了一个为止交换。

    3. 每一次插入完成,就要执行expungeStaleEntry方法和cleanSomeSlots方法,这个两个方法都是失效清理方法。

    expungeStaleEntry方法为探测式清理,从给定开始的下标开始向右遍历,直到第一个空桶为止

    private int expungeStaleEntry(int staleSlot) {
                Entry[] tab = table;
                int len = tab.length;
                tab[staleSlot].value = null;
                tab[staleSlot] = null;
                size--;
                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;
            }

    还记得这个变量吗slotToExpunge,这个变量的值是向左遍历得到的第一个Entry失效的桶的下标。

    此方法做的事情就是从这个下标开始向右把失效的Entry全部清除,而把没有失效的Entry重新计算下标,重新按照开放地址法放到数组中。直到第一个空桶停止遍历。并且把当前遍历到的桶的下标返回。

    我们先来总结下这个过程的几个关键点

    • 向左遍历到第一个空桶的位置。
    • 向右遍历的过程中清除失效Entry,重hash有效Entry,直到遍历到第一个空桶为止。

    那么为什么这么做呢?

    首先,之所以只操作两个空桶之间的元素,是因为两个空桶之间的元素都和当前key计算的下标有关系(有可能是hash冲突造成的临近元素),操作这一部分数据可以保证与当前key相关的元素都能得到失效处理。

    然后就是小范围的失效操作,避免大量数据参与,可以提高性能。

    最后是可以使得rehash后的数据距离正确的位置更近一些,能提高整个散列表的查询性能。

    同时这个方法会在set,get,remove,resize方法中反复使用,因此不能大规模扫描。

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

    cleanSomeSlots方法为启发式清理,从给定开始的下标开始向右遍历log2n个位置,对遍历过程中失效元素调用expungeStaleEntry方法,目的也是在不影响性能的基础上尽可能的多的把失效的元素清除。

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

    get方法要比set方法简单,逻辑步骤如下

  • 计算下标,通过下标获取元素
  • 对比下标对应桶中元素的key和要查询k是否相等,如果相等直接返回
  • 如果key不相等,就会走这个getEntryAfterMiss方法
  • getEntryAfterMiss方法就是从当前坐标开始向后检查key是否相等,相等的直接返回,如果失效,就调用expungeStaleEntry做失效处理,如果没有找到就返回null。

    remove方法解析

    private void remove(ThreadLocal key) {
                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)]) {
                    if (e.get() == key) {
                        e.clear();
                        expungeStaleEntry(i);
                        return;
                    }
                }
            }

    remove方法就更加简单了,遍历找到key相等的元素,进行删除,顺便在当前坐标位置开始调用expungeStaleEntry进行失效处理

    扩容解析

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

    扩容机制也比较简单,在扩容前会先调用expungeStaleEntry进行一次失效处理,这此失效处理是在坐标0开始,失效处理结束后如果size >= threshold - threshold / 4,那就进行扩容

    扩容步骤

  • 声明新的数组,是原来数据的2倍
  • 遍历原来的数组,对元素进行重hash计算下标,然后放入新的数组中。
  • 遍历过程中如果遇到失效的元素,value置为空。
  • 重置size,重置table,重新计算扩容因子threshold,(len * 2 / 3)
  • 六、ThreadLocal的问题

    内存泄露

    在ThreadLocalMap中使用WeakReference包装后的ThreadLocal对象作为key,也就是说这里对ThreadLocal对象为弱引用。当ThreadLocal对象在ThreadLocalMap引用之外,再无其他引用的时候能够被垃圾回收

    static class Entry extends WeakReference

    相关文章

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

    发布评论