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

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

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

一、应用场景

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

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

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

二、基础应用

public final class OperationInfoRecorder {

private static final ThreadLocal<OperationInfoDTO> 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();
        }

上面的代码步骤如下:

  1. 计算下标
  2. 如果当前下标无数据,直接进入4。
  3. 如果当前下标有数据,则从当前下标开始向后遍历,每遍历一次,i++
    3.1  如果当前下标桶中的Entry对象的k和需要保存的key相同,直接更新,结束
    3.2  如果当前下标桶中的Entry对象的k和需要保存的key不相同,且k不为空,不处理
    3.3  如果当前下标桶中的Entry对象的k为空,说明当前Entry对象已经失效无用,需要进行进一步处理
    3.4  进入replaceStaleEntry方法,结束
  4. 如果到现在没有结束方法,则创建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方法简单,逻辑步骤如下

  1. 计算下标,通过下标获取元素
  2. 对比下标对应桶中元素的key和要查询k是否相等,如果相等直接返回
  3. 如果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,那就进行扩容

扩容步骤

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

六、ThreadLocal的问题

内存泄露

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

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

垃圾回收后,Entry对象的key变为null,value还被引用着,既然key为null,那么value就不可能再被应用,但是因为value被Entry引用着,Entry被ThreadLocalMap引用着,ThreadLocalMap被Thread引用着,因此线程不结束,那么该回收的内存就会一直回收不了。

很容易出问题的情况就是我们在使用线程池的时候,线程池中的线程都是重复利用的,这时候使用threadLocal存放一些数据的话,如果在线程结束的时候没有显示的做清除处理,就有可能会出现内存泄露问题,甚至导致业务逻辑出现问题。所以在使用线程池的时候需要特别注意在代码运行完后显式的去清空设置的数据,如果用自定义的线程池同样也会遇到这样的问题。此时需要在finally代码块显式清除threadLocal中的数据。

当然对于内存泄露问题,ThreadLocalMap也是做了相关处理的,通过上面的源码知道ThreadLocalMap在get和set以及remove的时候,都会相应的做一次探测式清理操作,但是我们也说了这种清除是小范围的,是不能100%保证能够清理干净的。

我们可以通过以下两种方式来避免这个问题:

把ThreadLocal对象声明为static,这样ThreadLocal成为了类变量,生命周期不是和对象绑定,而是和类绑定,延长了声明周期,避免了被回收;

在使用完ThreadLocal变量后,手动remove掉,防止ThreadLocalMap中Entry一直保持对value的强引用。导致value不能被回收。

threadlocal的继承性

threadlocal不支持继承性:也就是说,同一个ThreadLocal变量在父线程中被设置值后,在子线程中是获取不到的。

但是父线程设置上下文就无法被子线程获取吗?当然不是,thread类除了提供了threadLocals,还提供了inheritableThreadLocals,InheritableThreadLocal继承了ThreadLocal,这个类中的父线程的值就可以在子线程中获取到。此类重写了ThreadLocal的三个方法。

public class InheritableThreadLocal<T> extends ThreadLocal<T> {
    public InheritableThreadLocal() {
    }
    protected T childValue(T var1) {
        return var1;
    }
    ThreadLocalMap getMap(Thread var1) {
        return var1.inheritableThreadLocals;
    }
    void createMap(Thread var1, T var2) {
        var1.inheritableThreadLocals = new ThreadLocalMap(this, var2);
    }
}

此类是如何实现子线程获取父线程保存的值的呢?下面代码是thread类的源码,在创建一个线程时,thread初始化的innt方法中会去判断父线程的inheritThreadLocals中是否有值,如果有,直接赋值给子线程

if (inheritThreadLocals && parent.inheritableThreadLocals != null)
    this.inheritableThreadLocals =
        ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);

InheritableThreadLocals的使用方式

private static final ThreadLocal<OperationInfoDTO> THREAD_LOCAL = new InheritableThreadLocals <OperationInfoDTO>();


责任编辑:武晓燕 来源: 码农本农
相关推荐

2024-01-11 08:12:20

重量级监视器

2021-02-19 10:02:57

HTTPSJava安全

2022-01-05 09:55:26

asynawait前端

2023-06-05 07:57:53

Kafka消息事务消息

2023-12-06 09:10:28

JWT微服务

2022-11-04 08:47:52

底层算法数据

2021-05-11 21:56:11

算法清除JVM

2023-12-20 14:35:37

Java虚拟线程

2021-09-07 10:44:33

Java 注解开发

2022-07-06 13:48:24

RedisSentinel机制

2021-08-05 12:41:57

高并发性能CAS

2024-03-08 07:53:19

LockMonitor线程

2021-12-25 22:31:10

MarkWord面试synchronize

2021-11-08 09:18:01

CAS面试场景

2023-11-15 09:14:27

Java值传递

2022-09-29 07:30:57

数据库索引字段

2024-02-22 15:36:23

Java内存模型线程

2021-12-08 06:53:29

面试动态代理

2021-01-21 07:53:29

面试官Promis打印e

2020-10-24 15:50:54

Java值传递代码
点赞
收藏

51CTO技术栈公众号