SimpleDateFormat 是 Java 提供的一个格式化和解析日期的工具类,在日常开发中经常会用到,但是由于它是线程不安全的,所以多线程共用一个 SimpleDateFormat 实例对日期进行解析或者格式化会导致程序出错。本节来揭示它为何是线程不安全的,以及如何避免该问题。
问题复现
为了复现问题,编写如下代码。
public class TestSimpleDateFormat {//(1)创建单例实例static SimpleDateFormat sdf = new SimpleDateFormat(「yyyy-MM-dd HH:mm:ss」);public static void main(String[] args) {//(2)创建多个线程,并启动for (int i = 0; i <10 ; ++i) {Thread thread = new Thread(new Runnable() {public void run() {try {//(3)使用单例日期实例解析文本System.out.println(sdf.parse(「2017-12-13 15:17:27」));} catch (ParseException e) {e.printStackTrace();}}});thread.start(); //(4)启动线程}}}
代码(1)创建了 SimpleDateFormat 的一个实例,代码(2)创建 10 个线程,每个线程都共用同一个 sdf 对象对文本日期进行解析。多运行几次代码就会抛出 java.lang. NumberFormatException 异常,增加线程的个数有利于复现该问题。
问题分析
为了便于分析,首先来看 SimpleDateFormat 的类图结构(见图 11-8)。

图 11-8
可以看到,每个 SimpleDateFormat 实例里面都有一个 Calendar 对象,后面我们就会知道,SimpleDateFormat 之所以是线程不安全的,就是因为 Calendar 是线程不安全的。后者之所以是线程不安全的,是因为其中存放日期数据的变量都是线程不安全的,比如 fields、time 等。
下面从代码层面来看下 parse 方法做了什么事情。
public Date parse(String text, ParsePosition pos){//(1)解析日期字符串,并将解析好的数据放入 CalendarBuilder 的实例 calb 中...Date parsedDate;try {//(2)使用 calb 中解析好的日期数据设置 calendarparsedDate = calb.establish(calendar).getTime();...}catch (IllegalArgumentException e) {...return null;}return parsedDate;}
代码(1)的主要作用是解析日期字符串并把解析好的数据放入 CalendarBuilder 的实例 calb 中。CalendarBuilder 是一个建造者模式,用来存放后面需要的数据。
代码(2)使用 calb 中解析好的日期数据设置 calendar,calb.establish 的代码如下。
Calendar establish(Calendar cal) {...//(3)重置日期对象 cal 的属性值cal.clear();//(4) 使用 calb 中的属性设置 cal...//(5)返回设置好的 cal 对象return cal;}
代码(3)重置 Calendar 对象里面的属性值,如下所示。
public final void clear(){for (int i = 0; i < fields.length; ) {stamp[i] = fields[i] = 0; // UNSET == 0isSet[i++] = false;}areAllFieldsSet = areFieldsSet = false;isTimeSet = false;}
代码(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 进行同步,具体如下。
public class TestSimpleDateFormat {// (1)创建单例实例static SimpleDateFormat sdf = new SimpleDateFormat(「yyyy-MM-dd HH:mm:ss」);public static void main(String[] args) {// (2)创建多个线程,并启动for (int i = 0; i < 10; ++i) {Thread thread = new Thread(new Runnable() {public void run() {try {// (3)使用单例日期实例解析文本synchronized (sdf) {System.out.println(sdf.parse(「2017-12-13 15:17:27」));}} catch (ParseException e) {e.printStackTrace();}}});thread.start(); // (4)启动线程}}}
进行同步意味着多个线程要竞争锁,在高并发场景下这会导致系统响应性能下降。
● 第三种方式:使用 ThreadLocal,这样每个线程只需要使用一个 SimpleDateFormat 实例,这相比第一种方式大大节省了对象的创建销毁开销,并且不需要使多个线程同步。使用 ThreadLocal 方式的代码如下。
public class TestSimpleDateFormat2 {// (1)创建 threadlocal 实例static ThreadLocal<DateFormat> safeSdf = new ThreadLocal<DateFormat>(){@Overrideprotected SimpleDateFormat initialValue(){return new SimpleDateFormat(「yyyy-MM-dd HH:mm:ss」);}};public static void main(String[] args) {// (2)创建多个线程,并启动for (int i = 0; i < 10; ++i) {Thread thread = new Thread(new Runnable() {public void run() {try {// (3)使用单例日期实例解析文本System.out.println(safeSdf.get().parse(「2017-12-1315:17:27」));} catch (ParseException e) {e.printStackTrace();}finally {//(4)使用完毕记得清除,避免内存泄漏safeSdf.remove();}}});thread.start(); // (5)启动线程}}}
代码(1)创建了一个线程安全的 SimpleDateFormat 实例,代码(3)首先使用 get()方法获取当前线程下 SimpleDateFormat 的实例。在第一次调用 ThreadLocal 的 get()方法时,会触发其 initialValue 方法创建当前线程所需要的 SimpleDateFormat 对象。另外需要注意的是,在代码(4)中,使用完线程变量后,要进行清理,以避免内存泄漏。
小结
本节通过简单介绍 SimpleDateFormat 的原理解释了为何 SimpleDateFormat 是线程不安全的,应该避免在多线程下使用 SimpleDateFormat 的单个实例。
