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 中解析好的日期数据设置 calendar
parsedDate = 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 == 0
isSet[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>(){
@Override
protected 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-13
15: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 的单个实例。