关于ThreadLocal的详细解析

前言

ThreadLocal 是什么?

ThreadLocal是一个为每个线程创建单独变量副本的类。ThreadLocal主要解决的是让每一个线程绑定自己的值,自己用自己的,不跟别的线程争抢。该类提供了线程局部 (thread-local) 变量。通过使用get()set()方法可以访问线程自己的、独立于初始化的变量副本。即通过ThreadLocal可以让指定的值实现线程隔离(线程之间不存在共享关系),从而避免了线程安全的问题

ThreadLocal 的常用API

  1. public void set(T value):将当前线程的此线程局部变量的副本设置为指定的值

  2. public T get():返回当前线程的此线程局部变量的副本中的值

  3. public void remove():删除此线程局部变量的当前线程的值

  4. protected T initialValue():返回此线程局部变量的当前线程的初始值

  5. public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier):(JDK1.8后加入的)创建线程局部变量,并通过供给者接口设置此线程局部变量的当前线程的初始值(查看源码可以看到其实最后还是重写了initialValue()来设置初始值的)

例子

public static void main(String[] args) {
    // JDK1.8前创建方式(通过重写initialValue设置初始值)
    ThreadLocal<Integer> threadLocal1 = new ThreadLocal<>(){
        @Override
        protected Integer initialValue() {
            return 666;
        }
    };
    // JDK1.8后创建方式
    ThreadLocal<Integer> threadLocal2 = ThreadLocal.withInitial(() -> 999);
    
    // 获取threadLocal1
    System.out.println(threadLocal1.get());
    // 获取threadLocal2
    System.out.println(threadLocal2.get());
    // 设置threadLocal1的值
    threadLocal1.set(6);
    // 获取threadLocal1
    System.out.println(threadLocal1.get());
    // 删除threadLocal1的值
    threadLocal1.remove();
    // 获取threadLocal1
    System.out.println(threadLocal1.get());
}

/*
 测试结果:
    666
    999
    6
    666
*/
复制代码

如果细心观察测试结果的话,会发现执行了remove()方法删除值后通过get查看发现结果竟然不是null,而是又变为了初始值。其实它真的删了,只是后续在调用get()时,底层的执行流程是如果返回不了值那么会调用setInitialValue()重新拿到初始化的值并重新初始化并返回初始化的值。有兴趣的可以接着往下看源码分析。

ThreadLocal 的应用场景举例

这里举一个Java开发手册中推荐使用的例子:使用ThreadLocal实现SimpleDateFormat线程安全。

首先,SimpleDateFormat是线程不安全的,但是开发中经常需要使用它来格式化时间,下面通过代码来演示为什么是线程不安全的。

/**
 * 测试
 * @author 兴趣使然的L
 */
public class SimpleDateFormatTest {
    // 定义时间格式
    private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");

