⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。
学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在实际的业务开发中,往往不需要我们手写数据结构,而是直接使用标准库的数据结构 / 容器类。
本文是 Java & Android 集合框架系列的第 9 篇文章,完整文章目录请移步到文章末尾~
前言
大家好,我是小彭。
在前面的文章里,我们聊到了散列表的开放寻址法和分离链表法,也聊到了 HashMap、LinkedHashMap 和 WeakHashMap 等基于分离链表法实现的散列表。
今天,我们来讨论 Java 标准库中一个使用开放寻址法的散列表结构,也是 Java & Android “面试八股文” 的标准题库之一 —— ThreadLocal。
本文源码基于 Java 8 ThreadLocal。
- Java & Android 集合框架 #9 全网最全的 ThreadLocal 原理详细解析 —— 原理篇
- Java & Android 集合框架 #10 全网最全的 ThreadLocal 原理详细解析 —— 源码篇
思维导图:
- 回顾散列表的工作原理
在开始分析 ThreadLocal 的实现原理之前,我们先回顾散列表的工作原理。
散列表是基于散列思想实现的 Map 数据结构,将散列思想应用到散列表数据结构时,就是通过 hash 函数提取键(Key)的特征值(散列值),再将键值对映射到固定的数组下标中,利用数组支持随机访问的特性,实现 O(1) 时间的存储和查询操作。
散列表示意图
在从键值对映射到数组下标的过程中,散列表会存在 2 次散列冲突:
- 第 1 次 - hash 函数的散列冲突: 这是一般意义上的散列冲突;
- 第 2 次 - 散列值取余转数组下标: 本质上,将散列值转数组下标也是一次 Hash 算法,也会存在散列冲突。
事实上,由于散列表是压缩映射,所以我们无法避免散列冲突,只能保证散列表不会因为散列冲突而失去正确性。常用的散列冲突解决方法有 2 类:
- 开放寻址法: 例如 ThreadLocalMap;
- 分离链表法: 例如 HashMap。
开放寻址(Open Addressing)的核心思想是: 在出现散列冲突时,在数组上重新探测出一个空闲位置。 经典的探测方法有线性探测、平方探测和双散列探测。线性探测是最基本的探测方法,我们今天要分析的 ThreadLocal 中的 ThreadLocalMap 散列表就是采用线性探测的开放寻址法。
- 认识 ThreadLocal 线程局部存储
2.1 说一下 ThreadLocal 的特点?
ThreadLocal 提供了一种特殊的线程安全方式。
使用 ThreadLocal 时,每个线程可以通过 ThreadLocal#get
或 ThreadLocal#set
方法访问资源在当前线程的副本,而不会与其他线程产生资源竞争。这意味着 ThreadLocal 并不考虑如何解决资源竞争,而是为每个线程分配独立的资源副本,从根本上避免发生资源冲突,是一种无锁的线程安全方法。
用一个表格总结 ThreadLocal 的 API:
public API | 描述 |
---|---|
set(T) | 设置当前线程的副本 |
T get() | 获取当前线程的副本 |
void remove() | 移除当前线程的副本 |
ThreadLocal |
创建 ThreadLocal 并指定缺省值创建工厂 |
protected API | 描述 |
T initialValue() | 设置缺省值 |
2.2 ThreadLocal 如何实现线程隔离?(重点理解)
ThreadLocal 在每个线程的 Thread 对象实例数据中分配独立的内存区域,当我们访问 ThreadLocal 时,本质上是在访问当前线程的 Thread 对象上的实例数据,不同线程访问的是不同的实例数据,因此实现线程隔离。
Thread 对象中这块数据就是一个使用线性探测的 ThreadLocalMap 散列表,ThreadLocal 对象本身就作为散列表的 Key ,而 Value 是资源的副本。当我们访问 ThreadLocal 时,就是先获取当前线程实例数据中的 ThreadLocalMap 散列表,再通过当前 ThreadLocal 作为 Key 去匹配键值对。
ThreadLocal.java
1 | java复制代码// 获取当前线程的副本 |
Thread.java
1 | java复制代码// Thread 对象的实例数据 |
ThreadLocal 示意图
2.3 使用 InheritableThreadLocal 继承父线程的局部存储
在业务开发的过程中,我们可能希望子线程可以访问主线程中的 ThreadLocal 数据,然而 ThreadLocal 是线程隔离的,包括在父子线程之间也是线程隔离的。为此,ThreadLocal 提供了一个相似的子类 InheritableThreadLocal
,ThreadLocal 和 InheritableThreadLocal 分别对应于线程对象上的两块内存区域:
- 1、ThreadLocal 字段: 在所有线程间隔离;
- 2、InheritableThreadLocal 字段: 子线程会继承父线程的 InheritableThreadLocal 数据。父线程在创建子线程时,会批量将父线程的有效键值对数据拷贝到子线程的 InheritableThreadLocal,因此子线程可以复用父线程的局部存储。
在 InheritableThreadLocal 中,可以重写 childValue()
方法修改拷贝到子线程的数据。
1 | java复制代码public class InheritableThreadLocal<T> extends ThreadLocal<T> { |
需要特别注意:
- 注意 1 - InheritableThreadLocal 区域在拷贝后依然是线程隔离的: 在完成拷贝后,父子线程对 InheritableThreadLocal 的操作依然是相互独立的。子线程对 InheritableThreadLocal 的写不会影响父线程的 InheritableThreadLocal,反之亦然;
- 注意 2 - 拷贝过程在父线程执行: 这是容易混淆的点,虽然拷贝数据的代码写在子线程的构造方法中,但是依然是在父线程执行的。子线程是在调用 start() 后才开始执行的。
InheritableThreadLocal 示意图
2.4 ThreadLocal 的自动清理与内存泄漏问题
ThreadLocal 提供具有自动清理数据的能力,具体分为 2 个颗粒度:
- 1、自动清理散列表: ThreadLocal 数据是 Thread 对象的实例数据,当线程执行结束后,就会跟随 Thread 对象 GC 而被清理;
- 2、自动清理无效键值对: ThreadLocal 是使用弱键的动态散列表,当 Key 对象不再被持有强引用时,垃圾收集器会按照弱引用策略自动回收 Key 对象,并在下次访问 ThreadLocal 时清理无效键值对。
引用关系示意图
然而,自动清理无效键值对会存在 “滞后性”,在滞后的这段时间内,无效的键值对数据没有及时回收,就发生内存泄漏。
- 举例 1: 如果创建 ThreadLocal 的线程一直持续运行,整个散列表的数据就会一致存在。比如线程池中的线程(大体)是复用的,这部分复用线程中的 ThreadLocal 数据就不会被清理;
- 举例 2: 如果在数据无效后没有再访问过 ThreadLocal 对象,那么自然就没有机会触发清理;
- 举例 3: 即使访问 ThreadLocal 对象,也不一定会触发清理(原因见下文源码分析)。
综上所述:虽然 ThreadLocal 提供了自动清理无效数据的能力,但是为了避免内存泄漏,在业务开发中应该及时调用 ThreadLocal#remove
清理无效的局部存储。
2.5 ThreadLocal 的使用场景
- 场景 1 - 无锁线程安全: ThreadLocal 提供了一种特殊的线程安全方式,从根本上避免资源竞争,也体现了空间换时间的思想;
- 场景 2 - 线程级别单例: 一般的单例对象是对整个进程可见的,使用 ThreadLocal 也可以实现线程级别的单例;
- 场景 3 - 共享参数: 如果一个模块有非常多地方需要使用同一个变量,相比于在每个方法中重复传递同一个参数,使用一个 ThreadLocal 全局变量也是另一种传递参数方式。
2.6 ThreadLocal 使用示例
我们采用 Android Handler 机制中的 Looper 消息循环作为 ThreadLocal 的学习案例:
1 | java复制代码// /frameworks/base/core/java/android/os/Looper.java |
示例代码
1 | java复制代码new Thread(new Runnable() { |
要点如下:
- 1、Looper 中的 ThreadLocal 被声明为静态类型,泛型参数为 Looper,全局共享同一个 ThreadLocal 对象;
- 2、
Looper#prepare()
中调用ThreadLocal#set()
设置当前线程关联的 Looper 对象; - 3、
Looper#myLooper()
中调用ThreadLocal#get()
获取当前线程关联的 Looper 对象。
我们可以画出 Looper 中访问 ThreadLocal 的 Timethreads 图,可以看到不同线程独立访问不同的 Looper 对象,即线程间不存在资源竞争。
Looper ThreadLocal 示意图
2.7 阿里巴巴 ThreadLocal 编程规约
在《阿里巴巴 Java 开发手册》中,亦有关于 ThreadLocal API 的编程规约:
- 【强制】 SimpleDateFormate 是线程不安全的类,一般不要定义为 static ****变量。如果定义为 static,必须加锁,或者使用 DateUtils 工具类(使用 ThreadLocal 做线程隔离)。
DataFormat.java
1 | java复制代码private static final ThreadLocal<DataFormat> df = new ThreadLocal<DateFormat>(){ |
- 【参考】 (原文过于啰嗦,以下是小彭翻译转述)ThreadLocal 变量建议使用 static 全局变量,可以保证变量在类初始化时创建,所有类实例可以共享同一个静态变量(例如,在 Android Looper 的案例中,ThreadLocal 就是使用 static 修饰的全局变量)。
- 【强制】 必须回收自定义的 ThreadLocal 变量,尤其在线程池场景下,线程经常被反复用,如果不清理自定义的 ThreadLocal 变量,则可能会影响后续业务逻辑和造成内存泄漏等问题。尽量在代码中使用 try-finally 块回收,在 finally 中调用 remove() 方法。
- ThreadLocal 源码分析
这一节,我们来分析 ThreadLocal 中主要流程的源码。
3.1 ThreadLocal 的属性
ThreadLocal 只有一个 threadLocalHashCode
散列值属性:
- 1、threadLocalHashCode 相当于 ThreadLocal 的自定义散列值,在创建 ThreadLocal 对象时,会调用
nextHashCode()
方法分配一个散列值; - 2、ThreadLocal 每次调用
nextHashCode()
方法都会将散列值追加HASH_INCREMENT
,并记录在一个全局的原子整型nextHashCode
中。
提示: ThreadLocal 的散列值序列为:0、HASH_INCREMENT、HASH_INCREMENT * 2、HASH_INCREMENT * 3、…
1 | java复制代码public class ThreadLocal<T> { |
不出意外的话又有小朋友出来举手提问了🙋🏻♀️:
- 🙋🏻♀️疑问 1:OK,threadLocalHashCode 类似于 hashCode(),那为什么 ThreadLocal 不重写 hashCode()?
如果重写 Object#hashCode()
,那么 threadLocalHashCode
散列值就会对所有散列表生效。而 threadLocalHashCode 散列值是专门针对数组为 2 的整数幂的散列表设计的,在其他散列表中不一定表现良好。因此 ThreadLocal 没有重写 Object#hashCode(),让 threadLocalHashCode 散列值只在 ThreadLocal 内部的 ThreadLocalMap 使用。
常规做法
1 | java复制代码public class ThreadLocal<T> { |
- 🙋🏻♀️疑问 2:为什么使用 ThreadLocal 作为散列表的 Key,而不是常规思维用 Thread Id 作为 Key?
如果使用 Thread Id 作为 Key,那么就需要在每个 ThreadLocal 对象中维护散列表,而不是每个线程维护一个散列表。此时,当多个线程并发访问同一个 ThreadLocal 对象中的散列表时,就需要通过加锁保证线程安全。而 ThreadLocal 的方案让每个线程访问独立的散列表,就可以从根本上规避线程竞争。
3.2 ThreadLocal 的 API
分析代码,可以总结出 ThreadLocal API 的用法和注意事项:
- 1、ThreadLocal#get: 获取当前线程的副本;
- 2、ThreadLocal#set: 设置当前线程的副本;
- 3、ThreadLocal#remove: 移除当前线程的副本;
- 4、ThreadLocal#initialValue: 由子类重写来设置缺省值:
- 4.1 如果未命中(Map 取值为 nul),则会调用
initialValue()
创建并设置缺省值; - 4.2 ThreadLocal 的缺省值只会在缓存未命中时创建,即缺省值采用懒初始化策略;
- 4.3 如果先设置后又移除副本,再次 get 获取副本未命中时依然会调用
initialValue()
创建并设置缺省值。
- 4.1 如果未命中(Map 取值为 nul),则会调用
- 5、ThreadLocal#withInitial: 方便设置缺省值,而不需要实现子类。
在 ThreadLocal 的 API 会通过 getMap() 方法获取当前线程的 Thread 对象中的 threadLocals 字段,这是线程隔离的关键。
ThreadLocal.java
1 | java复制代码public ThreadLocal() { |
3.3 InheritableThreadLocal 如何继承父线程的局部存储?
父线程在创建子线程时,在子线程的构造方法中会批量将父线程的有效键值对数据拷贝到子线程,因此子线程可以复用父线程的局部存储。
Thread.java
1 | java复制代码// Thread 对象的实例数据 |
ThreadLocal.java
1 | java复制代码// 带 Map 的构造方法 |
InheritableThreadLocal 在拷贝父线程散列表的过程中,会调用 InheritableThreadLocal#childValue()
尝试转换为子线程需要的数据,默认是直接传递,可以重写这个方法修改拷贝的数据。
InheritableThreadLocal.java
1 | java复制代码public class InheritableThreadLocal<T> extends ThreadLocal<T> { |
下面,我们来分析 ThreadLocalMap 的源码。
后续源码分析,见下一篇文章:Java & Android 集合框架 #10 全网最全的 ThreadLocal 原理详细解析 —— 源码篇。
版权声明
本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
参考资料
- 数据结构与算法分析 · Java 语言描述(第 5 章 · 散列)—— [美] Mark Allen Weiss 著
- 算法导论(第 11 章 · 散列表)—— [美] Thomas H. Cormen 等 著
- 《阿里巴巴Java开发手册》 杨冠宝 编著
- 数据结构与算法之美(第 18~22 讲) —— 王争 著,极客时间 出品
- ThreadLocal 和 ThreadLocalMap源码分析 —— KingJack 著
- Why 0x61c88647? —— Dr. Heinz M. Kabutz 著
推荐阅读
Java & Android 集合框架系列文章目录(2023/07/08 更新):
- #1 ArrayList 可以完全替代数组吗?
- #2 说一下 ArrayList 和 LinkedList 的区别?
- #3 CopyOnWriteArrayList 是如何保证线程安全的?
- #4 ArrayDeque:如何用数组实现栈和队列?
- #5 万字 HashMap 详解,基础(优雅)永不过时 —— 原理篇
- #6 万字 HashMap 详解,基础(优雅)永不过时 —— 源码篇
- #7 如何使用 LinkedHashMap 实现 LRU 缓存?
- #8 说一下 WeakHashMap 如何清理无效数据的?
- #9 全网最全的 ThreadLocal 原理详细解析 —— 原理篇
- #10 全网最全的 ThreadLocal 原理详细解析 —— 源码篇
数据结构与算法系列文章:跳转阅读
⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~
本文转载自: 掘金