likes
comments
collection
share

《吊打面试官系列》从源码全面解析 ThreadLocal 关键字的来龙去脉

作者站长头像
站长
· 阅读数 38
  • 👏作者简介:大家好,我是爱敲代码的小黄,独角兽企业的Java开发工程师,CSDN博客专家,阿里云专家博主
  • 📕系列专栏:Java设计模式、数据结构和算法、Kafka从入门到成神、Kafka从成神到升仙、Spring从成神到升仙系列
  • 🔥如果感觉博主的文章还不错的话,请👍三连支持👍一下博主哦
  • 🍂博主正在努力完成2023计划中:以梦为马,扬帆起航,2023追梦人
  • 📝联系方式:hls1793929520,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬👀

从根上理解 ThreadLocal 的来龙去脉

一、引言

对于 Java 开发者而言,关于 并发编程,我们一般当做黑盒来进行使用,不需要去打开这个黑盒。

但随着目前程序员行业的发展,我们有必要打开这个黑盒,去探索其中的奥妙。

本期 并发编程 解析系列文章,将带你领略 并发编程 的奥秘

废话不多说,发车!

《吊打面试官系列》从源码全面解析 ThreadLocal 关键字的来龙去脉

二、概念

ThreadLocal 的英文字面意思为 “本地线程”,实际上 ThreadLocal 代表的是线程的本地变量,可能将其命名为 ThreadLocalVariable 更加容易让人理解。

ThreadLocal 如何做到为每个线程存有一份独立的本地值呢?

一个 ThreadLocal 实例可以形象地理解为一个 Map(早期版本的 ThreadLocal 是这样设计的)。

当工作线程 Thread 实例向本地变量保持某个值时,会以 “Key-Value对”(即键-值对)的形式保存在 ThreadLocal 内部的Map中,其中 Key 为线程 Thread 实例,Value 为待保存的值。

当工作线程 Thread 实例从 ThreadLocal 本地变量取值时,会以 Thread 实例为 Key,获取其绑定的 Value

一个ThreadLocal实例内部结构的形象如下所示:

《吊打面试官系列》从源码全面解析 ThreadLocal 关键字的来龙去脉

后续在 JDK8 中进行了优化

《吊打面试官系列》从源码全面解析 ThreadLocal 关键字的来龙去脉

三、使用

public class ThreadLocalTest {
    ThreadLocal<String> threadLocal = new ThreadLocal<String>();

    public static void main(String[] args) {
        ThreadLocalTest threadLocalTest = new ThreadLocalTest();
        threadLocalTest.showTwoThread();
    }

    public void showTwoThread() {
        new Thread(new Runnable() {
            public void run() {
                threadLocal.set("Thread1");
                System.out.println("I am " + threadLocal.get());
            }
        }).start();

        new Thread(new Runnable() {
            public void run() {
                threadLocal.set("Thread2");
                System.out.println("I am " + threadLocal.get());
            }
        }).start();
    }
}

上述是我们 ThreadLocal 的一个使用 Demo

最终结果输出:

I am Thread1
I am Thread2

由此可以看到,我们在线程中使用了 ThreadLocal 做到了线程之间彼此隔离的作用。

四、源码解析

1. set 方法

public void set(T value) {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 通过当前线程获取 ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    // 验证当前的ThreadLocalMap是否为空
    if (map != null){
        // 若不为空,则直接插入数据即可
        map.set(this, value);
    } else{
        // 若为空,则需要创建 ThreadLocalMap
        createMap(t, value);
    }
}

// 根据当前的Thread创建ThreadLocalMap并赋予初始值firstValue
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

从这一步我们可以看出,如果当前并没有创建过 ThreadLocalMap,则会去第一次创建:

1.1 初始化设值

// 初始化ThreadLocalMap
// 1) 创建底层数据存储的Entry数组table
// 2) 根据hashCode计算出来所在数组的下标i执行赋值操作
// 3) 初始化代表table中元素个数size的值
// 4) 初始化阈值threshold
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    // 初始化table,默认数值为16
    table = new Entry[INITIAL_CAPACITY];
    // hash获取下标
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    // 创建待插入的对象并放入数据
    table[i] = new Entry(firstKey, firstValue);
    // 调整容量
    size = 1;
    // 调整负载系数
    setThreshold(INITIAL_CAPACITY);
}

但如果当前的 ThreadLocalMap 不是第一次创建,则会执行 map.set(this, value)

1.2 非初始化设值

