Wetts's blog

Stay Hungry, Stay Foolish.

0%

Java-并发-ThreadLocal的应用场景和注意事项.md

转自:https://blog.csdn.net/zzti_erlie/article/details/105322946

前言

ThreadLocal 主要有如下 2 个作用

  • 保证线程安全
  • 在线程级别传递变量

保证线程安全

最近一个小伙伴把项目中封装的日期工具类用在多线程环境下居然出了问题,来看看怎么回事吧

日期转换的一个工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DateUtil {

private static final SimpleDateFormat sdf =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static Date parse(String dateStr) {
Date date = null;
try {
date = sdf.parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
}

然后将这个工具类用在多线程环境下

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {

ExecutorService service = Executors.newFixedThreadPool(20);

for (int i = 0; i < 20; i++) {
service.execute(()->{
System.out.println(DateUtil.parse("2019-06-01 16:34:30"));
});
}
service.shutdown();
}

结果报异常了,因为部分线程获取的时间不对
1

这个异常就不从源码的角度分析了,写一个小 Demo,理解了这个小 Demo,就理解了原因

一个将数字加 10 的工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class NumUtil {

public static int addNum = 0;

public static int add10(int num) {
addNum = num;
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
return addNum + 10;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {

ExecutorService service = Executors.newFixedThreadPool(20);

for (int i = 0; i < 20; i++) {
int num = i;
service.execute(()->{
System.out.println(num + " " + NumUtil.add10(num));
});
}
service.shutdown();
}

然后代码的一部分输出为

1
2
3
4
5
0 28
3 28
7 28
11 28
15 28

什么鬼,不是加 10 么,怎么都输出了 28?这主要是因为线程切换的原因,线程陆续将 addNum 值设置为 0,3,7 但是都没有执行完(没有执行到 return addNum+10 这一步)就被切换了,当其中一个线程将 addNum 值设置为 18 时,线程陆续开始执行 addNum+10 这一步,结果都输出了 28。SimpleDateFormat 的原因和这个类似,那么我们如何解决这个问题呢?

解决方案

解决方案1:每次来都new新的,空间浪费比较大

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DateUtil {

public static Date parse(String dateStr) {
SimpleDateFormat sdf =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date date = null;
try {
date = sdf.parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
}

解决方案2:方法用synchronized修饰,并发上不来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DateUtil {

private static final SimpleDateFormat sdf =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static synchronized Date parse(String dateStr) {
Date date = null;
try {
date = sdf.parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
}

解决方案3:用jdk1.8中的日期格式类DateFormatter,DateTimeFormatter

1
2
3
4
5
6
7
8
9
public class DateUtil {

private static DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

public static LocalDateTime parse(String dateStr) {
return LocalDateTime.parse(dateStr, formatter);
}
}

解决方案4:用ThreadLocal,一个线程一个SimpleDateFormat对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DateUtil {

private static ThreadLocal<DateFormat> threadLocal = ThreadLocal.withInitial(
()-> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

public static Date parse(String dateStr) {
Date date = null;
try {
date = threadLocal.get().parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
}

上面的加 10 的工具类可以改成如下形式(主要为了演示 ThreadLocal 的使用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class NumUtil {

private static ThreadLocal<Integer> addNumThreadLocal = new ThreadLocal<>();

public static int add10(int num) {
addNumThreadLocal.set(num);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
return addNumThreadLocal.get() + 10;
}
}

现在2个工具类都能正常使用了,这是为啥呢?

原理分析

当多个线程同时读写同一共享变量时存在并发问题,如果不共享不就没有并发问题了,一个线程存一个自己的变量,类比原来好几个人玩同一个球,现在一个人一个球,就没有问题了,如何把变量存在线程上呢?其实 Thread 类内部已经有一个 Map 容器用来存变量了。它的大概结构如下所示
2

ThreadLocalMap 是一个 Map,key 是 ThreadLocal,value 是 Object

映射到源码就是如下所示:ThreadLocalMap 是 ThreadLocal 的一个静态内部类

1
2
3
public class Thread implements Runnable {
ThreadLocal.ThreadLocalMap threadLocals = null;
}

往 ThreadLocalMap 里面放值`

1
2
3
4
5
6
7
8
9
// ThreadLocal类里面的方法,将源码整合了一下
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals;
if (map != null)
map.set(this, value);
else
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

从 ThreadLocalMap 里面取值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ThreadLocal类里面的方法,将源码整合了一下
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals;
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

从 ThreadLocalMap 里面删除值

1
2
3
4
5
6
// ThreadLocal类里面的方法,将源码整合了一下
public void remove() {
ThreadLocalMap m = Thread.currentThread().threadLocals;
if (m != null)
m.remove(this);
}

执行如下代码

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
public class InfoUtil {

private static ThreadLocal<String> nameInfo = new ThreadLocal<>();
private static ThreadLocal<Integer> ageInfo = new ThreadLocal<>();

public static void setInfo(String name, Integer age) {
nameInfo.set(name);
ageInfo.set(age);
}

public static String getName() {
return nameInfo.get();
}

public static void main(String[] args) {
new Thread(() -> {
InfoUtil.setInfo("张三", 10);
// 张三
System.out.println(InfoUtil.getName());
}, "thread1").start();
new Thread(() -> {
InfoUtil.setInfo("李四", 20);
// 李四
System.out.println(InfoUtil.getName());
}, "thread2").start();
}
}

变量的结构如下图
3

在线程级别传递变量

假设有如下一个场景,method1()调用method2(),method2()调用method3(),method3()调用method4(),method1()生成了一个变量想在method4()中使用,有如下2种解决办法

  1. method 2 3 4的参数列表上都写上method4想要的变量
  2. method 1 往ThreadLocal中put一个值,method4从ThreadLocal中get出来

哪种实现方式比较优雅呢?相信我不说你也能明白了

我在生产环境中一般是这样用的,如果一个请求在系统中的处理流程比较长,可以对请求的日志打一个相同的前缀,这样比较方便处理问题

这个前缀的生成和移除可以配置在拦截器中,切面中,当然也可以在一个方法的前后

1
2
3
4
5
6
7
8
9
10
11
public class Main {

public static final ThreadLocal<String> SPANID =
ThreadLocal.withInitial(() -> UUID.randomUUID().toString());

public static void start() {
SPANID.set(UUID.randomUUID().toString());
// 方法调用过程中可以在日志中打印SPANID表明一个请求的执行链路
SPANID.remove();
}
}

当然Spring Cloud已经有现成的链路追踪组件了。

ThreadLocal使用注意事项

ThreadLocal如果使用不当会造成如下问题

  1. 脏数据
  2. 内存泄露

脏数据

线程复用会造成脏数据。由于线程池会复用 Thread 对象,因此 Thread 类的成员变量 threadLocals 也会被复用。如果在线程的 run() 方法中不显示调用 remove() 清理与线程相关的 ThreadLocal 信息,并且下一个线程不调用 set() 设置初始值,就可能 get() 到上个线程设置的值

内存泄露

1
2
3
4
5
6
7
8
9
10
11
static class ThreadLocalMap {

static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}

ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 没有外部强引用来引用它,那么系统 GC 的时候,这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些key为 null 的 Entry 的 value,如果当前线程再迟迟不结束的话,这些key为 null 的 Entry 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value 永远无法回收,造成内存泄漏

大白话一点,ThreadLocalMap 的 key 是弱引用,GC时会被回收掉,那么就有可能存在 ThreadLocalMap<null, Object> 的情况,这个 Object 就是泄露的对象

其实,ThreadLocalMap 的设计中已经考虑到这种情况,也加上了一些防护措施:在 ThreadLocal的get(),set(),remove() 的时候都会清除线程 ThreadLocalMap 里所有 key 为 null 的 value

解决办法

解决以上两个问题的办法很简单,就是在每次用完 ThreadLocal 后,及时调用 remove() 方法清理即可