SimpleDateFormat 是 Java 提供的一个格式化和解析日期的工具类,在日常开发中经常会用到,但是由于它是线程不安全的,所以多线程共用一个 SimpleDateFormat 实例对日期进行解析或者格式化会导致程序出错。本节来揭示它为何是线程不安全的,以及如何避免该问题。

问题复现

为了复现问题,编写如下代码。

  1. public class TestSimpleDateFormat {
  2. //(1)创建单例实例
  3. static SimpleDateFormat sdf = new SimpleDateFormat(「yyyy-MM-dd HH:mm:ss」);
  4. public static void mainString[] args {
  5. //(2)创建多个线程,并启动
  6. for int i = 0; i <10 ; ++i {
  7. Thread thread = new Thread(new Runnable() {
  8. public void run() {
  9. try {//(3)使用单例日期实例解析文本
  10. System.out.println(sdf.parse(「2017-12-13 15:17:27」));
  11. } catch ParseException e {
  12. e.printStackTrace();
  13. }
  14. }
  15. });
  16. thread.start(); //(4)启动线程
  17. }
  18. }
  19. }

代码(1)创建了 SimpleDateFormat 的一个实例,代码(2)创建 10 个线程,每个线程都共用同一个 sdf 对象对文本日期进行解析。多运行几次代码就会抛出 java.lang. NumberFormatException 异常,增加线程的个数有利于复现该问题。

问题分析

为了便于分析,首先来看 SimpleDateFormat 的类图结构(见图 11-8)。

SimpleDateFormat 是线程不安全的 - 图1

图 11-8

可以看到,每个 SimpleDateFormat 实例里面都有一个 Calendar 对象,后面我们就会知道,SimpleDateFormat 之所以是线程不安全的,就是因为 Calendar 是线程不安全的。后者之所以是线程不安全的,是因为其中存放日期数据的变量都是线程不安全的,比如 fields、time 等。

下面从代码层面来看下 parse 方法做了什么事情。

  1. public Date parseString text, ParsePosition pos
  2. {
  3. //(1)解析日期字符串,并将解析好的数据放入 CalendarBuilder 的实例 calb 中
  4. ...
  5. Date parsedDate
  6. try {//(2)使用 calb 中解析好的日期数据设置 calendar
  7. parsedDate = calb.establishcalendar).getTime();
  8. ...
  9. }
  10. catch IllegalArgumentException e {
  11. ...
  12. return null
  13. }
  14. return parsedDate
  15. }

代码(1)的主要作用是解析日期字符串并把解析好的数据放入 CalendarBuilder 的实例 calb 中。CalendarBuilder 是一个建造者模式,用来存放后面需要的数据。

代码(2)使用 calb 中解析好的日期数据设置 calendar,calb.establish 的代码如下。

  1. Calendar establishCalendar cal {
  2. ...
  3. //(3)重置日期对象 cal 的属性值
  4. cal.clear();
  5. //(4) 使用 calb 中的属性设置 cal
  6. ...
  7. //(5)返回设置好的 cal 对象
  8. return cal
  9. }

代码(3)重置 Calendar 对象里面的属性值,如下所示。

  1. public final void clear()
  2. {
  3. for (int i = 0; i < fields.length; ) {
  4. stamp[i] = fields[i] = 0; // UNSET == 0
  5. isSet[i++] = false;
  6. }
  7. areAllFieldsSet = areFieldsSet = false;
  8. isTimeSet = false;
  9. }

代码(4)使用 calb 中解析好的日期数据设置 cal 对象。

代码(5)返回设置好的 cal 对象。

从以上代码可以看出,代码(3)、代码(4)和代码(5)并不是原子性操作。当多个线程调用 parse 方法时,比如线程 A 执行了代码(3)和代码(4),也就是设置好了 cal 对象,但是在执行代码(5)之前,线程 B 执行了代码(3),清空了 cal 对象。由于多个线程使用的是一个 cal 对象,所以线程 A 执行代码(5)返回的可能就是被线程 B 清空的对象,当然也有可能线程 B 执行了代码(4),设置被线程 A 修改的 cal 对象,从而导致程序出现错误。

问题解决

● 第一种方式:每次使用时 new 一个 SimpleDateFormat 的实例,这样可以保证每个实例使用自己的 Calendar 实例,但是每次使用都需要 new 一个对象,并且使用后由于没有其他引用,又需要回收,开销会很大。

● 第二种方式:出错的根本原因是因为多线程下代码(3)、代码(4)和代码(5)三个步骤不是一个原子性操作,那么容易想到的是对它们进行同步,让代码(3)、代码(4)和代码(5)成为原子性操作。可以使用 synchronized 进行同步,具体如下。

  1. public class TestSimpleDateFormat {
  2. // (1)创建单例实例
  3. static SimpleDateFormat sdf = new SimpleDateFormat(「yyyy-MM-dd HH:mm:ss」);
  4. public static void mainString[] args {
  5. // (2)创建多个线程,并启动
  6. for int i = 0 i < 10 ++i {
  7. Thread thread = new Thread(new Runnable() {
  8. public void run() {
  9. try {// (3)使用单例日期实例解析文本
  10. synchronized sdf {
  11. System.out.println(sdf.parse(「2017-12-13 15:17:27」));
  12. }
  13. } catch ParseException e {
  14. e.printStackTrace();
  15. }
  16. }
  17. });
  18. thread.start(); // (4)启动线程
  19. }
  20. }
  21. }

进行同步意味着多个线程要竞争锁,在高并发场景下这会导致系统响应性能下降。

● 第三种方式:使用 ThreadLocal,这样每个线程只需要使用一个 SimpleDateFormat 实例,这相比第一种方式大大节省了对象的创建销毁开销,并且不需要使多个线程同步。使用 ThreadLocal 方式的代码如下。

  1. public class TestSimpleDateFormat2 {
  2. // (1)创建 threadlocal 实例
  3. static ThreadLocal<DateFormat> safeSdf = new ThreadLocal<DateFormat>(){
  4. @Override
  5. protected SimpleDateFormat initialValue(){
  6. return new SimpleDateFormat(「yyyy-MM-dd HH:mm:ss」);
  7. }
  8. };
  9. public static void mainString[] args {
  10. // (2)创建多个线程,并启动
  11. for int i = 0 i < 10 ++i {
  12. Thread thread = new Thread(new Runnable() {
  13. public void run() {
  14. try {// (3)使用单例日期实例解析文本
  15. System.out.println(safeSdf.get().parse(「2017-12-13
  16. 15:17:27」));
  17. } catch ParseException e {
  18. e.printStackTrace();
  19. }finally {
  20. //(4)使用完毕记得清除,避免内存泄漏
  21. safeSdf.remove();
  22. }
  23. }
  24. });
  25. thread.start(); // (5)启动线程
  26. }
  27. }
  28. }

代码(1)创建了一个线程安全的 SimpleDateFormat 实例,代码(3)首先使用 get()方法获取当前线程下 SimpleDateFormat 的实例。在第一次调用 ThreadLocal 的 get()方法时,会触发其 initialValue 方法创建当前线程所需要的 SimpleDateFormat 对象。另外需要注意的是,在代码(4)中,使用完线程变量后,要进行清理,以避免内存泄漏

小结

本节通过简单介绍 SimpleDateFormat 的原理解释了为何 SimpleDateFormat 是线程不安全的,应该避免在多线程下使用 SimpleDateFormat 的单个实例。