private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    
    // 首先,我们要先知晓,Entry对象是【弱引用】对象
    // 注意这里循环的条件是e != null,这个很重要,它采用的就是上面讲的开放地址法。
    // 这里遍历的逻辑是,先通过hash找到数组下标,然后寻找相等的ThreadLocal对象,找不到就往下一个index找。
    // --------------------------------------------------------------------------------------------
    // 有三种情况会跳出循环:
    // 1) 找到了相同key的ThreadLocal对象,然后更新value值;
    // 2) 找到了数组中的一个元素Entry,但是key=null,说明虚引用是可被GC回收的状态。
    // 3) 一直往数组下一个index查找,直到下一个index对应的元素为null;
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        // 拿到当前的ThreadLocal对象
        ThreadLocal<?> k = e.get();
        // 如果相同的ThreadLocal,直接更新即可
        if (k == key) {
            e.value = value;
            return;
        }
        // 当前的key=null,则表示虚引用的ThreadLocal是被GC回收的状态
        if (k == null) {
            // 1) 向前找到第一个空闲的key下标为 first
            // 2) 向后找到第一个空闲的key下标为 last
            // 3) 设置当前的key-value
            // 4) 【expungeStaleEntry】清除first~last之间的陈旧的Entry,直接将value置为null
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    
    // 走到这里就说明下标为i的位置上,是没有元素的,所以可以直接将新建的Entry元素插入到i这个位置
    tab[i] = new Entry(key, value);
    int sz = ++size;
    // cleanSomeSlots:存在陈旧的Entry且已经被清除
    if (!cleanSomeSlots(i, sz) && sz >= threshold){
        rehash();
    }
}

// 循环获取下标
// 0-1-2-3-0-1-2-3
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}


private void rehash() {
    // 清除陈旧的key
    expungeStaleEntries();

    // 扩容
    // 1) 创建一个长度是当前table的2倍
    // 2) 遍历旧的table数组,依次获得里面的Entry元素
    //   2-1) 计算Entry在新的table数组中应该插入的位置
    //   2-2) 如果下标h已经被占用了,那么就向后查找空位,直到找到空闲的位置为止,插入进去。
    //   2-3) 如果下标h没有被占用,那么就插入到h的位置上
    //   2-4) count++
    // 3) 根据新数组的长度,更新阈值threshold
    // 4) 针对新的数组,更新全局变量size和table
    if (size >= threshold - threshold / 4){
        resize();
    }
}

1.3 流程图

《吊打面试官系列》从源码全面解析 ThreadLocal 关键字的来龙去脉

2. get方法

