开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 5 天,点击查看活动详情
1. 前言
SimpleDateFormat 类是 Java 中提供的日期时间的转化类,能够很方便地帮助我们处理时间格式化的问题。但是,在高并发的环境下,它会存在安全问题。在阿里巴巴 Java 开发手册中有强调:
SimpleDateFormat 不允许定义为 static 变量,若定义为 static,则必须加锁,或者使用DateUtils工具类。
2. 复现 SimpleDateFormat 线程安全问题
一般并发度不高的系统,遇到 SimpleDateFormat 出现线程安全问题的几率比较小,但是当并发量达到一定的量级时,线程安全问题就会出现。现在,我们使用线程池并结合 Java 并发包中的 CountDownLatch 类和 Semaphore 类来复现线程安全问题。
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
/**
* @author zhongmingyi
* @date 2023/2/7 1:30 下午
*/
public class SimpleDateFormatLearning {
private static final int EXECUTE_COUNT = 1000; //执行总次数
private static final int THREAD_COUNT = 20; //同时运行的线程数量
private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");//SimpleDateFormat对象
public static void main(String[] args) throws InterruptedException {
final Semaphore semaphore = new Semaphore(THREAD_COUNT);
final CountDownLatch countDownLatch = new CountDownLatch(EXECUTE_COUNT);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < EXECUTE_COUNT; i++) {
executorService.execute(() -> {
try {
semaphore.acquire();
try {
simpleDateFormat.parse("2023-02-07");
} catch (ParseException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
} catch (NumberFormatException e) {
System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
e.printStackTrace();
System.exit(1);
}
semaphore.release();
} catch (InterruptedException e) {
System.out.println("信号量发生错误");
e.printStackTrace();
System.exit(1);
}
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
System.out.println("所有线程格式化日期成功");
}
}
执行结果如下:
由输出结果我们可以知道,simpleDateFormat 在高并发的情况下,其解析出现了异常,由此可以得出结论:SimpleDateFormat 类不是线程安全的。
3. 源码层面分析 SimpleDateFormat 类线程不安全
我们直接来看看 SimpleDateFormat 类的 parse 方法,其中关键的代码段如下:
通过调用 CalendarBuilder 的 establish 方法获得 Date 对象,我们来看看 establish 方法实现的关键:
它是先调用 clear() 方法,清除 Calendar 对象中设置的值,然后再调用 set 方法给对象设置值;但是 Calendar 对象并不是线程安全的(没有任何同步机制来保护),同时 clear 和 set 这两个操作并不是原子的,当多个线程同时操作一个 SimpleDateFormat 时就会引起混乱。
因此,可以输出结论:DateFormat 类中的 Calendar 对象被多线程共享,而 Calendar 对象本身不支持线程安全。
4. 解决 SimpleDateFormat 线程安全问题
(1)最简单:将 static 变量改为局部变量
将上述的代码中 SimpleDateFormat 这个静态变量:
private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
改造成每个线程里维护的局部变量:
executorService.execute(() -> {
//... 省略代码
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");//SimpleDateFormat对象;
simpleDateFormat.parse("2023-02-07");
//... 省略代码
});
优点: 本方式的优点是实现简单
缺点: 缺点也很明显,每个线程都要创建一个对象,会占用和消耗过多的机器资源,不适用于高并发的场景
(2)加锁
由于代码中是将 SimpleDateFormat 类对象定义成全局静态变量,此时所有线程共享 SimpleDateFormat 类对象,此时在调用格式化时间的方法时,因此对SimpleDateFormat对象加锁同步即可。
同步的方式有多种,可以使用 synchronized 关键字;也可以使用 JUC 下的 ReentrantLock 的方式。
- 使用 synchronized 关键字:
- 使用 ReentrantLock 锁:
优点: 本方式的优点是无需创建局部变量,可以节省一定的资源开销
缺点: 同一时刻只能有一个线程抢到锁,对于高并发的场景会使系统的整体性能下降
(3)ThreadLocal
由于 ThreadLocal 具备每个线程都有一份对象的副本的特点,能够有效的避免多线程造成的线程安全问题。
优点: 此种方式运行效率比较高,推荐在高并发业务场景的生产环境使用
缺点: ThreadLocal 如果使用不当,会引发内存泄露
(4)将 SimpleDateFormat 替换成 DateTimeFormatter
DateTimeFormatter 是 Java8 提供的新的日期时间 API 中的类,DateTimeFormatter类是线程安全的,可以在高并发场景下直接使用 DateTimeFormatter 类来处理日期的格式化操作。代码比较简单就不贴出来了,感兴趣的同学可以自己学习下 DateTimeFormatter 的各类方法。