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

本文涉及的产品
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
简介: 《吊打面试官系列》从源码全面解析 ThreadLocal 关键字的来龙去脉

从根上理解 ThreadLocal 的来龙去脉

一、引言

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

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

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

废话不多说,发车!

二、概念

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

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

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

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

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

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

后续在 JDK8 中进行了优化

三、使用

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 流程图

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 启发式清除

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

我们下期再见。


相关文章
|
11天前
|
存储 缓存 Java
Java 并发编程——volatile 关键字解析
本文介绍了Java线程中的`volatile`关键字及其与`synchronized`锁的区别。`volatile`保证了变量的可见性和一定的有序性,但不能保证原子性。它通过内存屏障实现,避免指令重排序,确保线程间数据一致。相比`synchronized`,`volatile`性能更优,适用于简单状态标记和某些特定场景,如单例模式中的双重检查锁定。文中还解释了Java内存模型的基本概念,包括主内存、工作内存及并发编程中的原子性、可见性和有序性。
Java 并发编程——volatile 关键字解析
|
10天前
|
存储 设计模式 算法
【23种设计模式·全精解析 | 行为型模式篇】11种行为型模式的结构概述、案例实现、优缺点、扩展对比、使用场景、源码解析
行为型模式用于描述程序在运行时复杂的流程控制,即描述多个类或对象之间怎样相互协作共同完成单个对象都无法单独完成的任务,它涉及算法与对象间职责的分配。行为型模式分为类行为模式和对象行为模式,前者采用继承机制来在类间分派行为,后者采用组合或聚合在对象间分配行为。由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象行为模式比类行为模式具有更大的灵活性。 行为型模式分为: • 模板方法模式 • 策略模式 • 命令模式 • 职责链模式 • 状态模式 • 观察者模式 • 中介者模式 • 迭代器模式 • 访问者模式 • 备忘录模式 • 解释器模式
【23种设计模式·全精解析 | 行为型模式篇】11种行为型模式的结构概述、案例实现、优缺点、扩展对比、使用场景、源码解析
|
10天前
|
设计模式 存储 安全
【23种设计模式·全精解析 | 创建型模式篇】5种创建型模式的结构概述、实现、优缺点、扩展、使用场景、源码解析
结构型模式描述如何将类或对象按某种布局组成更大的结构。它分为类结构型模式和对象结构型模式,前者采用继承机制来组织接口和类,后者釆用组合或聚合来组合对象。由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象结构型模式比类结构型模式具有更大的灵活性。 结构型模式分为以下 7 种: • 代理模式 • 适配器模式 • 装饰者模式 • 桥接模式 • 外观模式 • 组合模式 • 享元模式
【23种设计模式·全精解析 | 创建型模式篇】5种创建型模式的结构概述、实现、优缺点、扩展、使用场景、源码解析
|
10天前
|
设计模式 存储 安全
【23种设计模式·全精解析 | 创建型模式篇】5种创建型模式的结构概述、实现、优缺点、扩展、使用场景、源码解析
创建型模式的主要关注点是“怎样创建对象?”,它的主要特点是"将对象的创建与使用分离”。这样可以降低系统的耦合度,使用者不需要关注对象的创建细节。创建型模式分为5种:单例模式、工厂方法模式抽象工厂式、原型模式、建造者模式。
【23种设计模式·全精解析 | 创建型模式篇】5种创建型模式的结构概述、实现、优缺点、扩展、使用场景、源码解析
|
29天前
|
PyTorch Shell API
Ascend Extension for PyTorch的源码解析
本文介绍了Ascend对PyTorch代码的适配过程,包括源码下载、编译步骤及常见问题,详细解析了torch-npu编译后的文件结构和三种实现昇腾NPU算子调用的方式:通过torch的register方式、定义算子方式和API重定向映射方式。这对于开发者理解和使用Ascend平台上的PyTorch具有重要指导意义。
|
11天前
|
安全 搜索推荐 数据挖掘
陪玩系统源码开发流程解析,成品陪玩系统源码的优点
我们自主开发的多客陪玩系统源码,整合了市面上主流陪玩APP功能,支持二次开发。该系统适用于线上游戏陪玩、语音视频聊天、心理咨询等场景,提供用户注册管理、陪玩者资料库、预约匹配、实时通讯、支付结算、安全隐私保护、客户服务及数据分析等功能,打造综合性社交平台。随着互联网技术发展,陪玩系统正成为游戏爱好者的新宠,改变游戏体验并带来新的商业模式。
|
17天前
|
安全 编译器 C++
C++ `noexcept` 关键字的深入解析
`noexcept` 关键字在 C++ 中用于指示函数不会抛出异常,有助于编译器优化和提高程序的可靠性。它可以减少代码大小、提高执行效率,并增强程序的稳定性和可预测性。`noexcept` 还可以影响函数重载和模板特化的决策。使用时需谨慎,确保函数确实不会抛出异常,否则可能导致程序崩溃。通过合理使用 `noexcept`,开发者可以编写出更高效、更可靠的 C++ 代码。
25 0
|
2月前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
87 2
|
3月前
|
缓存 Java 程序员
Map - LinkedHashSet&Map源码解析
Map - LinkedHashSet&Map源码解析
87 0
|
3月前
|
算法 Java 容器
Map - HashSet & HashMap 源码解析
Map - HashSet & HashMap 源码解析
68 0

热门文章

最新文章

推荐镜像

更多