Java多线程与高并发专题——ThreadLocal 适合用在哪些实际生产的场景中?
引入
通过上一篇ThreadLocal 是用来解决共享资源的多线程访问的问题吗?,我们对ThreadLocal 有了一个初步的认识,我们在深入学习一个工具之前,除了了解它是什么,还应该知道这个工具的作用,能带来哪些好处,而不是直接闷头进入工具的 API、用法等,否则就算我们把某个工具的用法学会了,也不知道应该在什么场景下使用。所以我们来看看究竟哪些场景下需要用到 ThreadLocal。
在通常的业务开发中,ThreadLocal 有两种典型的使用场景。
- 场景1,ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了线程安全。
- 场景2,ThreadLocal 用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景。每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念。
典型场景1
这种场景通常用于保存线程不安全的工具类,典型的需要使用的类就是 SimpleDateFormat。
场景介绍
在这种情况下,每个 Thread 内都有自己的实例副本,且该副本只能由当前 Thread 访问到并使用,相当于每个线程内部的本地变量,这也是 ThreadLocal 命名的含义。因为每个线程独享副本,而不是公用的,所以不存在多线程间共享的问题。
我们来做一个比喻,比如饭店要做一道菜,但是有 5 个厨师一起做,这样的话就很乱了,因为如果一个厨师已经放过盐了,假如其他厨师都不知道,于是就都各自放了一次盐,导致最后的菜很咸。
这就好比多线程的情况,线程不安全。我们用了 ThreadLocal 之后,相当于每个厨师只负责自己的一道菜,一共有 5 道菜,这样的话就非常清晰明了了,不会出现问题。
在上一篇我们有举了一个例子,但是很多小伙伴可能对它不够理解,我们这里深入的去探索一下。
SimpleDateFormat 的进化之路
2 个线程都要用到 SimpleDateFormat
下面我们用一个案例来说明这种典型的第一个场景。假设有个需求,即 2 个线程都要用到SimpleDateFormat。
代码如下所示:
/**
* 该类用于演示多线程环境下日期格式化的使用。
* 它创建了两个线程,每个线程调用 `date` 方法将给定的秒数转换为日期字符串并打印。
*/
public class ThreadLocalDemo01 {
/**
* 程序的入口点,创建并启动两个线程,每个线程调用 `date` 方法格式化日期。
*
* @param args 命令行参数,本程序未使用。
* @throws InterruptedException 如果线程在睡眠期间被中断。
*/
public static void main(String[] args) throws InterruptedException {
// 创建并启动第一个线程,传入一个 lambda 表达式作为线程的执行体
new Thread(() -> {
// 调用 date 方法将 1 秒转换为日期字符串
String date = new ThreadLocalDemo01().date(1);
// 打印格式化后的日期字符串
System.out.println(date);
}).start();
// 主线程睡眠 100 毫秒,确保第一个线程有足够的时间执行
Thread.sleep(100);
// 创建并启动第二个线程,传入一个 lambda 表达式作为线程的执行体
new Thread(() -> {
// 调用 date 方法将 2 秒转换为日期字符串
String date = new ThreadLocalDemo01().date(2);
// 打印格式化后的日期字符串
System.out.println(date);
}).start();
}
/**
* 将给定的秒数转换为日期字符串,格式为 "mm:ss"。
*
* @param seconds 要转换的秒数。
* @return 格式化后的日期字符串。
*/
public String date(int seconds) {
// 根据给定的秒数创建一个 Date 对象
Date date = new Date(1000 * seconds);
// 创建一个 SimpleDateFormat 对象,指定日期格式为 "mm:ss"
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");
// 使用 SimpleDateFormat 对象将 Date 对象格式化为字符串
return simpleDateFormat.format(date);
}
}
在以上代码中可以看出,两个线程分别创建了一个自己的 SimpleDateFormat 对象,这样一来,有两个线程,那么就有两个 SimpleDateFormat 对象,它们之间互不干扰,这段代码是可以正常运转的,运行结果是:
10 个线程都要用到 SimpleDateFormat
假设我们的需求有了升级,不仅仅需要 2 个线程,而是需要 10 个,也就是说,有 10 个线程同时对应10 个 SimpleDateFormat 对象。
我们就来看下面这种写法:
/**
* 该类用于演示多线程环境下日期格式化操作。
* 创建多个线程,每个线程将给定的秒数转换为日期格式并输出。
*/
public class ThreadLocalDemo02 {
/**
* 程序的入口点,创建并启动多个线程来格式化日期。
*
* @param args 命令行参数,在本程序中未使用。
* @throws InterruptedException 如果线程在睡眠时被中断。
*/
public static void main(String[] args) throws InterruptedException {
// 循环创建并启动10个线程
for (int i = 0; i < 10; i++) {
// 由于lambda表达式只能引用最终的局部变量,所以需要将i赋值给一个最终变量
int finalI = i;
// 创建一个新线程
new Thread(() -> {
// 调用date方法将秒数转换为日期字符串
String date = new ThreadLocalDemo02().date(finalI);
// 输出格式化后的日期字符串
System.out.println(date);
}).start();
// 线程休眠100毫秒,避免线程启动过于密集
Thread.sleep(100);
}
}
/**
* 将给定的秒数转换为日期字符串,格式为 "mm:ss"。
*
* @param seconds 要转换的秒数。
* @return 格式化后的日期字符串。
*/
public String date(int seconds) {
// 根据给定的秒数创建一个Date对象
Date date = new Date(1000 * seconds);
// 创建一个SimpleDateFormat对象,指定日期格式为 "mm:ss"
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");
// 使用SimpleDateFormat对象将Date对象格式化为字符串
return simpleDateFormat.format(date);
}
}
上面的代码利用了一个 for 循环来完成这个需求。for 循环一共循环 10 次,每一次都会新建一个线程,并且每一个线程都会在 date 方法中创建一个 SimpleDateFormat 对象,一共有 10 个线程,对应 10 个 SimpleDateFormat 对象。
代码的运行结果:
需求变成了 1000 个线程都要用到 SimpleDateFormat
但是线程不能无休地创建下去,因为线程越多,所占用的资源也会越多。假设我们需要 1000 个任务,那就不能再用 for 循环的方法了,而是应该使用线程池来实现线程的复用,否则会消耗过多的内存等资源。
在这种情况下,我们给出下面这个代码实现的方案:
/**
* 该类用于演示多线程环境下日期格式化的操作。
* 通过线程池提交多个任务,每个任务都会调用 date 方法格式化日期。
*/
public class ThreadLocalDemo03 {
// 创建一个固定大小为 16 的线程池
public static ExecutorService threadPool = Executors.newFixedThreadPool(16);
/**
* 程序的入口点,创建并提交多个任务到线程池。
*
* @param args 命令行参数
* @throws InterruptedException 如果线程在等待过程中被中断
*/
public static void main(String[] args) throws InterruptedException {
// 循环 1000 次,提交 1000 个任务到线程池
for (int i = 0; i < 1000; i++) {
// 由于 lambda 表达式只能捕获最终的局部变量,所以需要将 i 赋值给 final 变量
int finalI = i;
// 向线程池提交一个新的任务
threadPool.submit(new Runnable() {
/**
* 任务的执行逻辑,调用 date 方法格式化日期并打印结果。
*/
@Override
public void run() {
// 调用 date 方法格式化日期
String date = new ThreadLocalDemo03().date(finalI);
// 打印格式化后的日期
System.out.println(date);
}
});
}
// 关闭线程池,不再接受新任务
threadPool.shutdown();
}
/**
* 根据给定的秒数生成格式化的日期字符串。
*
* @param seconds 秒数
* @return 格式化后的日期字符串,格式为 "mm:ss"
*/
public String date(int seconds) {
// 根据给定的秒数创建一个 Date 对象
Date date = new Date(1000 * seconds);
// 创建一个 SimpleDateFormat 对象,用于格式化日期
SimpleDateFormat dateFormat = new SimpleDateFormat("mm:ss");
// 格式化日期并返回结果
return dateFormat.format(date);
}
}
可以看出,我们用了一个 16 线程的线程池,并且给这个线程池提交了 1000 次任务。每个任务中它做的事情和之前是一样的,还是去执行 date 方法,并且在这个方法中创建一个simpleDateFormat 对象。
程序的一种运行结果是(多线程下,运行结果不唯一):
00:00
00:13
00:11
00:07
00:08
00:03
00:10
00:15
... ...
16:39
16:38
16:37
16:34
16:32
16:31
16:30
16:28
程序运行结果正确,把从 00:00 到 16:39 这 1000 个时间给打印了出来,并且没有重复的时间。我们这段代码所做的本质还是每个任务都创建了一个simpleDateFormat 对象,也就是说,1000 个任务对应 1000 个 simpleDateFormat 对象。
但是这样做是没有必要的,因为这么多对象的创建是有开销的,并且在使用完之后的销毁同样是有开销的,而且这么多对象同时存在在内存中也是一种内存的浪费。
现在我们就来优化一下。既然不想要这么多的 simpleDateFormat 对象,最简单的就是只用一个就可以了。
所有的线程都共用一个 simpleDateFormat 对象
共享一个 simpleDateFormat 对象的情况:
/**
* 该类用于演示线程池并发格式化日期时可能出现的问题。
* 代码中使用了一个固定大小的线程池来并发执行日期格式化任务。
*/
public class ThreadLocalDemo04 {
/**
* 创建一个固定大小为16的线程池,用于并发执行任务。
*/
public static ExecutorService threadPool = Executors.newFixedThreadPool(16);
/**
* 定义一个静态的 SimpleDateFormat 对象,用于将日期格式化为 "mm:ss" 格式。
* 注意:SimpleDateFormat 不是线程安全的。
*/
static SimpleDateFormat dateFormat = new SimpleDateFormat("mm:ss");
/**
* 主方法,程序的入口点。
* 在该方法中,会向线程池提交1000个任务,每个任务都会调用 date 方法进行日期格式化。
*
* @param args 命令行参数
* @throws InterruptedException 如果线程在等待过程中被中断
*/
public static void main(String[] args) throws InterruptedException {
// 循环1000次,向线程池提交任务
for (int i = 0; i < 1000; i++) {
// 由于匿名内部类不能直接访问外部的局部变量,因此需要将 i 赋值给一个 final 变量
int finalI = i;
// 向线程池提交一个新的任务
threadPool.submit(new Runnable() {
/**
* 线程执行的任务,调用 date 方法格式化日期并打印结果。
*/
@Override
public void run() {
// 调用 date 方法格式化日期
String date = new ThreadLocalDemo04().date(finalI);
// 打印格式化后的日期
System.out.println(date);
}
});
}
// 关闭线程池,不再接受新的任务
threadPool.shutdown();
}
/**
* 根据传入的秒数生成一个日期对象,并将其格式化为 "mm:ss" 格式。
*
* @param seconds 秒数
* @return 格式化后的日期字符串
*/
public String date(int seconds) {
// 根据传入的秒数创建一个 Date 对象
Date date = new Date(1000 * seconds);
// 使用 dateFormat 将 Date 对象格式化为字符串
return dateFormat.format(date);
}
}
在代码中可以看出,其他的没有变化,变化之处就在于,我们把这个 simpleDateFormat 对象给提取了出来,变成 static 静态变量,需要用的时候直接去获取这个静态对象就可以了。
省略掉了创建1000 个 simpleDateFormat 对象的开销,看上去没有问题,但我们有不同的线程,并且线程会执行它们的任务。不同的任务所调用的simpleDateFormat 对象都是同一个,所以它们所指向的那个对象都是同一个,但是这样一来就会有线程不安全的问题。
加锁
既然出错的原因在于 simpleDateFormat 这个对象本身不是一个线程安全的对象,不应该被多个线程同时访问。
所以自然而然就能想到用 synchronized 来解决。
于是代码就修改成下面的样子:
/**
* 该类演示了使用线程池处理日期格式化任务,同时展示了如何安全地使用 SimpleDateFormat。
* 由于 SimpleDateFormat 不是线程安全的,因此在多线程环境中使用时需要进行同步处理。
*/
public class ThreadLocalDemo05 {
/**
* 创建一个固定大小为 16 的线程池,用于并发执行任务。
*/
public static ExecutorService threadPool = Executors.newFixedThreadPool(16);
/**
* 定义一个 SimpleDateFormat 对象,用于将日期格式化为 "mm:ss" 的字符串。
* 注意:SimpleDateFormat 不是线程安全的,需要在多线程环境中进行同步处理。
*/
static SimpleDateFormat dateFormat = new SimpleDateFormat("mm:ss");
/**
* 主方法,程序的入口点。
* 在该方法中,我们提交 1000 个任务到线程池,每个任务将调用 date 方法格式化一个日期。
*
* @param args 命令行参数,本程序未使用。
* @throws InterruptedException 如果线程在等待过程中被中断。
*/
public static void main(String[] args) throws InterruptedException {
// 循环 1000 次,提交 1000 个任务到线程池
for (int i = 0; i < 1000; i++) {
// 由于 lambda 表达式需要使用 final 变量,因此创建一个 final 副本
int finalI = i;
// 提交一个任务到线程池
threadPool.submit(new Runnable() {
/**
* 线程池中的任务执行方法。
* 在该方法中,我们调用 date 方法格式化一个日期,并打印结果。
*/
@Override
public void run() {
// 调用 date 方法格式化日期
String date = new ThreadLocalDemo05().date(finalI);
// 打印格式化后的日期
System.out.println(date);
}
});
}
// 关闭线程池,不再接受新的任务
threadPool.shutdown();
}
/**
* 根据给定的秒数生成一个日期对象,并将其格式化为 "mm:ss" 的字符串。
*
* @param seconds 从 1970 年 1 月 1 日开始的秒数。
* @return 格式化后的日期字符串。
*/
public String date(int seconds) {
// 根据给定的秒数创建一个 Date 对象
Date date = new Date(1000 * seconds);
// 用于存储格式化后的日期字符串
String s = null;
// 由于 SimpleDateFormat 不是线程安全的,因此需要进行同步处理
synchronized (ThreadLocalDemo05.class) {
// 使用 SimpleDateFormat 格式化日期
s = dateFormat.format(date);
}
// 返回格式化后的日期字符串
return s;
}
}
可以看出在 date 方法中加入了 synchronized 关键字,把 simpleDateFormat 的调用给上了锁。
这样运行这段代码的结果就是正常的,没有出现重复的时间。但是由于我们使用了 synchronized 关键字,就会陷入一种排队的状态,多个线程不能同时工作,这样一来,整体的效率就被大大降低了。有没有更好的解决方案呢?
我们希望达到的效果是,既不浪费过多的内存,同时又想保证线程安全。经过思考得出,可以让每个线程都拥有一个自己的 simpleDateFormat 对象来达到这个目的,这样就能两全其美了。
使用 ThreadLocal
那么,要想达到这个目的,我们就可以使用 ThreadLocal。
示例代码如下所示:
/**
* 该类用于演示 ThreadLocal 的使用,通过线程池并发处理日期格式化任务。
*/
public class ThreadLocalDemo06 {
// 创建一个固定大小为 16 的线程池
public static ExecutorService threadPool = Executors.newFixedThreadPool(16);
/**
* 程序入口点,通过线程池并发执行日期格式化任务。
*
* @param args 命令行参数
* @throws InterruptedException 如果线程在等待时被中断
*/
public static void main(String[] args) throws InterruptedException {
// 循环 1000 次,提交任务到线程池
for (int i = 0; i < 1000; i++) {
// 由于匿名内部类不能直接使用外部循环变量 i,因此需要将其赋值给一个 final 变量
int finalI = i;
// 向线程池提交一个任务
threadPool.submit(new Runnable() {
/**
* 线程执行的任务,调用 date 方法格式化日期并打印结果。
*/
@Override
public void run() {
// 调用 date 方法格式化日期
String date = new ThreadLocalDemo06().date(finalI);
// 打印格式化后的日期
System.out.println(date);
}
});
}
// 关闭线程池
threadPool.shutdown();
}
/**
* 根据给定的秒数生成一个日期字符串。
*
* @param seconds 从 1970 年 1 月 1 日开始的秒数
* @return 格式化后的日期字符串
*/
public String date(int seconds) {
// 根据给定的秒数创建一个 Date 对象
Date date = new Date(1000 * seconds);
// 从 ThreadLocal 中获取 SimpleDateFormat 实例
SimpleDateFormat dateFormat = ThreadSafeFormatter.dateFormatThreadLocal.get();
// 使用 SimpleDateFormat 格式化日期
return dateFormat.format(date);
}
}
/**
* 该类用于管理线程安全的 SimpleDateFormat 实例。
*/
class ThreadSafeFormatter {
// 定义一个 ThreadLocal 变量,用于为每个线程提供独立的 SimpleDateFormat 实例
public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<SimpleDateFormat>() {
/**
* 为每个线程初始化一个 SimpleDateFormat 实例。
*
* @return 初始化的 SimpleDateFormat 实例,格式为 "mm:ss"
*/
@Override
protected SimpleDateFormat initialValue() {
return new SimpleDateFormat("mm:ss");
}
};
}
在这段代码中,我们使用了 ThreadLocal 帮每个线程去生成它自己的 simpleDateFormat 对象,对于每个线程而言,这个对象是独享的。但与此同时,这个对象就不会创造过多,一共只有 16 个,因为线程只有 16 个。
而这个结果也是正确的,不会出现重复的时间。
它最大的变化就是,虽然任务有 1000 个,但是我们不再需要去创建 1000 个 simpleDateFormat 对象了。即便任务再多,最终也只会有和线程数相同的 simpleDateFormat 对象。这样既高效地使用了内存,又同时保证了线程安全。
以上就是第一种非常典型的适合使用 ThreadLocal 的场景。
典型场景2
每个线程内需要保存类似于全局变量的信息(例如在拦截器中获取的用户信息),可以让不同方法直接使用,避免参数传递的麻烦却不想被多线程共享(因为不同线程获取到的用户信息不一样)。
例如,用 ThreadLocal 保存一些业务内容(用户权限信息、从用户系统获取到的用户名、用户ID 等),这些信息在同一个线程内相同,但是不同的线程使用的业务内容是不相同的。
在线程生命周期内,都通过这个静态 ThreadLocal 实例的 get() 方法取得自己 set 过的那个对象,避免了将这个对象(如 user 对象)作为参数传递的麻烦。
我们用一个案例来演示:
/**
* 该类演示了ThreadLocal的使用,通过不同的服务类传递用户信息。
*/
public class ThreadLocalDemo07 {
/**
* 程序的入口点,启动服务调用链。
*
* @param args 命令行参数
*/
public static void main(String[] args) {
// 创建Service1实例并调用其service1方法
new Service1().service1();
}
}
/**
* Service1类负责初始化用户信息并将其存储到ThreadLocal中,然后调用Service2。
*/
class Service1 {
/**
* 该方法创建一个User对象,将其存储到ThreadLocal中,然后调用Service2的service2方法。
*/
public void service1() {
// 创建一个新的User对象,用户名是"chaos"
User user = new User("chaos");
// 将User对象存储到ThreadLocal中
UserContextHolder.holder.set(user);
// 创建Service2实例并调用其service2方法
new Service2().service2();
}
}
/**
* Service2类负责从ThreadLocal中获取用户信息并打印,然后调用Service3。
*/
class Service2 {
/**
* 该方法从ThreadLocal中获取User对象,打印用户名,然后调用Service3的service3方法。
*/
public void service2() {
// 从ThreadLocal中获取User对象
User user = UserContextHolder.holder.get();
// 打印从ThreadLocal中获取的用户名
System.out.println("Service2拿到用户名:" + user.name);
// 创建Service3实例并调用其service3方法
new Service3().service3();
}
}
/**
* Service3类负责从ThreadLocal中获取用户信息并打印,最后移除ThreadLocal中的用户信息。
*/
class Service3 {
/**
* 该方法从ThreadLocal中获取User对象,打印用户名,最后移除ThreadLocal中的User对象。
*/
public void service3() {
// 从ThreadLocal中获取User对象
User user = UserContextHolder.holder.get();
// 打印从ThreadLocal中获取的用户名
System.out.println("Service3拿到用户名:" + user.name);
// 从ThreadLocal中移除User对象,防止内存泄漏
UserContextHolder.holder.remove();
}
}
/**
* UserContextHolder类提供一个静态的ThreadLocal变量,用于存储当前线程的User对象。
*/
class UserContextHolder {
// 静态的ThreadLocal变量,用于存储User对象
public static ThreadLocal<User> holder = new ThreadLocal<>();
}
/**
* User类表示一个用户,包含用户名。
*/
class User {
// 用户的名称
String name;
/**
* 构造函数,用于初始化用户的名称。
*
* @param name 用户的名称
*/
public User(String name) {
this.name = name;
}
}
在这个代码中我们可以看出,我们有一个 UserContextHolder,里面保存了一个 ThreadLocal,在调用 Service1 的方法的时候,就往里面存入了 user 对象,而在后面去调用的时候,直接从里面用 get 方法取出来就可以了。
没有参数层层传递的过程,非常的优雅、方便。
代码运行结果:
总结
下面我们进行总结。
本文我们主要介绍了 ThreadLocal 的两个典型的使用场景。
- 场景1,ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,每个线程都只能修改自己所拥有的副本, 而不会影响其他线程的副本,这样就让原本在并发情况下,线程不安全的情况变成了线程安全的情况。
- 场景2,ThreadLocal 用作每个线程内需要独立保存信息的场景,供其他方法更方便得获取该信息,每个线程获取到的信息都可能是不一样的,前面执行的方法设置了信息后,后续方法可以通过 ThreadLocal直接获取到,避免了传参。