likes
comments
collection
share

浅谈 ThreadLocal

作者站长头像
站长
· 阅读数 14

1.介绍

ThreadLocal 叫做 线程变量,即填充的变量属于当前线程,用于在多线程环境中保存线程局部变量。填充的变量对其他线程是隔离的,当前线程独享,所以不存在多线程间共享的问题

当通过 ThreadLocal 填充变量时,ThreadLocal 为当前线程创建了一个副本 ThreadLocal.ThreadLocalMap,并保存该变量。ThreadLocal.ThreadLocalMap 的底层数据结构是数组。通过当前 ThreadLocal 的 hash 值计算索引位置,所以当前 ThreadLocal 在当前线程只能存放一个数据。

Entry 类的源代码中,可以知道 ThreadLocal 的引用 k 被传递给 WeakReference 的构造函数,所以 ThreadLocalMap 中的 key 为 ThreadLocal弱引用。被存放在强引用的容器中的弱引用,在容器没有清理前,即使对象已经被垃圾回收,弱引用对象也无法被释放,这就会导致内存泄漏。因此在设置和查找的过程中,存在清理元素的操作,但这些操作比较耗时,因此在使用完毕后应当手动清理 new ThreadLocal<>().remove();

// 一个 Thread 代表一个线程;每个线程里都有一个 ThreadLocalMap
public class Thread implements Runnable{
    ThreadLocal.ThreadLocalMap threadLocals = null;
}
// ThreadLocal.ThreadLocalMap
static class ThreadLocalMap{
    // 存放键值对
    private Entry[] table;
    
    // 初始容量
    private static final int INITIAL_CAPACITY = 16;
    
    // 数组中当前键值对数量
    private int size = 0;
    
    // 扩容界限
    private int threshold;
    
    // 键值对;弱引用,当没有强引用指向时,发生 GC 时就会被垃圾回收
    static class Entry extends WeakReference<ThreadLocal<?>> {
        /** The value associated with this ThreadLocal. */
        Object value;

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

}

浅谈 ThreadLocal

浅谈 ThreadLocal

2.散列算法

获取哈希值的散列算法包括:除法散列法、平方散列法、斐波那契(Fibonacci)散列法、随机数法等。ThreadLocal 使用的散列算法是 斐波那契(Fibonacci)散列法,在发生碰撞时,通过开放寻址的方法寻找下一个位置。其公式如下所示

HASH_INCREMENT=1640531527HASH\_INCREMENT = 1640531527HASH_INCREMENT=1640531527

hash(i)=i∗HASH_INCREMENT+HASH_INCREMENThash(i) = i*HASH\_INCREMENT + HASH\_INCREMENThash(i)=iHASH_INCREMENT+HASH_INCREMENT

2.1 特殊数字 0x61c88647

HASH_INCREMENT=0x61c88647=1640531527HASH\_INCREMENT=0x61c88647=1640531527HASH_INCREMENT=0x61c88647=1640531527 是一个特殊的数字,它是一个哈希值的黄金分割点,使得散列时散列的足够均匀,减少哈希碰撞。通过下面的方式可以计算得到:

// hash_increment = -1640531527
int hash_increment = BigDecimal.valueOf(Math.pow(2,32) * 0.6180339887).intValue();
// hash_increment = 1640531527
hash_increment *= -1;

2.2 ThreadLocal 计算 hash

ThreadLocal 通过 AtomicInteger 实现哈希计算;AtomicIntegerstaticstaticstatic 修饰,是静态变量,属于类;每当有一个 ThreadLocal 被实例化时,通过 AtomicInteger 增加一个 HASH_INCREMENT

public class ThreadLocal<T>{
    // 当前 ThreadLocal 对象的哈希值;每次实例化都会调用 nextHashCode() 获取哈希值
    private final int threadLocalHashCode = nextHashCode();
    
    // Integer类型的原子操作类型
    private static AtomicInteger nextHashCode = new AtomicInteger();
    
    private static final int HASH_INCREMENT = 0x61c88647;
    