    public static void main(String[] args) {
        // 创建多线程测试
        while (true) {
            new Thread(() -> {
                // 记录格式化当前时间
                String res1 = simpleDateFormat.format(new Date());
                try {
                    // 将上次记录的时间重新格式化
                    Date date = simpleDateFormat.parse(res1);
                    String res2 = simpleDateFormat.format(date);

                    // 比较两次结果是否相同
                    System.out.println(res1.equals(res2));
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

/*
 部分测试结果:
    true
    true
    false
    true
    false
    false
    true
*/
复制代码

可以看到如果是线程安全的情况下,那么返回结果永远为true。SimpleDateFormat线程不安全大致是当多个线程调用format()时会调用calender.setTime(),会导致time被别的线程修改,所以会导致两次结果不相同。

接下来,通过ThreadLocal进行改进。

/**
 * 测试
 * @author 兴趣使然的L
 */
public class SimpleDateFormatTest {

    private static final ThreadLocal<DateFormat> dataFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

    public static void main(String[] args) {
        // 创建多线程测试
        while (true) {
            new Thread(() -> {
                // 记录格式化当前时间
                String res1 = dataFormat.get().format(new Date());
                try {
                    // 将上次记录的时间重新格式化
                    Date date = dataFormat.get().parse(res1);
                    String res2 = dataFormat.get().format(date);

                    // 比较两次结果是否相同
                    System.out.println(res1.equals(res2));
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

/*
 部分测试结果:
    true
    true
    true
    true
    true
    true
    true
*/
复制代码

可以看到通过ThreadLocal可以解决SimpleDateFormat线程不安全问题。

使用 ThreadLocal 的时机

当面对线程不安全的问题时,通常会使用加锁等方式保证线程安全,但是上面的例子并没有使用加锁的方式来避免线程不安全问题,而是使用ThreadLocal的方式。ThreadLocal是通过让每个线程拥有自己的变量来实现线程隔离。所以我认为使用ThreadLocal的时机是当需要处理的线程不安全变量(如SimpleDateFormat)不需要线程之间共享(即不需要每个线程使用同一个变量)时,可以不使用加锁的方式来解决线程不安全问题,而是使用ThreadLocal的方式隔离线程。

当然除了线程安全问题之外,ThreadLocal也可以用作Session管理,或者是保存每个线程都拥有自己的一个值,比如前后端通过token进行鉴权操作时,后端需要将前端的token进行保存,此时可以使用ThreadLocal对token进行存储,在调用API前过滤器可以通过ThreadLocal拿到token进行鉴权操作。


ThreadLocal 的常见问题 & 源码分析

一个小小的 ThreadLocal 其实包含了许多有意思的知识

  • ThreadLocal是怎么实现线程隔离的?
  • ThreadLocal为什么有内存泄露问题?
  • ThreadLocal底层使用了斐波那契散列方式来计算Hash?
  • ThreadLocal使用到了弱引用?
  • ThreadLocal使用了探测式&启发式清理方式来处理过期的Key?
  • ...

接下来从 ThreadLocal 的源码出发,渐进式的了解上面的问题。

1. ThreadLocal怎么实现的线程隔离?

Thread & ThreadLocal & ThreadLocalMap 三者奇妙的关系

  • Thread类有一个ThreadLocal.ThreadLocalMap类型的变量threadLocals,即每个线程都会有一个自己的ThreadLocalMap实例。

  • ThreadLocalMapThreadLocal的静态内部类。并且ThreadLocalMap通过自身的内部类Entry来实现数据的存储,可以简单地看成是类似于HashMap的k-v存储方式,其中的key是ThreadLocal类型,value是Object类型

这三者的关系可能有些混乱,通过ThreadLocal调用get()的过程图理解一下

怎么实现线程隔离的?

Thread类维护了一个变量名为threadLocals的线程版的Map<ThreadLocal,Object>,当需要使用ThreadLocal类来存储数据时,会先拿到ThreadLocal的实例,并ThreadLocal的实例作为key,将要存储的值作为value存入当前线程的threadLocals的Entry中。当需要取出值时,一样通过ThreadLocal的实例作为key,去当前线程维护的threadLocals中取出。通过这样的方式,可以发现,每个线程都维护着自己的threadLocals,即每个线程都有自己的独立变量,这样子在并发的情况下就不会造成线程不安全的问题,因为不存在变量共享,因此实现了线程隔离。


2. ThreadLocal基本方法的源码分析

ThreadLocal的 get() 方法执行流程

  1. get()
public T get() {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 拿到当前线程的threadLocals实例
    ThreadLocalMap map = getMap(t);
    // 判断threadLocals是否初始化
    if (map != null) {
        // 通过this当前ThreadLocal对象实例调用ThreadLocalMap的getEntry()取出结果(后续文章详解)
        ThreadLocalMap.Entry e = map.getEntry(this);
        // 取得出值则返回
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // threadLocals未初始化或threadLocals通过this取出的值为null,则进行初始化
    return setInitialValue();
}
复制代码
  1. getMap()
// 获取线程的threadLocals
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}
复制代码
  1. setInitialValue()
// 初始化方法
private T setInitialValue() {
    // 获取initialValue()的值(初始值),创建ThreadLocal实例时可以通过重写的方式赋上初始值
    T value = initialValue();
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 获取线程的threadLocals
    ThreadLocalMap map = getMap(t);
    // 如果threadLocals尚未初始化,则执行createMap,否者直接设置值
    if (map != null) {
        map.set(this, value);
    } else {
        createMap(t, value);
    }
    if (this instanceof TerminatingThreadLocal) {
        TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
    }
    // 最后返回从initialValue()获取的初始值
    return value;
}
复制代码

4.createMap()

// 创建(初始化)ThreadLocalMap,并通过firstValue设置初始值
void createMap(Thread t, T firstValue) {
    // 这里涉及到ThreadLocalMap后续文章详解
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}
复制代码

总结get()执行流程:

  • 先通过Thread.currentThread()获取当前线程,并通过getMap()获取当前线程的ThreadLocalMap实例threadLocals。
  • 判断获取到的threadLocals是否已经初始化(判断是否为null):
    • threadLocals已经初始化则通过当前ThreadLocal的对象this作为参数(作为ThreadLocalMapEntry的key)调用ThreadLocalMap的getEntry()获取对应的值。
  • 如果 threadLocals未初始化或者通过getEntry(this)获取到的值为空,则调用ThreadLocal的 setInitialValue()对threadLocals初始化
  • setInitialValue()执行过程:
    • setInitialValue()中会调用重写过的initialValue()获取指定初始值(没有重写则为null),再次通过getMap()获取threadLocals对象。
    • 判断threadLocals对象是否为null:已初始化则调用ThreadLocalMap的set(this, value)将当前的ThreadLocal对象作为key设置值value。 未初始化则调用createMap()对threadLocals进行初始化new ThreadLocalMap(this, firstValue))并设置上初始值。

总的来说就是调用get()如果threadLocals存在且能查到值则直接返回,threadLocals不存在或者查不到值(为null)则初始化并返回初始化的值(这个值是通过重写initialValue()赋值的)。


ThreadLocal的 set(T value) 方法执行流程

许多细节地方在get()中提到,这里就不再进行流程总结

public void set(T value) {
    // 1. 获取当前线程
    Thread t = Thread.currentThread();
    // 2. 获取当前线程的threadLocals对象
    ThreadLocalMap map = getMap(t);
    // 3. 判断threadLocals对象是否初始化
    if (map != null) {
        // 已初始化则直接设置值
        map.set(this, value);
    } else {
        // 未初始化则将当前设置的值作为初始值进行初始化
        createMap(t, value);
    }
}
复制代码

ThreadLocal的 remove() 方法执行流程

public void remove() {
    // 直接一步到位获取当前线程的threadLocals
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null) {
        // 通过ThreadLocalMap的remove()删除,后续文章详解
        m.remove(this);
    }
}
复制代码

总结

通过上述的三个ThreadLocal的核心方法可以发现以下几点:

  • ThreadLocal本身并并不存储值,调用ThreadLocalset()方法时,实际上就是往ThreadLocalMap设置值,key是ThreadLocal对象,值Value是传递进来的对象,调用ThreadLocalget()方法时,实际上就是往ThreadLocalMap获取值,key是ThreadLocal对象。
  • 通过new的方式创建ThreadLocal实例时,并没有直接将ThreadLocal实例作为key,初始值作为value去存储进当前线程的threadLocals中,而是在后续调用get()或者set()时才去将当前信息加入Entry中。
  • 一旦调用get()获取值时,只会返回通过set()设置的值或者初始值,不会返回null,除非初始值就是null。

3. ThreadLocal为什么会造成内存泄露?

使用弱引用的ThreadLocalMap

可以看到ThreadLocalMap下的Entry静态内部类的key使用了弱引用。

ThreadLocal的内存泄露问题之一:为什么需要使用弱引用?

通过上图分析,假设现在创建并使用了ThreadLocal,当使用完后,会销毁Stack中的ThreadLocal对象引用,但是还存在Entry对象的Key对象引用了ThreadLocal对象,因此如果Key是强引用,那么会导致ThreadLocal对象即使已经使用完了并且Stack中的引用也销毁了,但是因为还存在Key的引用,所以GC不能回收ThreadLocal对象,所以会造成内存泄露。

所以这就是为什么底层需要Entry的Key需要使用弱引用的原因,使用弱引用,发生GC时,如果对象只被弱引用指向,那么就会被回收。

ThreadLocal的内存泄露问题之二:使用弱引用就能保证100%不会造成内存泄漏吗?

还是通过上图分析,正常情况下是Key对象使用的弱引用可以保证ThreadLocal对象被GC回收,使得Entry出现Key为null,Value对象还存在的情况。那么这样的Entry对象怎么被回收呢,ThreadLocalMap具有清除过期的Key的能力ThreadLocalMap调用get()或者set()方法会检查key为null时会调用清理方法进行清理。具体实现在后续文章的探测式&启发式清理会说明。

但是假设当前情况为:使用线程池来管理线程,那么会存在线程复用的情况,那么Thread对象将不会被销毁,而生产中经常会使用static final修饰声明的ThreadLocal对象来保留一个全局的ThreadLocal对象方便传递其他value,这样会导致ThreadLocal一直被强引用,即使Key是弱引用,但强引用不释放,GC便无法回收,所以这种情况下Key不会为null,同时Value也会有值,那么清理方法也不会清理这样的Entry对象,这样就会导致可能已经不用的ThreadLocal无法被回收,造成内存泄漏。

如何避免ThreadLocal的内存泄露问题?

通过上面两个内存泄露问题的分析可知:如果无法确保线程会结束(比如生产中使用线程池管理线程),一定要记得手动使用remove()方法(底层会将key设置为null,通过这样的方式标识该ThreadLocal对象不再使用,后续会被过期Key清理方法清除Entry),从而避免内存泄漏问题。

Java开发手册中推荐这样使用:


4. ThreadLocalMap的斐波那契散列法

ThreadLocalMap的存储结构

与HashMap不同,ThreadLocalMap采用的是数组结构存储方式,当出现哈希碰撞时,并不是向HashMap那样存在链表或红黑树中,而是采用开放寻址的方式进行存储(遇到哈希碰撞时,会下标往后寻找直到遇到Entry为null时停止寻找,并放入当前Entry)。

ThreadLocalMap在计算Hash时更注重让数据散列均匀,所以ThreadLocalMap采用斐波那契散列法来计算Hash,这样能比较好让数据更加散列,减少哈希碰撞。通过累加 0x61c88647 更新HashCode的值,这里的0x61c88647是一个哈希值的黄金分割点。

源码

// 黄金分割点
private static final int HASH_INCREMENT = 0x61c88647;

// 数组长度
private static final int INITIAL_CAPACITY = 15;

// 原子类记录一直在更新的HashCode
private static AtomicInteger nextHashCode = new AtomicInteger();

// 记录自身对象的HashCode
private final int threadLocalHashCode = nextHashCode();

// 更新HashCode(每创建一个ThreadLocal对象则更新一次,同时新对象会通过threadLocalHashCode记录自己的HashCode)
private static int nextHashCode() {
    // 每次更新都是累加HASH_INCREMENT
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

// 计算下标(取出自身的threadLocalHashCode)
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
复制代码

因为ThreadLocal对象以自身为key,所以每个对象都可以通过threadLocalHashCode记录属于自己唯一的HashCode。在通过key查找下标时也只需要取出自己的threadLocalHashCode作为HashCode进行查找即可。

下面模仿源码的方法实现斐波那契散列查看一下散列效果

/**
 * 看看斐波那契散列有多均匀
 * @author 兴趣使然的L
 */
public class Test {
    // 黄金分割点
    private static final int HASH_INCREMENT = 0x61c88647;

    // 数组长度
    private static final int INITIAL_CAPACITY = 15;

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            // 获取hashCode
            int hashCode = i * HASH_INCREMENT + HASH_INCREMENT;
            // hashCode & (数组长度 - 1) 得到对应得下标位置
            int idx = hashCode & (INITIAL_CAPACITY - 1);
            System.out.println("第 " + i + " 位散列的结果为 " + idx);
        }
    }
}

/*
  结果:
    第 0 位散列的结果为 7
    第 1 位散列的结果为 14
    第 2 位散列的结果为 5
    第 3 位散列的结果为 12
    第 4 位散列的结果为 3
    第 5 位散列的结果为 10
    第 6 位散列的结果为 1
    第 7 位散列的结果为 8
    第 8 位散列的结果为 15
    第 9 位散列的结果为 6
    第 10 位散列的结果为 13
    第 11 位散列的结果为 4
    第 12 位散列的结果为 11
    第 13 位散列的结果为 2
    第 14 位散列的结果为 9
    第 15 位散列的结果为 0
*/
复制代码

可以看到测试结果很均匀,这里计算hashCode时使用的是i * HASH_INCREMENT + HASH_INCREMENT,不同于源码这是因为需要通过加入i计算来作为自己的标识。

对于斐波那契散列法的具体细节这里不再深入,如果开发中用得到斐波那契散列法可以按照上面模仿的代码方式实现即可。


5. ThreadLocalMap基本方法的源码分析

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) 源码分析

// 构造器
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    // 开辟长度为INITIAL_CAPACITY的Entry数组
    table = new Entry[INITIAL_CAPACITY];
    // 通过firstKey自身的Hashcode计算出下标
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    // 存入下标对应的Entry中
    table[i] = new Entry(firstKey, firstValue);
    // 更新size
    size = 1;
    // 设置扩容阈值为传入参数的2/3
    setThreshold(INITIAL_CAPACITY);
}
复制代码

set(ThreadLocal<?> key, Object value) 源码分析

private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    // 计算key的下标
    int i = key.threadLocalHashCode & (len-1);
    
    // 开放寻址方式向后遍历
    for (Entry e = tab[i];
         e != null;  // 直到找到Entry为null
         e = tab[i = nextIndex(i, len)]) {  // nextIndex环状向后遍历(不存在越界问题,到末尾为设置为开头)
        // 获取当前Entry的key
        ThreadLocal<?> k = e.get();
        
        // 情况一:当前key和需要设置的key相同,直接更新值
        if (k == key) {
            e.value = value;
            return;
        }
        
        // 情况二:遇到过期的key,进行替换过期数据操作
        if (k == null) {
            // 替换过期数据,底层调用了启发式清理和探测式清理
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    
    // 情况三:找到空的Entry,直接加入并更新size
    tab[i] = new Entry(key, value);
    int sz = ++size;
    // 最后调用一次启发式清理方法,清理过期的Key。清理完成后如果size还是超过了扩容阈值则进行rehash操作
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
}
复制代码

set()流程概述

  • 通过参数key(目标key)计算出数组的下标位置,从当前下标位置向后遍历Entry数组。
  • 情况一:如果当前遍历的位置的Entry为空,则直接跳出循环,将需要设置的数据存储在当前Entry中。
  • 情况二:如果当前遍历的位置的Entry不为空并且该Entry的key与目标key一致,则直接将需要设置的value覆盖掉该Entry的value即可(覆盖操作)。
  • 情况三:如果当前遍历的位置的Entry不为空并且该Entry的key为null,说明当前的Entry是过期数据,此时执行replaceStaleEntry()替换过期数据(替换操作),详细操作后面讲解。
  • 循环结束后,调用cleanSomeSlots()做一次启发式清理,清理数组中过期的数据。
  • 如果清理后的Entry数量sz还是大于扩容阈值时则 执行rehash() 进行探测式清理以及判断是否需要扩容。(rehash()后续机制中详细讲解)。

补充一下replaceStaleEntry() 源码以及概述

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

    int slotToExpunge = staleSlot;
    
    // 更新slotToExpunge保证其是最前的过期数据下标
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;
    
    // 向后遍历看看是否有一致的key可以覆盖数据
    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()执行流程概述

  • 开始时会创建一个 slotToExpunge 变量用于记录最前的过期数据下标,这个变量后续会用于在探测式清理时当作参数传入
  • 创建时 slotToExpunge = staleSlotstaleSlotreplaceStaleEntry传入的参数表示当前检查出的过期的Entry下标。
  • 通过从staleSlot下标处向前遍历Entry数组,更新slotToExpunge 保证其是最前的过期数据下标。
  • 通过从staleSlot下标处向后遍历Entry数组,如果遇到了当前遍历的Entry的key与目标key一致时,会进行三步处理,执行完后直接返回。
    • ① 直接覆盖value。
    • ② 将当前的Entry与staleSlot下标处的Entry进行交换,把过期数据移到后面。
    • ③ 判断一下slotToExpunge == staleSlot,说明replaceStaleEntry()一开始向前查找过期数据时并未找到过期的Entry数据,接着向后查找过程中也未发现过期数据,修改slotToExpunge = i指直接从当前位置开始执行探测式清理就好了,并且将探测式清理返回的值当作参数传给cleanSomeSlots,进行启发式清理。
  • 如果并未遇到一致的key,则判断一下是否有过期数据并且slotToExpunge == staleSlot,跟上述一样如果条件成立则说明前后遍历时未找到过期数据,所以直接把slotToExpunge设置到当前位置即可(从当前位置进行探测式清理)。
  • 跳出遍历后,则说明没有找到一致的key进行覆盖,此时只能把staleSlot处过期的数据置空并把要存储的新数据存储进staleSlot处的Entry中(这就是上面set()方法说的本质上的替换操作)。
  • 最后就是为准备了好久的slotToExpunge作为参数启动探测式清理,并在探测式清理完后再启动启发式清理

总结:replaceStaleEntry(key, value, staleSlot)方法就是怎么样都会把需要存储的keyvalue存入当前staleSlot下标的Entry中,并且 staleSlot位置的过期数据总会被清理掉,该方法会调用探测式和启发式清理


getEntry(ThreadLocal<?> key) 源码分析

// getEntry()方法
private Entry getEntry(ThreadLocal<?> key) {
    // 计算Entry存放的数组下标
    int i = key.threadLocalHashCode & (table.length - 1);
    // 获得Entry
    Entry e = table[i];
    // 如果Entry不为空且不是过期数据
    if (e != null && e.get() == key)
        // 直接返回
        return e;
    else
        // 否则调用getEntryAfterMiss()开放寻址的方式向后查找
        return getEntryAfterMiss(key, i, e);
}

// getEntryAfterMiss()方法
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;
    
    // 直到Entry为空则退出循环
    while (e != null) {
        // 获取key
        ThreadLocal<?> k = e.get();
        // k == key 表示找到了返回
        if (k == key)
            return e;
        // 遇到过期数据启用探测式清理方式清理
        if (k == null)
            expungeStaleEntry(i);
        else
            // 更新下标
            i = nextIndex(i, len);
        e = tab[i];
    }
    // 找不到返回null
    return null;
}
复制代码

getEntry()流程概述

  • 通过参数key(目标key)计算出数组的下标位置,获得数组的下标位置的Entry。
  • 情况一:如果Entry不为空并且Entry的key与目标key一致时,直接返回Entry。
  • 情况二:如果Entry的key与目标key不一致时,调用getEntryAfterMiss()以开放寻址的方式查找后续Entry(通过以当前下标位置开始向后遍历Entry数组)。
    • 如果后续的Entry的key为null,则说明该Entry是过期数据,通过调用expungeStaleEntry(i)启动探测式清理
    • 如果后续的Entry的key与目标key一致,则返回该Entry。
  • 如果上面两种情况都无法找到Entry,则返回null。

remove(ThreadLocal<?> key) 源码分析

// remove()方法
private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    // 获取key的数组下标
    int i = key.threadLocalHashCode & (len-1);
    // 从下标位置向后遍历Entry数组,直到Entry为空
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        // 当key与目标key相同时
        if (e.get() == key) {
            // 将key置空
            e.clear();
            // 启动探测式清理清除
            expungeStaleEntry(i);
            return;
        }
    }
}
复制代码

6. rehash() & resize() 扩容机制

rehash() 源码分析

通过前面的set()方法最后部分

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

启发式清理后如果Entry的数量size大于等于扩容阈值(数组的2/3)时,触发rehash()方法。

rehash()源码

private void rehash() {
    // 通过从头循环的方式判断是否有过期数据,有则执行探测式清理
    expungeStaleEntries();
    // 如果清理后的Entry数量size大于阈值的3/4时则执行扩容操作
    if (size >= threshold - threshold / 4)
        resize();
}
复制代码

expungeStaleEntries()源码

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

rehash() & resize() 的触发条件

通过上面的源码可知

  • rehash() 触发条件:set()执行最后当启发式清理完毕后 Entry的数量size >= 扩容阈值threshold(Entry数组的长度的2/3) 时触发
  • resize() 触发条件:rehash()执行并且expungeStaleEntries()底层调用的探测式清理完毕后 Entry的数量size >= 扩容阈值的3/4 时触发

resize() 源码分析

private void resize() {
    // 获取旧的Entry数组
    Entry[] oldTab = table;
    // 旧数组的长度
    int oldLen = oldTab.length;
    // 新数组的长度
    int newLen = oldLen * 2;
    // 扩容新数组为旧数组两倍
    Entry[] newTab = new Entry[newLen];
    // 记录新数组下的Entry数量
    int count = 0;
    
    // 遍历旧数组
    for (Entry e : oldTab) {
        // 判断Entry是否存在
        if (e != null) {
            // 获取Entry的key
            ThreadLocal<?> k = e.get();
            // Entry是过期数据时,将值设置为null
            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;
                // 更新新数组下的Entry数量
                count++;
            }
        }
    }
    
    // 设置新阈值
    setThreshold(newLen);
    // 设置新数量
    size = count;
    // 设置新数组
    table = newTab;
}
复制代码

resize()流程概述

  • 将数组扩容为原先旧数组长度的两倍
  • 遍历旧的数组,对于Entry不为空且为过期数据的,直接将value设置为null,后续GC回收。
  • 对于Entry不为空且不为过期数据的,按新数组的长度重新计算下标,如果出现哈希冲突,则开放寻址,加入最近的Entry为空的位置。
  • 最后更新新的扩容阈值以及Entry数量以及Entry数组

7. 探测式&启发式清理流程

过期Key清理方式一:探测式清理expungeStaleEntry()

expungeStaleEntry() 源码

// 探测式清理 staleSlot开始清理下标
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // 1. 清理当前位置的Entry同时更新size
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;
    
    Entry e;
    int i;
    // 2. 从开始位置向后遍历Entry,直到遇到Entry为null时停止
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
         
        // 获取当前的key
        ThreadLocal<?> k = e.get();
        // 判断是否为过期数据
        if (k == null) {
            // 是过期数据则直接清理当前Entry,同时更新size
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            // 不是过期数据则再次获取Entry对于的下标
            int h = k.threadLocalHashCode & (len - 1);
            // 判断获得的下标是否是当前位置相同,不相同则重新定位
            if (h != i) {
                // 先置空当前位置
                tab[i] = null;

                // 从获取的下标处开始向后开放寻址找到能存放的下标
                while (tab[h] != null)
                    h = nextIndex(h, len);
                // 最后存储进空的Entry中
                tab[h] = e;
            }
        }
    }
    return i;
}
复制代码

探测式清理概述

  • 首先将传入参数 staleSlot(开始下标) 所在Entry清理(将Entry的value置null,并且将Entry置null)。
  • 从开始下标位置向后遍历Entry数组,获得对于下标对的key,判断key是否为null(判断当前位置的Entry是否过期):
    • 如果当前位置的Entry过期,则将当前位置的Entry清理(将Entry的value置null,并且将Entry置null)。
    • 如果当前位置的Entry未过期,则再次获得该Entry的下标,判断下标和当前位置索引是否相同,不相同时,则从获取的下标开始重新遍历Entry,直到遇到空的Entry时,放入当前空的Entry中。

所以探测式清理的作用是从传入参数开始向后遍历Entry数组,遇到过期数据则清理,遇到未过期数据则重新选择存放的数组位置,目的是让未过期数据离正确的下标更近一点(可以起到压缩作用)

图示探测式清理过程


过期Key清理方式二:启发式清理cleanSomeSlots()

cleanSomeSlots() 源码

// 启发式清理 (i表示开始下标,n表示数组长度)
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    // 从开始下标位置向后循环遍历,只会循环 数组长度的对数(比如长度为16则循环4次)
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        // 判断当Entry不为空且Entry又为过期数据时
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            // 从当前下标启动探测式清理
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0); // 每次将n >>> 1 (即 n / 2)
    return removed;
}
复制代码

启发式清理概述

  • 从传入的i开始下标位置向后检查,检查次数(位置)取决于数组长度的对数,比如数组长度为16,则检查log16 = 4次(即只会从开始下标向后检查4位)。
  • 遇到当前位置的Entry为过期数据时,会启动探测式清理,把当前位置的下标传给expungeStaleEntry() 方法。

启动式清理只会检查一些单元格来清理过期数据。试探的扫描一些单元格,寻找过期元素,也就是被垃圾回收的元素。当添加新元素set()或删除另一个过时元素时,将调用此函数。它执行对数扫描次数作为 不扫描(保留过期数据)与元素数量成比例的扫描次数 之间的平衡,使其能够清除过期数据。

作者:单程车票
链接:https://juejin.cn/post/7196647843137437756
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值