

SimpleDateFormat 线程不安全的 5 种解决方案!
source link: https://www.techug.com/post/five-solutions-to-thread-insecurity-of-simpledateformat.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.


1.什么是线程不安全?
线程不安全也叫非线程安全,是指多线程执行中,程序的执行结果和预期的结果不符的情况就叫做线程不安全。
线程不安全的代码
SimpleDateFormat
就是一个典型的线程不安全事例,接下来我们动手来实现一下。首先我们先创建 10 个线程来格式化时间,时间格式化每次传递的待格式化时间都是不同的,所以程序如果正确执行将会打印 10 个不同的值,接下来我们来看具体的代码实现:
import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class SimpleDateFormatExample { // 创建 SimpleDateFormat 对象 private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss"); public static void main(String[] args) { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); // 执行 10 次时间格式化 for (int i = 0; i < 10; i++) { int finalI = i; // 线程池执行任务 threadPool.execute(new Runnable() { @Override public void run() { // 创建时间对象 Date date = new Date(finalI * 1000); // 执行时间格式化并打印结果 System.out.println(simpleDateFormat.format(date)); } }); } } }
我们预期的正确结果是这样的(10 次打印的值都不同):
然而,以上程序的运行结果却是这样的:
从上述结果可以看出,当在多线程中使用 SimpleDateFormat
进行时间格式化是线程不安全的。
2.解决方案
SimpleDateFormat
线程不安全的解决方案总共包含以下 5 种:
- 将
SimpleDateFormat
定义为局部变量; - 使用
synchronized
加锁执行; - 使用
Lock
加锁执行(和解决方案 2 类似); - 使用
ThreadLocal
; - 使用
JDK 8
中提供的DateTimeFormat
。
接下来我们分别来看每种解决方案的具体实现。
① 将 SimpleDateFormat 变为局部变量
将 SimpleDateFormat
定义为局部变量时,因为每个线程都是独享 SimpleDateFormat
对象的,相当于将多线程程序变成“单线程”程序了,所以不会有线程不安全的问题,具体实现代码如下:
import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class SimpleDateFormatExample { public static void main(String[] args) { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); // 执行 10 次时间格式化 for (int i = 0; i < 10; i++) { int finalI = i; // 线程池执行任务 threadPool.execute(new Runnable() { @Override public void run() { // 创建 SimpleDateFormat 对象 SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss"); // 创建时间对象 Date date = new Date(finalI * 1000); // 执行时间格式化并打印结果 System.out.println(simpleDateFormat.format(date)); } }); } // 任务执行完之后关闭线程池 threadPool.shutdown(); } }
以上程序的执行结果为:
当打印的结果都不相同时,表示程序的执行是正确的,从上述结果可以看出,将 SimpleDateFormat
定义为局部变量之后,就可以成功的解决线程不安全问题了。
② 使用 synchronized 加锁
锁是解决线程不安全问题最常用的手段,接下来我们先用 synchronized
来加锁进行时间格式化,实现代码如下:
import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class SimpleDateFormatExample2 { // 创建 SimpleDateFormat 对象 private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss"); public static void main(String[] args) { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); // 执行 10 次时间格式化 for (int i = 0; i < 10; i++) { int finalI = i; // 线程池执行任务 threadPool.execute(new Runnable() { @Override public void run() { // 创建时间对象 Date date = new Date(finalI * 1000); // 定义格式化的结果 String result = null; synchronized (simpleDateFormat) { // 时间格式化 result = simpleDateFormat.format(date); } // 打印结果 System.out.println(result); } }); } // 任务执行完之后关闭线程池 threadPool.shutdown(); } }
以上程序的执行结果为:
③ 使用 Lock 加锁
在 Java 语言中,锁的常用实现方式有两种,除了 synchronized
之外,还可以使用手动锁 Lock
,接下来我们使用 Lock
来对线程不安全的代码进行改造,实现代码如下:
import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; /** * Lock 解决线程不安全问题 */ public class SimpleDateFormatExample3 { // 创建 SimpleDateFormat 对象 private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss"); public static void main(String[] args) { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); // 创建 Lock 锁 Lock lock = new ReentrantLock(); // 执行 10 次时间格式化 for (int i = 0; i < 10; i++) { int finalI = i; // 线程池执行任务 threadPool.execute(new Runnable() { @Override public void run() { // 创建时间对象 Date date = new Date(finalI * 1000); // 定义格式化的结果 String result = null; // 加锁 lock.lock(); try { // 时间格式化 result = simpleDateFormat.format(date); } finally { // 释放锁 lock.unlock(); } // 打印结果 System.out.println(result); } }); } // 任务执行完之后关闭线程池 threadPool.shutdown(); } }
以上程序的执行结果为:
从上述代码可以看出,手动锁的写法相比于 synchronized
要繁琐一些。
④ 使用 ThreadLocal
加锁方案虽然可以正确的解决线程不安全的问题,但同时也引入了新的问题,加锁会让程序进入排队执行的流程,从而一定程度的降低了程序的执行效率,如下图所示:
那有没有一种方案既能解决线程不安全的问题,同时还可以避免排队执行呢?
答案是有的,可以考虑使用 ThreadLocal
。ThreadLocal
翻译为中文是线程本地变量的意思,字如其人 ThreadLocal
就是用来创建线程的私有(本地)变量的,每个线程拥有自己的私有对象,这样就可以避免线程不安全的问题了,实现如下:
知道了实现方案之后,接下来我们使用具体的代码来演示一下 ThreadLocal
的使用,实现代码如下:
import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * ThreadLocal 解决线程不安全问题 */ public class SimpleDateFormatExample4 { // 创建 ThreadLocal 对象,并设置默认值(new SimpleDateFormat) private static ThreadLocal<SimpleDateFormat> threadLocal = ThreadLocal.withInitial(() -> new SimpleDateFormat("mm:ss")); public static void main(String[] args) { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); // 执行 10 次时间格式化 for (int i = 0; i < 10; i++) { int finalI = i; // 线程池执行任务 threadPool.execute(new Runnable() { @Override public void run() { // 创建时间对象 Date date = new Date(finalI * 1000); // 格式化时间 String result = threadLocal.get().format(date); // 打印结果 System.out.println(result); } }); } // 任务执行完之后关闭线程池 threadPool.shutdown(); } }
以上程序的执行结果为:
ThreadLocal 和局部变量的区别
首先来说 ThreadLocal
不等于局部变量,这里的“局部变量”指的是像 2.1 示例代码中的局部变量, ThreadLocal
和局部变量最大的区别在于:ThreadLocal
属于线程的私有变量,如果使用的是线程池,那么 ThreadLocal
中的变量是可以重复使用的,而代码级别的局部变量,每次执行时都会创建新的局部变量,二者区别如下图所示:
更多关于 ThreadLocal
的内容,可以访问磊哥前面的文章《ThreadLocal不好用?那是你没用对!》。
⑤ 使用 DateTimeFormatter
以上 4 种解决方案都是因为 SimpleDateFormat
是线程不安全的,所以我们需要加锁或者使用 ThreadLocal
来处理,然而,JDK 8
之后我们就有了新的选择,如果使用的是 JDK 8+
版本,就可以直接使用 JDK 8
中新增的、安全的时间格式化工具类 DateTimeFormatter
来格式化时间了,接下来我们来具体实现一下。
使用 DateTimeFormatter
必须要配合 JDK 8
中新增的时间对象 LocalDateTime
来使用,因此在操作之前,我们可以先将 Date
对象转换成 LocalDateTime
,然后再通过 DateTimeFormatter
来格式化时间,具体实现代码如下:
import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * DateTimeFormatter 解决线程不安全问题 */ public class SimpleDateFormatExample5 { // 创建 DateTimeFormatter 对象 private static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("mm:ss"); public static void main(String[] args) { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); // 执行 10 次时间格式化 for (int i = 0; i < 10; i++) { int finalI = i; // 线程池执行任务 threadPool.execute(new Runnable() { @Override public void run() { // 创建时间对象 Date date = new Date(finalI * 1000); // 将 Date 转换成 JDK 8 中的时间类型 LocalDateTime LocalDateTime localDateTime = LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()); // 时间格式化 String result = dateTimeFormatter.format(localDateTime); // 打印结果 System.out.println(result); } }); } // 任务执行完之后关闭线程池 threadPool.shutdown(); } }
以上程序的执行结果为:
3.线程不安全原因分析
要了解 SimpleDateFormat
为什么是线程不安全的?我们需要查看并分析 SimpleDateFormat
的源码才行,那我们先从使用的方法 format
入手,源码如下:
private StringBuffer format(Date date, StringBuffer toAppendTo, FieldDelegate delegate) { // 注意此行代码 calendar.setTime(date); boolean useDateFormatSymbols = useDateFormatSymbols(); for (int i = 0; i < compiledPattern.length; ) { int tag = compiledPattern[i] >>> 8; int count = compiledPattern[i++] & 0xff; if (count == 255) { count = compiledPattern[i++] << 16; count |= compiledPattern[i++]; } switch (tag) { case TAG_QUOTE_ASCII_CHAR: toAppendTo.append((char)count); break; case TAG_QUOTE_CHARS: toAppendTo.append(compiledPattern, i, count); i += count; break; default: subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols); break; } } return toAppendTo; }
也许是好运使然,没想到刚开始分析第一个方法就找到了线程不安全的问题所在。
从上述源码可以看出,在执行 SimpleDateFormat.format
方法时,会使用 calendar.setTime
方法将输入的时间进行转换,那么我们想象一下这样的场景:
- 线程 1 执行了
calendar.setTime(date)
方法,将用户输入的时间转换成了后面格式化时所需要的时间; - 线程 1 暂停执行,线程 2 得到
CPU
时间片开始执行; - 线程 2 执行了
calendar.setTime(date)
方法,对时间进行了修改; - 线程 2 暂停执行,线程 1 得出
CPU
时间片继续执行,因为线程 1 和线程 2 使用的是同一对象,而时间已经被线程 2 修改了,所以此时当线程 1 继续执行的时候就会出现线程安全的问题了。
正常的情况下,程序的执行是这样的:
非线程安全的执行流程是这样的:
在多线程执行的情况下,线程 1 的 date1
和线程 2 的 date2
,因为执行顺序的问题,最终都被格式化成 date2 formatted
,而非线程 1 date1 formatted
和线程 2 date2 formatted
,这样就会导致线程不安全的问题。
4.各方案优缺点总结
如果使用的是 JDK 8+
版本,可以直接使用线程安全的 DateTimeFormatter
来进行时间格式化,如果使用的 JDK 8
以下版本或者改造老的 SimpleDateFormat
代码,可以考虑使用 synchronized
或 ThreadLocal
来解决线程不安全的问题。因为实现方案 1 局部变量的解决方案,每次执行的时候都会创建新的对象,因此不推荐使用。synchronized
的实现比较简单,而使用 ThreadLocal
可以避免加锁排队执行的问题。
关注公号「Java 中文社群」查看更多有意思、涨知识的并发编程文章。
本文作者: InfoQ
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK