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

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

文章目录

  • 从根上理解 ThreadLocal 的来龙去脉
    • 一、引言
    • 二、概念
    • 三、使用
    • 四、源码解析
      • 1. set 方法
        • 1.1 初始化设值
        • 1.2 非初始化设值
        • 1.3 流程图
      • 2. get方法
      • 3. remove方法
      • 4. 开放地址法
      • 5. 清理方式
        • 5.1 探测式清除
        • 5.2 启发式清除
    • 五、内存泄露
    • 六、总结

从根上理解 ThreadLocal 的来龙去脉

一、引言

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

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

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

废话不多说,发车!

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

二、概念

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

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

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

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

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

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

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

后续在 JDK8 中进行了优化

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

三、使用

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 关键字的来龙去脉_第4张图片

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 关键字的来龙去脉_第5张图片

从上面探测式清除我们可以得到一个结论:探测式清除并不能完全清除我们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领域新星创作者,喜欢后端架构和中间件源码。

我们下期再见。

你可能感兴趣的:(#,高并发,java,开发语言,面试,后端,并发)