15.ThreadLocal的作用
1 什么是ThreadLocal
在多线程环境中,如果多个线程同时访问某个变量,如果希望每个线程对共享变量的相关操作仅对自己可见 ,该如何做呢?对应的现实场景就是各有各家的炉灶,相互之间生火做饭互不影响,这就是ThreadLocal要干的事。
ThreadLocal为每个线程提供一个独立的空间,用来存储共享变量的副本,每个副本只会对共享变量的副本进行操作,线程之间互不影响。
我们先看一个例子:
public class ThreadLocalExample {
public final static ThreadLocal<String> string = ThreadLocal.withInitial(() -> "DEFAULT VALUE");
public static void main(String[] args) throws InterruptedException {
System.out.println(Thread.currentThread().getName() + ":INITIAL_VALUE->" + string.get());
string.set("Main Thread Value");
System.out.println(Thread.currentThread().getName() + ":BEFORE->" + string.get());
Thread t1 = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + ":T1->" + string.get());
string.set("T1 Thread Value");
System.out.println(Thread.currentThread().getName() + ":T1->" + string.get());
}, "t1");
Thread t2 = new Thread(() -> {
//第一个关注点
System.out.println(Thread.currentThread().getName() + ":T2->" + string.get());
string.set("T2 Thread Value");
System.out.println(Thread.currentThread().getName() + ":T2->" + string.get());
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
//第二个关注点
System.out.println(Thread.currentThread().getName() + ":AFTER->" + string.get());
}
}
这段代码虽然比较长,但是功能很简单, 首先定义了一个全局变量string,并初始化为"DEFAULT VALUE"。
在main()方法中,主要是main()线程和t1、t2两个子线程,分别获得和修改string变量的值。我们重点关注上面注释标注的两个位置,输出对应的分别是下面的①和②:
main:INITIAL_VALUE->DEFAULT VALUE
main:BEFORE->Main Thread Value
t1:T1->DEFAULT VALUE
t2:T2->DEFAULT VALUE//①第一个关注点输出结果,为什么是DEFAULT VALUE?
t1:T1->T1 Thread Value
t2:T2->T2 Thread Value
main:AFTER->Main Thread Value//②第二个关注点输出结果,为什么是Main Thread Value
我们发现,不同线程通过 string.set()方法设置的值,仅对当前线程可见,各个线程之间不会相互影响,这就是ThreadLocal的作用,它实现了不同线程之间的隔离,从而保证多线程对于共享变量操作的安全性。
2 ThreadLocal的应用
ThreadLocal最经典的应用是在日期方法里,SimpleDateFormat是非线程安全的,例如:
public class SimpleDateFormatExample {
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static Date parse(String strDate) throws ParseException {
return sdf.parse(strDate);
}
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(5);
for (int i = 0; i < 9; i++) {
executorService.execute(() -> {
try {
System.out.println(parse("2022-08-18 16:35:20"));
} catch (ParseException e) {
e.printStackTrace();
}
});
}
}
}
这里构建了一个线程池,通过9次循环让这个线程池去执行一个解析字符串的任务,运行上面的程序,会抛出multiple points的异常,为什么会这样呢?就是因为SimpleDateFormat是非线性安全的,SimpleDateFormat在使用的时候,需要为每个线程单独创建示例,如果有多个线程同时访问,则必须通过外部的同步机制来保护。
SimpleDateFormat继承了DateFormat类,而在DateFormat中定义了两个全局成员变量Calendar和NumberFormat,分别用来进行日期和数字的转化,而DateFormat本身也不是线程安全的。
public class SimpleDateFormat extends DateFormat {
protected Calendar calendar;
protected NumberFormat numberFormat;
}
在SimpleDateFormat类的subParse中 ,会用到numberFormat进行分析操作
if (obeyCount) {
...
number = numberFormat.parse(text.substring(0, start+count), pos);
} else {
number = numberFormat.parse(text, pos);
}
再看numberFormat.parse的实现类的部分代码:
//DecimalFormat类中
public Number parse(String text, ParsePosition pos) {
if (!subparse(text, pos, positivePrefix, negativePrefix, digitList, false, status)) {
return null;
}
...
if (digitList.fitsIntoLong(status[STATUS_POSITIVE], isParseIntegerOnly())) {
gotDouble = false;
longResult = digitList.getLong();
} else {
doubleResult = digitList.getDouble();
}
注意上面的digitList是一个全局变量。
再看上面的subparse()方法,该方法非常长,我们看几个关键位置:
private final boolean subparse(...){
...
digits.decimalAt = digits.count = 0;
....
backup = -1;
....
if (!sawDecimal) {
digits.decimalAt = digitCount; // Not digits.count!
}
digits.decimalAt += exponent;
}
导致报错的原因就是这里的subparse()方法,对全局变量digits的更新操作没有加锁,不满足原子性。假设ThreadA和B同时进入该方法,就会导致处理冲突。
为了解决该问题,我们可以通过ThreadLocal来保护该值。代码如下:
public class SimpleDateFormatSafetyExample {
private static final String DATEFORMAT = "yyyy-MM-dd HH:mm:ss";
private static ThreadLocal<DateFormat> dateFormatThreadLocal = new ThreadLocal<>();
private static DateFormat getDateFormat() { //每次从threadlocal中获取SimpleDateFormat实例
DateFormat df = dateFormatThreadLocal.get();
if (df == null) {
df = new SimpleDateFormat(DATEFORMAT);
dateFormatThreadLocal.set(df);
}
return df;
}
public static Date parse(String strDate) throws ParseException {
return getDateFormat().parse(strDate);
}
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(5);
for (int i = 0; i < 9; i++) {
executorService.execute(() -> {
try {
System.out.println(parse("2021-06-16 16:35:20"));
} catch (ParseException e) {
e.printStackTrace();
}
});
}
}
}
这里,每个线程通过parse()方法做格式转换时,都可以获得一个完全独立的SimpleDateFormat实例,由于线程不存在对于同一个共享实例的竞争,也就不存在线程安全的问题了。