    // 计算哈希值
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
}

3.初始化

ThreadLocal 通过无参构造函数进行实例化,同时根据需要设置泛型。在实例化的过程,计算 hash 值。

public ThreadLocal() {
}

4.设置元素

设置元素时,主要有以下几种情况:

  • 情况1:直接更新待插入的下标为空,直接插入
  • 情况2:待插入的下标不为空,key 相同
  • 情况3:待插入的下标不为空,key 不相同,开放式寻址
  • 情况4:待插入的下标不为空,key = null,碰到过期 key,清理过期元素,并在该位置设置元素

浅谈 ThreadLocal

浅谈 ThreadLocal

// ThreadLocal$set
public void set(T value) {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 获取当前线程的 ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 调用 ThreadLocalMap 的 set() 方法设置元素
        map.set(this, value);
    } else {
        // 给当前线程创建 ThreadLocalMap,并设置元素
        createMap(t, value);
    }
}
// ThreadLocal.ThreadLocalMap$set
private void set(ThreadLocal<?> key, Object value) {
    // 键值对数组
    Entry[] tab = table;
    // 数组长度
    int len = tab.length;
    // 通过当前 ThreadLocal 的 hash 值计算索引位置
    int i = key.threadLocalHashCode & (len-1);
    
    // 开放寻址法,寻找一个空位置
    for (Entry e = tab[i];e != null;e = tab[i = nextIndex(i, len)]) {
        // 获取当前键值对的索引,相当于 key
        ThreadLocal<?> k = e.get();
        // 情况2:待插入的下标不为空,key 相同,直接更新
        if (k == key) {
            e.value = value;
            return;
        }
        // 情况4:待插入的下标不为空,key = null,碰到过期 key,清理过期元素,并在该位置设置元素
        if (k == null) {
            // 探测式清理过期元素
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    
     // 进入了 for 循环,则是情况3:待插入的下标不为空,key 不相同,开放式寻址
     // 未进入 for 循环,则是情况1:待插入的下标为空,直接插入

    tab[i] = new Entry(key, value);
    int sz = ++size;
    // cleanSomeSlots 进行启发式清理
    // 当没有元素清理并且数组中元素大于界限时,进行扩容
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

5.获取元素

获取元素主要有以下几种情况:

  • 情况1:直接命中,返回元素
  • 情况2:没有直接命中,开放式寻址,遇到key相同的键值对,则返回元素
  • 情况3:没有直接命中,开放式寻址,key=null,探测式清理,继续开放式寻址
// ThreadLocal$get
public T get() {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 获取当前线程的 ThreadLocalMap
    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;
        }
    }
    // map 为 null,创建 ThreadLocalMap,并返回 null
    return setInitialValue();
}
// 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)
        // 情况1:直接命中,返回
        return e;
    else
        // 没有直接命中,开放式寻址
        return getEntryAfterMiss(key, i, e);
}
// 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)
            // 情况2:没有直接命中,开放式寻址,找到元素返回
            return e;
        if (k == null)
            // 情况3:没有直接命中,开放式寻址,key=null,探测式清理
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}

6.清理元素

6.1 替换过期的ThreadLocal键值对条目

replaceStaleEntry() 方法在设置元素时被调用。其执行流程如下:

  • 首先,通过遍历向前查找过期条目,记录最早出现的过期槽的索引
  • 接着,在遍历过程中,如果找到了与指定 key 相等的 ThreadLocal,就将其对应的 value 值进行更新,并将其与过期槽交换位置,以保持哈希表的顺序;并调用相关方法清理过期条目
  • 如果在遍历过程中没有找到与指定 key 相等的 ThreadLocal,就将新的键值对放入过期槽(staleSlot)中
  • 最后,如果存在先前的过期条目,通过调用 expungeStaleEntry 方法清理这些过期条目,并重新哈希运行中的所有其他条目
