Java 线程本地变量 ThreadLocal 详解
1. ThreadLocal 简介
先一起看一下 ThreadLocal 类的官方解释:
用大白话翻译过来,大体的意思是:
ThreadLoal 提供给了线程局部变量
。同一个 ThreadLocal 所包含的对象,在不同的 Thread 中有不同的副本。这里有几点需要注意:
-
因为每个 Thread 内有自己的实例副本,且
该副本只能由当前 Thread 使用
。这是也是 ThreadLocal 命名的由来。 -
既然每个 Thread 有自己的实例副本,且其它 Thread 不可访问,那就
不存在多线程间共享的问题
。
ThreadLocal 提供了线程本地的实例。它与普通变量的区别在于:每个使用该变量的线程都会初始化一个完全独立的实例副本
。ThreadLocal 变量通常被 private static
修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。
总的来说,ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景。
2. ThreadLocal 的使用
2.1 ThreadLocal 接口
ThreadLocal 类接口很简单,只有 4 个方法:initialValue()、get()、set(T)、remove()。
(1)initialValue()
protected T initialValue() {
return null;
}
该方法是一个 protected
的方法,显然是为了让子类重写而设计的。该方法返回当前线程在该线程局部变量的初始值,这个方法是一个延迟调用方法,在一个线程第一次调用 get() 时才执行,并且仅执行1次(即:线程第一次使用 get() 方法访问变量的时候才执行。如果线程先于 get() 方法调用 set(T) 方法,则不会在线程中再调用 initialValue() 方法)。ThreadLocal 中的缺省实现直接返回一个null。
(2)get()
该方法返回当前线程所对应的线程局部变量。
public T get() {
// 获取当前线程
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
// map存在时,获取value,getEntry中会判断key是不是为null
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 未获取到值,初始化
return setInitialValue();
}
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
// 当值为null或者key为null时进行处理
return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
if (k == null)
// 清除value,设置为null
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
从get方法的一系列逻辑我们可以看出,即使使用线程池,在每次get时也会将key为null的值清除掉。
(3)set(T value)
设置当前线程的线程局部变量的值。
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
(4)remove()
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
该方法是 JDK 5.0 新增的方法:将当前线程局部变量的值删除,目的是为了减少内存的占用。
需要指出的是,当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显式调用该方法清除线程的局部变量并不是必须的操作,但它可以加快内存回收的速度
。
2.2 ThreadLocal 应用
- 存储用户信息上下文
使用 ThreadLocal,在控制层拦截请求把用户信息存入 ThreadLocal,这样我们在任何一个地方,都可以取出 ThreadLocal 中存的用户数据。
- 数据库连接池
数据库连接池的连接交给 ThreadLoca 进行管理,保证当前线程的操作都是同一个 Connnection。
3. ThreadLocal 的实现原理
3.1 ThreadLocal 的原理是什么?
ThreadLocal 的 get()、set() 实现大同小异,我们以 set() 为例看一下 ThreadLocal.set(T)
方法:
public void set(T value) {
// 获取当前线程
Thread t = Thread.currentThread();
// 调用 getMap() 方法获取对应的 ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
ThreadLocalMap
是一个声明在 ThreadLocal 的静态内部类,Thread 类中定义了一个类型为ThreadLocal.ThreadLocalMap
的成员变量 threadLocals,也就是 ThreadLocalMap 实例化是在 Thread 内部,所以 getMap() 是直接返回 Thread.threadLocals
的这个成员变量。
public class Thread implements Runnable {
// ThreadLocal.ThreadLocalMap 是 Thread 的成员变量
ThreadLocal.ThreadLocalMap threadLocals = null;
}
看下 ThreadLocal 的内部类 ThreadLocalMap 源码,这里其实是个标准的 Map 实现(Map 的本质是一个个 <key,value> 形式的节点组成的数组),内部有一个元素类型为 Entry 的数组,用以存放线程可能需要的多个副本变量:
可以看到有个 Entry 内部静态类,它继承了 WeakReference,实际上 key 并不是 ThreadLocal 本身,而是它的一个弱引用,可以看到 Entry 的 key 继承了 WeakReference(弱引用),再来看一下 key 怎么赋值的:
public WeakReference(T referent) {
super(referent);
}
key的赋值,使用的是WeakReference的赋值。
总结
-
Thread 类有一个类型为 ThreadLocal.ThreadLocalMap 的成员变量 threadLocals,每个线程都有一个属于自己的 ThreadLocalMap。
-
ThreadLocalMap 内部维护着 Entry 数组,每个 Entry 代表一个完整的对象,key 是 ThreadLocal 的弱引用,value 是 ThreadLocal 的泛型值。
-
每个线程在往 ThreadLocal 里设置值的时候,都是往自己的 ThreadLocalMap 里存,读也是以某个 ThreadLocal 作为引用,在自己的 map 里找对应的 key,从而实现了线程隔离。
-
ThreadLocal 本身不存储值,它只是作为一个 key 来让线程往 ThreadLocalMap 里存取值。
3.2 为什么用 ThreadLocal 做 key?
ThreadLocalMap 为什么要用 ThreadLocal 做 key,而不是用 Thread 做 key?
理论上是可以在 ThreadLocal 下定义 Map,key 是 Thread,value 是 set 进去的值,但没那么优雅。这个做法实际上就是所有的线程都访问 ThreadLocal 的 Map,而 key 是当前线程。
但这有点小问题,一个线程是可以拥有多个私有变量的,那 key 如果是当前线程的话,意味着还需要做点「手脚」来唯一标识 set 进去的 value。
假如上一步解决了,还是有个问题:并发足够大时,意味着所有的线程都去操作同一个 Map,Map 体积就很有可能会膨胀,导致访问性能下降。这个 Map 维护着所有线程的私有变量,意味着你不知道什么时候可以「销毁」。
现在 JDK 实现的结构就不一样了,线程需要多个私有变量,那有多个 ThreadLocal 对象就足够了,对应的 Map 体积不会太大。只要线程销毁了,ThreadLocalMap 也会被销毁。
4. ThreadLocal 引发的内存泄漏
4.1 引用、强引用、软引用、弱引用、虚引用
- 引用
在JVM中,栈内存线程私有,存储了对象的引用,堆内存线程共享,存储了对象实例。
Object o = new Object();
这个 o,我们可以称之为对象引用,而 new Object() 我们可以称之为在内存中产生了一个对象实例。当写下 o=null 时,只是表示 o 不再指向堆中 object 的对象实例,不代表这个对象实例不存在了。
- 强引用
强引用是最常见的,只要把一个对象赋给一个引用变量,这个引用变量就是一个强引用,类似 “Object obj=new Object()” 这类的引用,只要对象没有被置 null,在 GC 时就不会被回收。
- 软引用
软引用相对弱化了一些,需要继承 SoftReference 实现。如果内存充足,只有软引用指向的对象不会被回收。如果内存不足了,只有软引用指向的对象就会被回收。
- 弱引用
弱引用又更弱了一些,需要继承 WeakReference 实现。只要发生GC,只有弱引用指向的对象就会被回收。
- 虚引用
最后就是虚引用,需要继承 PhantomReference 实现。它是最弱的一种引用关系。一个对象实例是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象实例被收集器回收时收到一个系统通知。
4.2 ThreadLocal 的内存泄露问题
(1)什么是内存泄露?
内存泄漏: 是指本应该被 GC 回收的无用对象没有被回收,导致内存空间的浪费,当内存泄露严重时会导致内存溢出
。Java 内存泄露的根本原因是:长生命周期的对象持有短生命周期对象的引用,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被 GC 回收。
内存溢出: 就是我们常说的OOM
(OutOfMemoryError)异常,简单理解就是内存不够
了,通常发生在程序申请的内存超出了JVM中可用内存的大小,就会抛出OOM异常
。在 JVM 内存区域中,除了程序计数器外其他的内存区域都有可能抛出 OOM 异常。
ThreadLocal 很好地解决了线程之间需要数据隔离的问题,同时也引入了另一个问题,在应用程序中通常会使用线程池来管理线程,那么线程的生命周期与应用程序的生命周期基本保持一致,如果线程的数量很多,随着程序的运行,时间的推移,ThreadLocal 类型的变量会越来越多,将会占用非常大的内存空间,从而产生内存泄漏,如果这些对象一直不被释放的话,可能会导致内存溢出。
(2)ThreadLocal 的内存泄露分析
从图中可以看出,ThreadLocal 对象存在于堆中,有栈中的强引用指向它,也有 ThreadLocalMap 中 Entry 的 key 的弱引用键指向它。
而随着程序的运行,栈中 ThreadLocal 的强引用会消亡,只剩下弱引用连接着 ThreadLocal 独享,由于 ThreadLocalMap.Entity 中的 key 是弱引用,所以堆中的 ThreadLocal 对象会被回收(只要发生 GC,弱引用对象就会被回收),但是 ThreadLocalMap ⽣命周期和 Thread 是⼀样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap 的 key 没了,value 还在,这就会造成了内存泄漏问题(由弱引用引起的内存泄漏)。
而对于线程来说,线程的生命周期与应用程序的生命周期基本保持一致,所以一直会存在:Current Thread Refefence -> Thread -> ThreaLocalMap -> Entry -> value -> Object 的强引用,这样 value 所强引用的 Object 对象迟迟得不到回收,就会导致内存泄漏。
(3)如何解决内存泄露问题?
ThreadLocalMap 的设计中已经考虑到这种情况,在 ThreadLocal 的 get()、set()、remove() 的时候都会调用 expungeStaleEntry() 方法清除线程 ThreadLocalMap 里所有 key 为 null
的 value。
综上所述,内存泄漏应该只会存在于线程池数量较大且存储在ThreadLocal中的数据量较大时,但是手动调用 remove() 可以加快内存的释放
,所以还是推荐手动调用的。
使用完 ThreadLocal 后,及时调用 remove() 方法释放内存空间。
ThreadLocal<String> localVariable = new ThreadLocal();
try {
localVariable.set("张三”);
……
} finally {
localVariable.remove();
}
(4)为什么 key 还要设计成弱引用而不是强引用?
key 使用强引用:即便我们在代码中显式的对 ThreadLocal 对象实例的引用置为 null,告诉 GC 要垃圾回收该对象。但是 ThreadLocalMap 还持有这个 ThreadLocal 对象实例的强引用,如果没有手动删除, ThreadLocal 的对象实例不会被回收,导致 Entry 内存泄漏。
而 key 设置为弱引用,GC 扫描到时, 发现 ThreadLocal 没有强引用, 会回收该 ThreadLocal 对象,可以预防大多数内存泄漏的情况。
(5)ThreadLocal 为什么建议用 static 修饰?
首先明确一下 static 修饰的变量的生命周期:static 修饰的变量是在类在加载时就分配地址了,在类卸载才会被回收。
ThreadLocal 的原理是在 Thread 内部有一个 ThreadLocalMap 的集合对象,它的 key 是 ThreadLocal,value 就是你要存储的变量副本,不同的线程他的 ThreadLocalMap 是隔离开的,如果变量 ThreadLocal 是非 static 的就会造成每次生成实例都要生成不同的 ThreadLocal 对象,虽然这样程序不会有什么异常,但是会浪费内存资源,造成内存泄漏。
所以建议 ThreadLocal 用 static 修饰。
(6)总结
-
JVM 利用设置 ThreadLocalMap 的 Key 为弱引用,来避免内存泄露。
-
JVM 利用调用 remove、get、set 方法的时候,回收弱引用。
-
当 ThreadLocal 存储很多 Key 为 null 的 Entry 的时候,而不再去调用 remove、get、set 方法,那么将导致内存泄漏。
-
使用 线程池+ ThreadLocal 时要小心,因为这种情况下,线程是一直在不断的重复运行的,从而也就造成了 value 可能造成累积的情况。
5. ThreadLocalMap
5.1 ThreadLocalMap 的基本结构
ThreadLocalMap 虽然被叫做 map,但它没有实现 map 接口。ThreadLocalMap 的数据结构实际上是数组,对比 HashMap 它只有散列数组
没有链表,主要关注的是两个要素:元素数组
和散列方法
。
- 元素数组
一个 table 数组,存储 Entry 类型的元素,Entry 是 ThreaLocal 的弱引用作为 key,Object 作为 value 的结构。
private Entry[] table;
- 散列方法
散列方法就是怎么把对应的key映射到table数组的相应下标,ThreadLocalMap 用的是哈希取余法,取出 key 的 threadLocalHashCode,然后和 table 数组长度减一&运算(相当于取余)。
int i = key.threadLocalHashCode & (table.length - 1);
每创建一个 ThreadLocal 对象,threadLocalHashCode
就会新增 0x61c88647
,这个值很特殊,它是斐波那契数
也叫黄金分割数
。hash 增量为 这个数字,带来的好处就是 hash 分布非常均匀。
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
5.2 ThreadLocalMap 是怎么解决 Hash 冲突的?
HashMap 使用了链表来解决冲突,也就是所谓的链地址法(这种方法的基本思想是将所有哈希地址为 i 的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第 i 个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。为了避免 hash 洪水攻击,1.8 版本开始还引入了红黑树)
ThreadLocalMap 没有使用链表,自然也不是用链地址法来解决冲突了,它用的是另外一种方式 - 开放定址法(出现冲突后依次向后查找一个空位置存放)。
如上图所示,如果我们插入一个 value = 27 的数据,通过 hash 计算后应该落入第 4 个槽位中,而槽位 4 已经有了 Entry 数据,而且 Entry 数据的 key 和当前不相等。此时就会线性向后查找,一直找到 Entry 为 null 的槽位才会停止查找,把元素放到空的槽中。
在 get 的时候,也会根据 ThreadLocal 对象的 hash 值,定位到 table 中的位置,然后判断该槽位 Entry 对象中的 key 是否和 get 的 key 一致,如果不一致,就判断下一个位置。
5.3 ThreadLocalMap 是怎么扩容的?
在 ThreadLocalMap.set() 方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中 Entry
的数量已经达到了列表的扩容阈值 (len*2/3)
,就开始执行 rehash()
逻辑:
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
再着看 rehash() 具体实现:这里会先去清理过期的 Entry,然后还要根据条件判断 size >= threshold - threshold / 4
也就是 size >= threshold* 3/4
来决定是否需要扩容。
private void rehash() {
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}
private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get() == null)
expungeStaleEntry(j);
}
}
接着看看具体的 resize() 方法,扩容后的 newTab
的大小为老数组的两倍,然后遍历老的 table
数组,散列方法重新计算位置,开放地址解决冲突,然后放到新的 newTab
,遍历完成之后,oldTab
中所有的 entry
数据都已经放入到 newTab
中了,然后 table
引用指向 newTab
。
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}
setThreshold(newLen);
size = count;
table = newTab;
}
6. 父子线程如何共享数据?
前面介绍的 ThreadLocal 都是在一个线程中保存和获取数据的。
但在实际工作中,有可能是在父子线程中共享数据的。即在父线程中往 ThreadLocal 设置了值,在子线程中能够获取到。
例如:
public class ThreadLocalTest {
public static void main(String[] args) {
ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
threadLocal.set(6);
System.out.println("父线程获取数据:" + threadLocal.get());
new Thread(() -> {
System.out.println("子线程获取数据:" + threadLocal.get());
}).start();
}
}
执行结果:
父线程获取数据:6
子线程获取数据:null
你会发现,在这种情况下使用 ThreadLocal 是行不通的。main 方法是在主线程中执行的,相当于父线程。在 main 方法中开启了另外一个线程,相当于子线程。
显然通过 ThreadLocal,无法在父子线程中共享数据。
那么,该怎么办呢?
答:使用 InheritableThreadLocal,它是 JDK 自带的类,继承了 ThreadLocal 类。
修改代码之后:
public class ThreadLocalTest {
public static void main(String[] args) {
InheritableThreadLocal<Integer> threadLocal = new InheritableThreadLocal<>();
threadLocal.set(6);
System.out.println("父线程获取数据:" + threadLocal.get());
new Thread(() -> {
System.out.println("子线程获取数据:" + threadLocal.get());
}).start();
}
}
执行结果:
父线程获取数据:6
子线程获取数据:6
果然,在换成 InheritableThreadLocal 之后,在子线程中能够正常获取父线程中设置的值。
其实,在 Thread 类中除了成员变量 threadLocals 之外,还有另一个成员变量:inheritableThreadLocals。
Thread 类的部分代码如下:
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
最关键的一点是,在它的 init 方法中会将父线程中往 ThreadLocal 设置的值,拷贝一份到子线程中。
7. ThreadLocal 有哪些用途?
-
在spring事务中,保证一个线程下,一个事务的多个操作拿到的是一个Connection。
-
在hiberate中管理session。
-
在JDK8之前,为了解决SimpleDateFormat的线程安全问题。
-
获取当前登录用户上下文。
-
临时保存权限数据。
-
使用MDC保存日志信息。