public T get() {
    // 拿到当前线程
    Thread t = Thread.currentThread();
    // 通过当前线程获取 ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    // 如果 ThreadLocalMap 不为空的情况下
    if (map != null) {
        // 得到当前的Entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 初始化(和Set一样的流程)
    return setInitialValue();
}

private Entry getEntry(ThreadLocal<?> key) {
    // 获取下标
    int i = key.threadLocalHashCode & (table.length - 1);
    // 获取键值
    Entry e = table[i];
    // 如果不为空且当前key相等
    if (e != null && e.get() == key)
        return e;
    else
        // 
        return getEntryAfterMiss(key, i, e);
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    // 取当前的table
    Entry[] tab = table;
    int len = tab.length;
    
    // 循环遍历当前entry
    while (e != null) {
        ThreadLocal<?> k = e.get();
        // 找到即返回
        if (k == key)
            return e;
        if (k == null)
            // 清理陈旧的key
            expungeStaleEntry(i);
        else
            // 遍历下一个
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}

3. remove方法

public void remove() {
    // 拿到当前线程的ThreadLocalMap
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null){
        m.remove(this);
    }
}

private void remove(ThreadLocal<?> key) {
    // 获取当前key的所属下标
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    
    // 循环找出当前的key
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        // 若找到引用置为空并清理陈旧的entry
        if (e.get() == key) {
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

4. 开放地址法

当我们看到这里,就发现这个 ThreadLocalMap 有些“奇怪”,它并没有按照我们之前在学习 HashMap链式 方式去解决哈希冲突,即:数组+链表。它其实使用的是一种叫做 “开放地址法” 作为解决哈希冲突的一种方式。

什么是开放地址法呢?

开放地址法的基本思想就是:一旦发生了冲突,那么就去寻找下一个空的地址;那么只要表足够大,空的地址总能找到,并将记录插入进去。

ThreadLocalMapHashMap 的区别是什么呢?

HashMap:

  • 数据结构是数组+链表
  • 通过链地址法解决 hash 冲突的问题
  • 里面的 Entry 内部类的引用都是强引用

ThreadLocalMap:

  • 数据结构仅仅是数组
  • 通过开放地址法来解决 hash 冲突的问题
  • Entry 内部类中的 key 是弱引用,value 是强引用

链地址法和开放地址法的优缺点是什么呢?

开放地址法

  • 容易产生堆积问题,不适于大规模的数据存储。
  • 散列函数的设计对冲突会有很大的影响,插入时可能会出现多次冲突的现象。
  • 删除的元素是多个冲突元素中的一个,需要对后面的元素作处理,实现较复杂。

链地址法

  • 处理冲突简单,且无堆积现象,平均查找长度短。
  • 链表中的结点是动态申请的,适合构造表不能确定长度的情况。
  • 删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。
  • 指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间。

ThreadLocalMap采用开放地址法原因是什么?

ThreadLocal 往往存放的数据量不会特别大(而且key 是弱引用又会被垃圾回收,及时让数据量更小)。

采用开放地址法简单的结构会更节省空间,同时数组的查询效率也是非常高,加上第一点的保障,冲突概率也比较低。

5. 清理方式

5.1 探测式清除

这个清除主要是通过这个方法: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;
    
    
    // 遍历Table,这里是一直向后遍历,直到遇到为null(也就是没用过的)为止
    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 {
            // 重新hash
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;.
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}

5.2 启发式清除

《吊打面试官系列》从源码全面解析 ThreadLocal 关键字的来龙去脉

从上面探测式清除我们可以得到一个结论:探测式清除并不能完全清除我们table的所有陈旧数据

这个时候需要 启发式清除 的出场:

// i:探测式清除的返回地址
// n:table的总容量
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];
        // 如果这个已经被GC掉了
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            // 由当前地址去进行探测式清除
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}

看完说实话,实现有点操蛋,接着人家探测式清除外包了一层,就换了一个名字了

假设:m = n>>>=1 ,也就是 2 的 m 次幂等于 n

当连续 m 次没有去进行清除操作,则默认当前 table 中没有垃圾

例如:数组长度是16,那么2^4=16,也就是连续4次没有过期Entry,即 m = logn/log2(n为数组长度)

五、内存泄露

public class ThreadLocalForOOM {
    /**
     * -Xms50m -Xmx50m
     */
    static class OOMObject {
        private Long[] a = new Long[2 * 1024 * 1024];
    }

    final static ThreadPoolExecutor pool = new ThreadPoolExecutor(5, 5, 1, TimeUnit.SECONDS, new LinkedBlockingQueue<>());

    final static ThreadLocal<OOMObject> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        for (int i = 0; i < 50; i++) {
            int finalI = i;
            pool.execute(() -> {
                threadLocal.set(new OOMObject());
                System.out.println("oom object--->" + finalI);
                OOMObject oomObject = threadLocal.get();
                System.out.println("oomObject---->" + oomObject);
                // threadLocal.remove(); // 记得remove 防止内存泄露,此时一定要在使用完remove
            });

            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

我们运行上述代码,会出现 OOM 异常:Exception in thread "pool-1-thread-4" java.lang.OutOfMemoryError: Java heap space

我们在使用完 threadLocal 之后,需要及时的进行 remove 删除,加上这句就OK了。

六、总结

又是一篇大工程的文章结束了

记得校招的时候,对于 ThreadLocal 的认知只停留在线程隔离,但并未真正的去剖析其源码是怎么做到线程隔离的

我们通过讲解 setgetremove 等三大方法源码,体会到了整个的运行流程

而其 table扩容清除方式解决hash冲突的方式也令人感到眼前一亮,读完还是有收获的

那么如何证明你真的理解了 ThreadLocal 呢,我这里出个经典的题目,大家可以想一下:请你聊一下 ThreadLocalset 过程?

如果你能看到这,那博主必须要给你一个大大的鼓励,谢谢你的支持!

下期是 reentrantlock 源码文章,这个是 Java 层面的,应该还好,哈哈哈哈

我是爱敲代码的小黄,独角兽企业的Java开发工程师,CSDN博客专家,Java领域新星创作者,喜欢后端架构和中间件源码。

我们下期再见。

转载自:https://juejin.cn/post/7218564871830945848
评论
请登录