// staleSlot:当前 key=null 的索引
private void replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot) {
    // ThreadLocalMap中的Entry数组
    Entry[] tab = table;
    // Entry数组的长度
    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();

        // 如果找到关键字,我们需要将其与过期条目交换以保持哈希表的顺序 
        // 然后可以将新过期槽或上面遇到的任何其他过期槽发送到 expungeStaleEntry 中,以删除或重新哈希运行中的所有其他条目
        if (k == key) {
            e.value = value;

            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            // 如果存在先前的过期条目,则从先前的过期条目开始清除
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        // 如果没有先前条目,并且在向后的扫描中也没有过期的条目,则将 slotToExpunge 设置为当前的过期条目
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    // 如果没有找到关键字,则将新条目放入过期槽
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // 如果运行中有其他过期条目,则清除它们
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

6.2 探测式清理

expungeStaleEntry() 方法用于清理过期的 ThreadLocal 键值对条目,并进行重新哈希,以维护哈希表的结构和顺序,直到遇到 null 才停止。其过程如下:

  • 首先,清除过期槽(staleSlot)上的条目,将其 value 值置为 null,并将该条目设为 null,同时减小 size 计数器
  • 然后,从 staleSlot 的下一个位置开始重新哈希,直到遇到 null 为止
    • 在重新哈希过程中,如果遇到 key 为 null 的条目,表示该条目已过期,将其 value 值置为 null,将该条目设为 null,并减小 size 计数器
    • 如果遇到的条目的 key 不为 null,就根据其 threadLocalHashCode 重新计算哈希值 h,并与当前位置 i 进行比较
      • 如果 h 与 i 不相等,说明哈希冲突发生,将当前位置 i 的条目设为 null
      • 然后,在哈希表中找到合适的位置 h,直到遇到 null 为止
      • 将之前设为 null 的条目 e 放入新的位置 h 上,以保持哈希表的顺序
// staleSlot:key=null 的索引位置
// 返回 staleSlot 之后的下一个空槽的索引
// 在 staleSlot 和该空槽之间的所有槽位都已经被检查过是否需要清理
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // 清理staleSlot 位置的条目
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // 重新哈希直到遇到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) {
            // 如果 k=null,则清理该位置的条目
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            // 如果 k!=null,则重新哈希
            int h = k.threadLocalHashCode & (len - 1);
            // 当索引位置与当前位置不同时,则从索引位置向后遍历,直到一个空位置
            if (h != i) {
                tab[i] = null;

                // 不同于 Knuth 6.4中 的算法R,我们必须扫描到 null,因为多个条目可能已过期
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}

6.3 启发式清理

cleanSomeSlots() 方法用于清理一些槽位中的过期条目。它会从给定的起始索引开始,逐个扫描一些槽位,检查对应的 ThreadLocal 引用是否已经被回收(即过期)。如果发现过期的条目,将其进行清理,并返回是否有条目被清理的结果。

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) {
            // 如果当前槽位存在且对应的 ThreadLocal 引用已经被回收(即过期)
            // 则进行调用探测式清理方法执行清理操作,并更新相关变量
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0); // 按对数次数进行扫描;相当于每次除 2
    return removed;
}

代码中的注释翻译如下:

根据注释说明,这段代码的作用是启发式地扫描一些单元格,寻找过期的条目。当添加新元素或清理掉另一个过期条目时,会调用该方法。它执行对数次数的扫描,作为快速但保留 垃圾和以元素数量为比例 的扫描之间的平衡。这样的扫描既能发现所有的垃圾,又不会导致某些插入操作的时间复杂度为O(n)。

参数:

  • i:已知不包含过期条目的位置。扫描将从 i 之后的元素开始。
  • n:扫描控制参数。默认情况下,会扫描 log2(n) 个单元格,除非发现过期条目,此时会再额外扫描 log2(table.length)-1 个单元格。当从插入操作调用时,该参数是元素的数量;当从 replaceStaleEntry 调用时,该参数是表的长度。

返回值:

  • 如果删除了任何过期的条目,则返回 true
转载自:https://juejin.cn/post/7242924789656567845
评论
请登录