引言
在处理复杂的并发问题时,我们经常需要面对多线程环境中数据一致性和线程安全的挑战。其中一种常见的解决方式就是使用锁或者其他同步机制,但是这往往会导致性能的下降。那么有没有一种方法,可以在保证线程安全的同时,又不会降低程序的性能呢?答案就是Java中的ThreadLocal。ThreadLocal为每个线程都提供了一份独立的变量副本,使得每个线程在使用该变量时,实际上都在操作自己的局部变量。这样既解决了多线程环境下的线程安全问题,又避免了同步带来的性能开销。在本篇博客中,我们将一起探索ThreadLocal的内部工作原理,以及如何在实际应用中正确地使用ThreadLocal来提高我们程序的性能和稳定性。
正如我们在 并发编程 | 线程安全-编写零错误代码 - 掘金 (juejin.cn) 所探讨的,为了在编程实践中更为安全、高效地利用并发技术,通常需要在设计和编码过程中纳入以下三种策略来进行考虑:互斥同步、非阻塞同步以及无同步。
我们来重新回顾下这三个方案:
互斥同步
的方法主要通过使用锁或者其他同步机制,来保证任何时刻只有一个线程可以访问共享数据,从而避免了数据竞态和不一致性的问题。非阻塞同步
方案则主要利用了原子操作,使得我们能在无需使用互斥锁的情况下实现线程间的同步,进而提高了系统的整体性能。无同步方案
是最极端的一种,它适用于那些可以容忍一定程度的数据不一致性的场景,通过完全避免同步操作来最大化性能。
带着这个问题往下思考,ThreadLocal属于哪个方案?
基础 | 理解ThreadLocal
什么是ThreadLocal
ThreadLocal是Java中的一个类,用于创建线程本地变量。每个线程都会创建一个独立的变量副本,每个副本对其他线程都是隔离的。这样,数据在每个线程间都不会互相影响,从而实现了线程安全的数据共享。
ThreadLocal的基本使用
在我开始详细解释ThreadLocal的基本使用方法之前,我先为你展示一段相关的代码,具体如下:
java
public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
int finalI = i;
threadPool.execute(() -> {
String date = date(finalI);
System.out.println(date);
});
}
threadPool.shutdown();
try {
if (!threadPool.awaitTermination(60, TimeUnit.SECONDS)) {
threadPool.shutdownNow();
}
} catch (InterruptedException e) {
threadPool.shutdownNow();
Thread.currentThread().interrupt();
}
long end = System.currentTimeMillis();
System.out.println("花费:" + (end - start) + "ms");
}
public static String date(int seconds) {
//参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
Date date = new Date(1000 * seconds);
return dateFormat.format(date);
}
我创建了一个线程池,并提交了1000个任务,让其对时间+I。现在暂停思考一下,你觉得会出现什么问题?好,我们来公布答案:
java
Connected to the target VM, address: '127.0.0.1:7250', transport: 'socket'
1970-01-01 08:00:03
1970-01-01 08:00:03
1970-01-01 08:00:03
1970-01-01 08:00:04
1970-01-01 08:00:05
....
花费:30ms
数据重复了,为什么?这个问题的原因是,SimpleDateFormat
内部有一个共享的 Calendar
对象,当调用 format()
或 parse()
方法时,它会更改这个对象的状态。如果同时有多个线程修改这个状态,就可能会得到错误的结果。现在,我们来解决这个问题。首先想到的解决办法就是,既然SimpleDateFormat
线程不安全 ,那我们把它弄成局部变量不就好了。我们改下代码,把全局变量dateFormat放到date()
方法里面,代码如下:
java
public static String date(int seconds) {
//参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
Date date = new Date(1000 * seconds);
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return dateFormat.format(date);
}
好,运行,结果如下:
java
Connected to the target VM, address: '127.0.0.1:7950', transport: 'socket'
1970-01-01 08:00:01
1970-01-01 08:00:05
1970-01-01 08:00:04
1970-01-01 08:00:09
...
花费:57ms
果然没问题。但是慢了一倍,原因是啥? 因为我们把dateFormat放到date()里面,等于说1000个线程创建了1000次对象,自然就慢了。还有没有别的办法?有,JDK8引入了DateTimeFormatter
,是线程安全的,而且功能更强大,易用性更好。我们来看下代码:
java
static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// ...略
public static String date(int seconds) {
return Instant.ofEpochSecond(seconds).atZone(ZoneId.systemDefault()).format(dateTimeFormatter);
}
我们来执行一下:
java
Connected to the target VM, address: '127.0.0.1:9637', transport: 'socket'
1970-01-01 08:00:04
1970-01-01 08:00:01
1970-01-01 08:00:07
1970-01-01 08:00:02
...
花费:65ms
问题倒是没问题....就是更慢了。还有没有别的方案?这时候就请出我们今天的主角ThreadLocal。代码如下:
java
static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = ThreadLocal.withInitial(() ->
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
// ...略
public static String date(int seconds) {
//参数的单位是毫秒,从1970.1.1 00:00:00 GMT计时
Date date = new Date(1000 * seconds);
SimpleDateFormat dateFormat = dateFormatThreadLocal.get();
return dateFormat.format(date);
}
执行!
java
Connected to the target VM, address: '127.0.0.1:9070', transport: 'socket'
1970-01-01 08:00:02
1970-01-01 08:00:05
1970-01-01 08:00:08
1970-01-01 08:00:04
...
花费:31ms
时间又回到30ms左右了。至此圆满结束。
好了,回到上面的问题,你是不是已经有答案了?我们来公布答案。ThreadLocal是一种避免共享的设计模式,它是一种无同步方案
。其实把dateFormat
写在date()
里面也是无同步方案
,只不过,它叫另一种名字------栈封闭
工作场景总不会用的这么简单吧?那我宁愿使用Java8的新特性。别急,我们接着往下看
ThreadLocal的常见使用场景
- 在复杂的链式调用中传递参数:工作中可能会遇到很长的调用链,例如:在A->B->C->D的调用链中,D需要使用A的参数,而为了避免一层层传递参数,可以使用ThreadLocal将参数存储在A中,然后在D中取出使用。
- 为每个线程生成独立的随机数或者其他类似的资源:在多线程编程中,可能需要为每个线程生成独享的对象,避免多线程下的竞争条件。在上面的例子中,就是给每个线程携带dateFormat。
你现在是不是有掌握工具的喜悦感?保持兴奋感,我们接着往下看
进阶 | 熟练ThreadLocal
ThreadLocal的内部实现机制
掌握一个工具的秘密,就要深入其内部。让我们一起揭开ThreadLocal的工作原理,让你编程更得心应手。
首先,我们先来回答两个关键的问题:
- 为什么ThreadLocal要用ThreadLocalMap来存储ThreadLocal对象?:因为一个Thread可能持有多个ThreadLocal。
- 为什么ThreadLocalMap由Thread持有?:在 Java 的实现方案里面,ThreadLocal 仅仅是一个代理工具类,内部并不持有任何与线程相关的数据,所有和线程相关的数据都存储在 Thread 里面,这样的设计容易理解。而从数据的亲缘性上来讲,ThreadLocalMap 属于 Thread 也更加合理。当然,主要目的是实现线程间数据的隔离,保证线程安全。每个线程拥有自己的ThreadLocalMap,互不干扰。这样的设计也便于线程结束后,垃圾回收器对ThreadLocalMap的回收,防止内存泄漏。
带着这两个问题,我们继续往下看
ThreadLocal的一些重要方法
首先是initialValue()
方法:
java
protected T initialValue() {
return null;
}
- 这个方法会返回当前线程的初始值,它是一个延迟加载的方法,只有调用
get()
才会触发。 - 当线程第一次使用
get()
方法访问变量时,将调用此方法,除非线程先前调用了set方法,在这种情况下,不会为线程调用本initialValue方法。 - 通常,每个线程最多调用一次此方法,但如果已经调用了remove()后,再调用get(),则可以再次调用此方法。
- 如果不重写本方法,这个方法会返回null。一般使用匿名内部类的方法来重写initialValue0方法,以便在后续使用中可以初始化副本对象。
接下来我们来看set()
方法。这个方法相对直接,其主要功能就是向内部赋予value值。然后,我们回过头来重新审视get()
方法。我们前面已经提到,get()
方法在执行之前会先调用initialValue()
方法。最后,我们讨论remove()
方法,这个方法的主要职责是清除ThreadLocal
对应的value值。
接下来我们来看下源码
高级 | 掌握ThreadLocal
ThreadLocal源码分析
首先是get()
方法:
java
public T get() {
// 获取当前执行的线程
Thread t = Thread.currentThread();
// 从当前线程中获取其持有的ThreadLocalMap
ThreadLocalMap map = getMap(t);
// 检查当前线程是否已有ThreadLocalMap
if (map != null) {
// 从ThreadLocalMap中获取与当前ThreadLocal关联的Entry
ThreadLocalMap.Entry e = map.getEntry(this);
// 检查Entry是否存在
if (e != null) {
// 存在则返回Entry中的值
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 如果当前线程的ThreadLocalMap不存在或者没有当前ThreadLocal的值,初始化一个值并返回
return setInitialValue();
}
代码已经附有详细注释,如果你对此感兴趣,可以进行阅读。同时,remove()
和set()
函数的实现也已展示,具体代码如下:
java
public void set(T value) {
// 获取当前执行的线程
Thread t = Thread.currentThread();
// 从当前线程中获取其持有的ThreadLocalMap
ThreadLocalMap map = getMap(t);
// 检查当前线程是否已有ThreadLocalMap
if (map != null) {
// 如果存在,将传入的值存入与当前ThreadLocal关联的Entry中
map.set(this, value);
} else {
// 如果不存在,创建一个新的ThreadLocalMap,并存入当前线程与传入值的映射关系
createMap(t, value);
}
}
public void remove() {
// 获取当前执行的线程
Thread t = Thread.currentThread();
// 从当前线程中获取其持有的ThreadLocalMap
ThreadLocalMap m = getMap(t);
// 检查当前线程是否已有ThreadLocalMap
if (m != null) {
// 如果存在,从ThreadLocalMap中移除与当前ThreadLocal关联的Entry
m.remove(this);
}
}
代码分析也告一段落了,是不是很简单。接下来我们根据所掌握的代码分析下面两个问题。
使用ThreadLocal的陷阱和注意事项
内存泄露问题
ThreadLocal是一个容器,它存储了线程局部变量,但是这个存储的线程局部变量对于线程来说是长期存在的,除非这个线程被销毁,否则这个变量将一直存在。在长时间运行的线程中,如果ThreadLocal被大量使用,而又没有被正确清理,就可能导致内存泄露问题。 在Java中,ThreadLocal的实现用的是线程的弱引用,而不是强引用,所以理论上不会出现内存泄漏。然而,如果我们在ThreadLocal中放入的对象是强引用对象,并且我们没有手动调用ThreadLocal的remove方法,那么ThreadLocal中的这个对象就有可能一直存在,即使ThreadLocal本身已经不存在了。这就可能导致内存泄露。
空指针异常
ThreadLocal为每个线程都保存了一个独立的变量副本,如果我们试图获取一个没有初始化的ThreadLocal变量,就可能引发空指针异常。在使用ThreadLocal时,我们通常会在ThreadLocal变量首次使用前调用ThreadLocal的initialValue()方法进行初始化,但如果我们忘记了这个步骤,或者在初始化后误删了变量,再试图访问它,就可能抛出NullPointerException。
注意事项总结
- 尽量在不使用ThreadLocal变量时调用其remove()方法,将其清除,避免可能的内存泄露。
- 在使用ThreadLocal变量前,确保已经进行了初始化,以避免空指针异常。
- 适当的设计和使用ThreadLocal变量,避免在不必要的情况下长期存储大对象,以降低内存压力。
结论
好,我们来做个总结。本章,我带你了解了究竟什么是ThreadLocal,并通过一个案例动手实践优化了线程不安全的代码,最终性能优于syncronized关键字。紧接着我为你讲解了其工作机制。并通过源码带你深入了解其背后的运行机制。最后基于源码回答了ThreadLocal最经常出现的两个问题。