JUC学习笔记(五)-ThreadLocal学习
十三、ThreadLocal
13.1、ThreadLocal 简介
1、ThreadLocal 是什么?
ThreadLocal 能提供线程局部变量,这些变量与正常的变量不同,因为每一个线程在访问 ThreadLocal 实例的时候(通过 get 或 set 方法)都有自己独立初始化的变量副本,ThreadLocal 实例通常是类中的私有静态字段,使用它的目的是希望将状态(例如,用户 ID 或者事务 ID)关联起来。
总结:
- 线程并发
ThreadLocal运用于多线程环境下
- 传递数据
我们可以通过ThreadLocal在同一线程,不同组件中传递公共变量
- 线程隔离
每个线程的变量都是独立的,不会相互影响
2、ThreadLocal 能干什么?
ThreadLocal 能实现每一个线程都有自己专属的本地变量副本,主要解决了让每个线程绑定自己的值,通过使用 get()和 set() 方法,获取默认值或将其值更改为当前线程所存的副本的值从而避免了线程安全问题。
3、ThreadLocal 常用API介绍
返回该线程局部变量在当前线程副本中的值。如果该变量对于当前线程没有值,它首先被初始化调用 initialValue 方法得到返回的值。
1 | public T get() {} |
返回当前线程的这个线程局部变量的“初始值”。该方法将在线程第一次使用 get 方法访问变量时被调用
除非线程之前调用了set 方法,在这种情况下,initialValue 方法将不会被线程调用。
通常,这个方法在每个线程中最多调用一次,但是在后续调用 remove 和 get 的情况下,它可能会被再次调用。
1 | protected T initialValue() { |
删除当前线程局部变量的值。如果这个线程局部变量随后被当前线程调用了 get ,它的值将通过调用它的 initialValue 方法重新初始化,除非它的值在过渡期间被当前线程调用了 set 。这可能导致在当前线程中多次调用 initialValue 方法。
1 | public void remove() {} |
将当前线程的这个线程局部变量的副本设置为指定的值。大多数子类将不需要覆盖这个方法,仅仅依靠 initialValue 方法来设置线程局部变量的值
1 | public void set(T value) {} |
创建线程局部变量。变量的初始值是通过方法上 Supplier 的 get 方法来确定的。jdk1.8 才有的。
1 | public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {} |
13.2、ThreadLocal 简单使用
1、问题引出
需求:线程隔离
- 在多线程并发场景下,每个线程中的变量都是相互独立的
- 线程A,设置变量1,获取变量1
- 线程B,设置变量2,获取变量2
在没有引入ThreadLocal之前
1 |
|
运行程序,发现线程间没有实现隔离,有线程拿到了自己以外的线程资源。
2、引入ThreadLocal
set():将变量绑定到当前线程中
get():获取当前线程绑定的变量
在上面的程序中使用ThreadLocal代替
content
1 |
|
再次运行程序,发现已经实现线程隔离
13.3、ThreadLocal 和 synchronized的区别
synchronized | ThreadLocal | |
---|---|---|
原理 | 同步机制采用以时间换空间的方式。只提供一份变量,让不同的线程排队访问。 | ThreadLocal采用了以空间换时间的方式,为每一个线程都提供了一份变量的副本,从而实现同时访问而互不干扰 |
侧重点 | 多个线程之间访问资源的同步 | 多线程中让每个线程之间的数据相互隔离 |
总结:在上面的案例中,使用
synchronized
和ThreadLocal
都可以解决问题,但使用ThreadLocal
更为合适,因为这样可以使程序拥有更高的并发性。
13.4、ThreadLocal 的内部结构
探究实现线程数据隔离原理
1、常见的误解
如果我们不去看源代码的话,可能会猜测ThreadLocal是这样子设计的:每个ThreadLocal类都创建一个
Map
,然后用线程作为Map
的key
,要存储的局部变量作为Map
的value
,这样就能达到各个线程的局部变量隔离的效果。这是最简单的设计方法,JDK最早期的ThreadLocal就是这样设计的。
2、现在的设计
JDK后面优化了设计方案,在JDK8 中
ThreadLocal
的设计是:每个Thread
都维护一个ThreadLocalMap
,这个Map的key
是ThreadLocal
实例本身,value
才是真正要存储的值Object
。 具体过程如下:
- 每个Thread线程内部都有一个Map (ThreadLocalMap)
- Map里面存储ThreadLocal对象(key)和线程的变量副本(value)
- Thread内部的Map是由ThreadLocal维护的,由ThreadLocal负责向map获取和设置线程的变量值。
- 对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了副本的隔离,互不干扰。
JDK8中ThreadLocal设计图
3、这样设计的好处
从上图可以看出,JDK8后的设计和我们一开始猜测的设计正好相反,这样设计有如下优势:
- 这样设计之后每个
Map
存储的Entry
数量就会变少,因为之前的存储数量由Thread
的数量决定,现在是由ThreadLocal
的数量决定。 - 当
Thread
销毁之后,对应的ThreadLocalMap
也会随之销毁,能减少内存的使用。
13.5、ThreadLocal的核心方法源码
除了构造方法外,ThreadLocal还有以下4个对外暴露的方法
方法 | 描述 |
---|---|
protected T initialValue() | 返回当前线程局部变量的初始值 |
public void set( T value) | 设置当前线程绑定的局部变量 |
public T get() | 获取当前线程绑定的局部变量 |
public void remove() | 移除当前线程绑定的局部变量 |
1、ThreadLocalMap相关方法
ThreadLocal中有一个静态内部类
ThreadLocalMap
,其中ThreadLocalMap
内部维护了一个Entry 数组;除此之外,ThreadLocal中还有
getMap
、createMap
等一系列方法,这些方法与ThreadLocalMap
息息相关。
- ThreadLocalMap静态内部类
- ThreadLocalMap中的静态内部类Entry
1 | static class ThreadLocalMap { |
- ThreadLocal中的getMap方法
1 | /** |
- ThreadLocal中的createMap方法
1 | /** |
- setInitialValue方法
1 | /** |
2、set方法
源码及中文注释
1 | /** |
set方法代码执行流程如下
首先获取当前线程,并根据当前线程获取一个Map
如果获取的Map不为空,则将参数设置到Map中(当前ThreadLocal的引用作为key)
如果Map为空,则给该线程创建 Map,并设置初始值
3、get方法
源码及中文注释
1 | /** |
代码执行流程
首先获取当前线程,根据当前线程获取一个Map
如果获取的Map不为空,则在Map中以ThreadLocal的引用作为key来在Map中获取对应的value e,否则转到E
如果e不为null,则返回e.value,否则执行下一步操作
Map为空或者e为空,则通过initialValue函数获取初始值value,然后用ThreadLocal的引用和value作为firstKey和firstValue创建一个新的Map
get方法总结: 先获取当前线程的 ThreadLocalMap 变量,如果存在则返回值,不存在则创建并返回初始值。
4、remove方法
1 | /** |
代码执行流程
首先获取当前线程,并根据当前线程获取一个Map
如果获取的Map不为空,则移除当前ThreadLocal对象对应的entry
5、initialValue方法
1 | /** |
此方法的作用是 返回该线程局部变量的初始值。
这个方法是一个延迟调用方法,从上面的代码我们得知,在set方法还未调用而先调用了get方法时才执行,并且仅执行1次。
这个方法缺省实现直接返回一个null。
如果想要一个除null之外的初始值,可以重写此方法。(备注: 该方法是一个protected的方法,显然是为了让子类覆盖而设计的)
13.6、ThreadLocalMap源码分析
1、基本结构
ThreadLocalMap是ThreadLocal的内部类,没有实现Map接口,用独立的方式实现了Map的功能,其内部的Entry也是独立实现。
2、成员变量
1 | /** |
与HashMap类似,
INITIAL_CAPACITY
代表这个Map数组的初始容量,table
是一个Entry类型的数组,用于存储数据,size
代表表中存储的数目,threshold
代表扩容时对于size的阈值。
3、ThreadLocalMap中的存储结构Entry
ThreadLocalMap中的Entry类继承自
WeakReference
(弱引用),也就是key
(ThreadLocal) 是弱引用,其目的是将ThreadLocal 对象的生命周期和线程生命周期解绑。
1 | // 在ThreadLocalMap中,也是用Entry来保存K-V结构数据的。但是Entry中key只能是ThreadLocal对象,这点被Entry的构造方法已经限定死了 |
4、弱引用和内存泄漏
有些程序员在使用ThreadLocal的过程中会发现有内存泄漏的情况发生,就猜测这个内存泄漏跟Entry中使用了弱引用的key有关系。这个理解其实是不对的。
我们先来回顾这个问题中涉及的几个名词概念,再来分析问题
内存泄漏相关概念
- Memory overflow:内存溢出,没有足够的内存提供申请者使用。
- Memory leak:内存泄漏是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。内存泄漏的堆积终将导致内存溢出。
弱引用相关概念
Java中的引用有4种类型: 强、软、弱、虚。当前这个问题主要涉及到强引用和弱引用:
强引用(StrongReference), 就是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还“活着”,垃圾回收器就不会回收这种对象。
弱引用(WeakReference),垃圾回收器一旦发现了 只具有弱弓|用的对象,不管当前内存空间足够与否,都会回收它的内存。
如果key使用强引用
- 假设ThreadLocalMap中的key使用了强引用,那么会出现内存泄漏吗?
此时ThreadLocal的内存图(实线表示强引用)如下:
如果key使用弱引用
5、ThreadLocal内存泄漏总结
如果ThreadLocalMap中的key使用强引用,那么当指向ThreadLocal的引用被回收后,key指向的ThreadLocal实例将无法被回收(因为key为强引用),此时无法避免内存泄漏
如果ThreadLocalMap中的key使用弱引用,那么当指向ThreadLocal的引用被回收后,key指向的ThreadLocal实例直接被gc回收(因为key为弱引用),但由于此时Entry中的value不会被回收,因此仍然可能造成内存泄漏
根据刚才的分析,我们知道了:无论使用ThreadLocalMap中的key使用哪种类型引用都无法完全避免内存泄漏,跟使用弱引用没有关系。
ThreadLocal造成内存泄漏的根本原因是:由于ThreadLocalMap的生命周期和Thread一样长,如果没有手动删除对应Entry就会造成内存泄漏。
要避免内存泄漏有两种方式:
- 使用完ThreadLocal ,调用其remove方法删除对应的Entry,就能避免内存泄漏
- 使用完ThreadLocal ,当前Thread也随之运行结束相对第一种方式, 第二种方式显然更不好控制,特别是使用线程池的时候,线程结束是不会销毁的。
也就是说,只要记得在使用完ThreadLocal及时的调用remove ,无论key是强引用还是弱引用都不会有问题。
6、key使用弱引用的原因
事实上,在ThreadLocalMap中的set/getEntry方法中,会对key为null (也即是ThreadLocal为null )进行判断,如果key为nul的话,那么是会将value置为null的。
这就意味着使用完ThreadLocal , CurrentThread依然运行的前提下,就算忘记调用remove方法,弱引用比强引用可以多一层保障:弱引用的ThreadLocal会被回收,对应的value在下一次ThreadLocalMap调用set,get,remove中的任一方法的时候会被清除,从而避免内存泄漏。
13.7、ThreadLocalMap解决hash冲突的方法
ThreadLocal使用的是自定义的ThreadLocalMap,接下来我们来探究一下ThreadLocalMap的hash冲突解决方式。
1、回顾ThreadLocal的set方法
1 | public void set(T value) { |
- 获取当前线程,并获取当前线程的ThreadLocalMap实例(从getMap(Thread t)中很容易看出来)。
- 如果获取到的map实例不为空,调用map.set()方法,否则调用构造函数 ThreadLocal.ThreadLocalMap(this, firstValue)实例化map。
可以看出来线程中的ThreadLocalMap使用的是延迟初始化,在第一次调用get()或者set()方法的时候才会进行初始化。
2、ThreadLocalMap构造方法
1 | ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) { |
主要说一下计算索引,
firstKey.threadLocalHashCode
&(INITIAL_CAPACITY - 1)
- 关于
& (INITIAL_CAPACITY - 1)
,这是取模的一种方式,对于2的幂作为模数取模,用此代替%(2^n)
,这也就是为啥容量必须为2的幂,在这个地方也得到了解答。 - 关于
firstKey.threadLocalHashCode
:
1 | private final int threadLocalHashCode = nextHashCode(); |
这里定义了一个AtomicInteger类型,每次获取当前值并加上
HASH_INCREMENT
,HASH_INCREMENT
= 0x61c88647,这个值和斐波那契散列有关(这是一种乘数散列法,只不过这个乘数比较特殊,是32位整型上限2^32-1乘以黄金分割比例0.618…的值2654435769,用有符号整型表示就是-1640531527,去掉符号后16进制表示为0x61c88647),其主要目的就是为了让哈希码能均匀的分布在2的n次方的数组里, 也就是Entry[] table
中,这样做可以尽量避免hash冲突。
3、ThreadLocalMap中的set方法
ThreadLocalMap使用开发地址-线性探测法来解决哈希冲突,线性探测法的地址增量di = 1, 2, … 其中,i为探测次数。
该方法一次探测下一个地址,直到有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。假设当前table长度为16,也就是说如果计算出来key的hash值为14,如果table[14]上已经有值,并且其key与当前key不一致,那么就发生了hash冲突,这个时候将14加1得到15,取table[15]进行判断,这个时候如果还是冲突会回到0,取table[0],以此类推,直到可以插入。
按照上面的描述,可以把table看成一个环形数组。
先看一下线性探测相关的代码,从中也可以看出来table实际是一个环:
1 | private void set(ThreadLocal<?> key, Object value) { |
笔记参考来源如下: