Java 并发编程之美(四):深入剖析 ThreadLocal

前言

想必很多朋友对 ThreadLocal 并不陌生,而本人也在项目中应用到了 ThreadLocal,今天我们就来一起探讨下 ThreadLocal 的使用方法和实现原理。首先,本文先谈一下对 ThreadLocal 的理解,然后根据 ThreadLocal 类的源码分析了其实现原理和使用需要注意的地方,最后给出了 ThreadLocal 应用场景。

对 ThreadLocal 的理解

ThreadLocal 为解决多线程的并发问题提供了一种新的思路。ThreadLocal,顾名思义是线程的一个本地化对象,当工作于多线程中的对象使用 ThreadLocal 维护变量时,ThreadLocal 为每个使用该变量的线程分配一个独立的变量副本,所以每一个线程都可以独立的改变自己的副本,而不影响其他线程所对应的副本。从线程的角度看,这个变量就像是线程的本地变量。

ThreadLocal 是一个本地线程副本变量工具类。主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不同的变量值完成操作的场景。

2184951-9611b7b31c9b2e20

从上面的结构图,我们已经窥见 ThreadLocal 的核心机制:

  • 每个 Thread 线程内部都有一个 Map。
  • Map 里面存储线程本地对象(key)和线程的变量副本(value)
  • 但是,Thread 内部的 Map 是由 ThreadLocal 维护的,由 ThreadLocal 负责向 Map 获取和设置线程的变量值。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Scratch {
// 创建一个 String 型的线程本地变量, 设置初始值 "Hello World!"
public static final ThreadLocal<String> localVariable = ThreadLocal.withInitial(() -> "Hello World!");

public static void main(String[] args) {
Thread threadOne = new Thread(() -> {
localVariable.set("I'm variable in threadOne");
System.out.println(Thread.currentThread().getName() + ":" + localVariable.get());
});

Thread threadTwo = new Thread(() -> {
localVariable.set("I'm variable in threadTwo");
System.out.println(Thread.currentThread().getName() + ":" + localVariable.get());
});

threadOne.start();
threadTwo.start();
}
}

// Thread-0:I'm variable in threadOne
// Thread-1:I'm variable in threadTwo

虽然程序里面是操作的同一个变量 localVariable,但是不同线程都有自己的一份拷贝。

15500714012019

深入解析 ThreadLocal 类

先了解一下 ThreadLocal 类提供的几个方法:

1
2
3
4
5
6
7
8
9
10
11
// get() 方法用于获取当前线程的副本变量值
public T get()

// set() 方法用于保存当前线程的副本变量值
public void set(T value)

// remove() 方法移除当前前程的副本变量值
public void remove()

// initialValue() 为当前线程初始副本变量值 [一个 protected 方法,一般是用来在使用时进行重写的]
protected T initialValue()

public T get()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public T get() {
Thread t = Thread.currentThread();
// 1. 获取当前线程的 ThreadLocalMap 对象 threadLocals
ThreadLocalMap map = getMap(t);
if (map != null) {
// 2. 从 map 中获取线程存储的 K-V Entry 节点
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null)
// 3. 从 Entry 节点获取存储的 Value 副本值返回.
return (T) e.value;
}
// 4.map 为空的话返回初始值 null, 即线程变量副本为 null, 在使用时需要注意判断 NullPointerException.
return setInitialValue();
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

protected T initialValue() {
return null;
}

public void set(T value)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void set(T value) {
Thread t = Thread.currentThread();
// 1. 获取当前线程的 ThreadLocalMap 对象 threadLocals
ThreadLocalMap map = getMap(t);
if (map != null)
// 2.map 非空, 则重新将 ThreadLocal 和新的 value 副本放入到 map 中.
map.set(this, value);
else
// 3.map 空, 则对线程的成员变量 ThreadLocalMap 进行初始化创建, 并将 ThreadLocal 和 value 副本放入 map 中.
createMap(t, value);
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

public void remove()

1
2
3
4
5
6
7
8
9
10
11
public void remove() {
// 1. 获取当前线程的 ThreadLocalMap 对象 threadLocals
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
// 2. 从 map 中删除该 K-V Entry 节点
m.remove(this);
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

Hash 冲突怎么解决?

和 HashMap 的最大的不同在于,ThreadLocalMap 结构非常简单,没有 next 引用,也就是说 ThreadLocalMap 中解决 Hash 冲突的方式并非链表的方式,而是采用线性探测(开放寻址法)的方式,所谓线性探测,就是根据初始 Key 的 Hashcode 值确定元素在 table 数组中的位置,如果发现这个位置上已经有其他 Key 值的元素被占用,则利用固定的算法寻找一定步长的下个位置,依次判断,直至找到能够存放的位置。

ThreadLocalMap 解决 Hash 冲突的方式就是简单的步长加 1 或减 1,寻找下一个相邻的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Increment i modulo len.
*/
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

/**
* Decrement i modulo len.
*/
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}

显然 ThreadLocalMap 采用线性探测的方式解决 Hash 冲突的效率很低,如果有大量不同的 ThreadLocal 对象放入 Map 中时发生冲突,或者发生二次冲突,则效率很低。

所以这里引出的良好建议是:每个线程只存一个变量,这样的话所有的线程存放到 Map 中的 Key 都是相同的 ThreadLocal,如果一个线程要保存多个变量,就需要创建多个 ThreadLocal,多个 ThreadLocal 放入 Map 中时会极大的增加 Hash 冲突的可能。

如何避免泄漏

ThreadLocalMap 原理

7432604-072ea1eed5e63601

ThreadLocal 的原理:每个 Thread 内部维护着一个 ThreadLocalMap(初始容量 16,负载因子 2/3,解决冲突的方法是再 hash 法),它是一个 Map。这个映射表的 Key 是一个弱引用,其实就是 ThreadLocal 本身,Value 是真正存的线程变量 Object。也就是说 ThreadLocal 本身并不真正存储线程的变量值,它只是一个工具,用来维护 Thread 内部的 Map,帮助存和取。注意上图的虚线,它代表一个弱引用类型,而弱引用的生命周期只能存活到下次 GC 前。

ThreadLocal 为什么会内存泄漏?

ThreadLocal 在 ThreadLocalMap 中是以一个弱引用身份被 Entry 中的 Key 引用的,因此如果 ThreadLocal 没有外部强引用来引用它,那么 ThreadLocal 会在下次 JVM 垃圾收集时被回收。这个时候就会出现 Entry 中 Key 已经被回收,出现一个 null Key 的情况,外部读取 ThreadLocalMap 中的元素是无法通过 null Key 来找到 Value 的。因此如果当前线程的生命周期很长,一直存在,那么其内部的 ThreadLocalMap 对象也一直生存下来,这些 null Key 就存在一条强引用链的关系一直存在:Thread–>ThreadLocalMap–>Entry–>Value,这条强引用链会导致 Entry 不会回收,Value 也不会回收,但 Entry 中的 Key 却已经被回收的情况,造成内存泄漏。

但是 JVM 团队已经考虑到这样的情况,并做了一些措施来保证 ThreadLocal 尽量不会内存泄漏:在 ThreadLocal 的 get()、set()、remove() 方法调用的时候会清除掉线程 ThreadLocalMap 中所有 Entry 中 Key 为 null 的 Value,并将整个 Entry 设置为 null,利于下次内存回收。

但这样也并不能保证 ThreadLocal 不会发生内存泄漏,例如:使用 static 的 ThreadLocal,延长了 ThreadLocal 的生命周期,可能导致的内存泄漏。分配使用了 ThreadLocal 又不再调用 get()、set()、remove() 方法,那么就会导致内存泄漏。

ThreadLocal 如何避免泄漏?

既然 Key 是弱引用,那么我们要做的事,就是在调用 ThreadLocal 的 get()、set() 方法时完成后再调用 remove() 方法,将 Entry 节点和 Map 的引用关系移除,这样整个 Entry 对象在 GCRoots 分析后就变成不可达了,下次 GC 的时候就可以被回收。如果使用 ThreadLocal 的 set() 方法之后,没有显示的调用 remove() 方法,就有可能发生内存泄露,所以养成良好的编程习惯十分重要,使用完 ThreadLocal 之后,记得调用 remove() 方法。

在使用线程池的情况下,没有及时清理 ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用 ThreadLocal 就跟加锁完要解锁一样,用完就清理。

应用场景

ThreadLocal 适用于如下两种场景:

  • 每个线程需要有自己单独的实例

  • 实例需要在多个方法中共享,但不希望被多线程共享

对于第一点,每个线程拥有自己实例,实现它的方式很多。例如可以在线程内部构建一个单独的实例。ThreadLocal 可以以非常方便的形式满足该需求。

对于第二点,可以在满足第一点(每个线程有自己的实例)的条件下,通过方法间引用传递的形式实现。ThreadLocal 使得代码耦合度更低,且实现更优雅。

ThreaLocal 的 JDK 文档中说明:ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread。如果我们希望通过某个类将状态(例如用户 ID、事务 ID)与线程关联起来,那么通常在这个类中定义 private static 类型的 ThreadLocal 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class ThreadContext {

private String userId;

private Long transactionId;

private static ThreadLocal<ThreadContext> threadLocal = ThreadLocal.withInitial(ThreadContext::new);

public static ThreadContext getThreadContext() {
return threadLocal.get();
}

public static void removeThreadContext() {
threadLocal.remove();
}

public String getUserId() {
return userId;
}

public void setUserId(String userId) {
this.userId = userId;
}

public Long getTransactionId() {
return transactionId;
}

public void setTransactionId(Long transactionId) {
this.transactionId = transactionId;
}
}

ThreadLocal 的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用。作用:提供一个线程内公共变量(比如本次请求的用户信息),减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度,或者为线程提供一个私有的变量副本,这样每一个线程都可以随意修改自己的变量副本,而不会对其他线程产生影响。

还记得 Hibernate 的 session 获取场景吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static final ThreadLocal<Session> threadLocal = new ThreadLocal<Session>();

// 获取 Session
public static Session getCurrentSession() {
Session session = threadLocal.get();
// 判断 Session 是否为空, 如果为空, 将创建一个 session, 并设置到本地线程变量中
try {
if (session == null && !session.isOpen()) {
if (sessionFactory == null) {
rbuildSessionFactory();// 创建 Hibernate 的 SessionFactory
} else {
session = sessionFactory.openSession();
}
}
threadLocal.set(session);
} catch (Exception e) {
// TODO: handle exception
}

return session;
}

为什么?每个线程访问数据库都应当是一个独立的 Session 会话,如果多个线程共享同一个 Session 会话,有可能其他线程关闭连接了,当前线程再执行提交时就会出现会话已关闭的异常,导致系统异常。此方式能避免线程争抢 Session,提高并发下的安全性。使用 ThreadLocal 的典型场景正如上面的数据库连接管理,线程会话管理等场景,只适用于独立变量副本的情况,如果变量为全局共享的,则不适用在高并发下使用。

总结

  • ThreadLocal 并不解决线程间共享数据的问题
  • ThreadLocal 通过隐式的在不同线程内创建独立实例副本避免了实例线程安全的问题
  • 每个线程持有一个 Map 并维护了 ThreadLocal 对象与具体实例的映射,该 Map 由于只被持有它的线程访问,故不存在线程安全以及锁的问题
  • ThreadLocalMap 的 Entry 对 ThreadLocal 的引用为弱引用,避免了 ThreadLocal 对象无法被回收的问题
  • ThreadLocalMap 的 set 方法通过调用 replaceStaleEntry 方法回收键为 null 的 Entry 对象的值(即为具体实例)以及 Entry 对象本身从而防止内存泄漏
  • ThreadLocal 适用于变量在线程间隔离且在方法间共享的场景

参考博文

[1]. ThreadLocal - 面试必问深度解析
[2]. 谈谈 Java 中的 ThreadLocal
[3]. Java 进阶(七)正确理解 Thread Local 的原理与适用场景


Java 并发编程之美系列


谢谢你长得那么好看,还打赏我!😘
0%