java-SimpleDateFormat的用法以及线程安全

开发中我们经常会用到时间相关类,我们有很多办法在Java代码中获取时间。但是不同的方法获取到的时间的格式都不尽相同,这时候就需要一种格式化工具,把时间显示成我们需要的格式。最常用的方法就是使用SimpleDateFormat类。这是一个看上去功能比较简单的类,但是,一旦使用不当也有可能导致很大的问题。

在阿里巴巴Java开发手册中,有如下明确规定:
image
本文就围绕SimpleDateFormat的用法、原理等来深入分析下如何以正确使用它。

1. SimpleDateFormat用法

1.1 基本用法

SimpleDateFormat是java提供的能对时间格式化及解析的工具类。

  • 格式化:将规范日期格式化成日期文本(时间字符串)

    1
    2
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    String dateStr = sdf.format(new Date());
  • 解析: 将文本日期解析成规范化的时间格式

1
Date d = sdf.parse(dataStr);

用户可以自定义文本日期的格式,通过字母来描述时间元素,并组装成想要的日期和时间格式。常用的时间元素和字母的对应表如下:
模板定义
模式字母通常是重复的,其数量确定其精确表示。如下表是常用的输出格式的表示方法。
image

1.2 时区

如何在Java代码中获取不同时区的时间呢?SimpleDateFormat可以实现这个功能。

1
2
3
4
5
6
public static void main(String[] args){
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println(sdf.format(Calendar.getInstance().getTime()));
sdf.setTimeZone(TimeZone.getTimeZone("America/Los_Angeles"));
System.out.println(sdf.format(Calendar.getInstance().getTime()));
}

以上代码,输出的结果

1
2
2019-04-24 09:26:38
2019-04-23 18:26:38

中国的时间第一行,而美国洛杉矶时间比中国北京时间慢了17个小时(这还和冬夏令时有关系)。
当然,这不是显示其他时区的唯一方法

2. SimpleDateFormat线程安全性

由于SimpleDateFormat比较常用,而且在一般情况下,一个应用中的时间显示模式都是一样的,所以很多人愿意使用如下方式定义SimpleDateFormat:

1
2
3
4
5
6
7
public class Main {
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
sdf.setTimeZone(TimeZone.getTimeZone("America/New_York"));
...
}
}

这种定义方式,存在很大的线程安全隐患。

2.1 问题重现

以下代码使用线程池来执行时间输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class Main {

/**
* 定义一个全局的SimpleDateFormat
*/
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

/**
* 使用ThreadFactoryBuilder定义一个线程池
*/
private static ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("demo-pool-%d").build();

private static ExecutorService pool = new ThreadPoolExecutor(5, 200,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());

/**
* 定义一个CountDownLatch,保证所有子线程执行完之后主线程再执行
*/
private static CountDownLatch countDownLatch = new CountDownLatch(100);

public static void main(String[] args) {
//定义一个线程安全的HashSet
Set<String> dates = Collections.synchronizedSet(new HashSet<String>());
for (int i = 0; i < 100; i++) {
//获取当前时间
Calendar calendar = Calendar.getInstance();
int finalI = i;
pool.execute(() -> {
//时间增加
calendar.add(Calendar.DATE, finalI);
//通过simpleDateFormat把时间转换成字符串
String dateString = sdf.format(calendar.getTime());
//把字符串放入Set中
dates.add(dateString);
//countDown
countDownLatch.countDown();
});
}
//阻塞,直到countDown数量为0
countDownLatch.await();
//输出去重后的时间个数
System.out.println(dates.size());
}
}

以上代码,其实比较容易理解。就是循环一百次,每次循环的时候都在当前时间基础上增加一个天数(这个天数随着循环次数而变化),然后把所有日期放入一个线程安全的、带有去重功能的Set中,然后输出Set中元素个数。

正常情况下,以上代码输出结果应该是100。但是实际执行结果是一个小于100的数字。

原因就是因为SimpleDateFormat作为一个非线程安全的类,被当做了共享变量在多个线程中进行使用,这就出现了线程安全问题。

2.2 线程不安全原因

其实,JDK文档中已经明确表明了SimpleDateFormat不应该用在多线程场景中:

Date formats are not synchronized.
It is recommended to create separate format instances for each thread.
If multiple threads access a format concurrently, it must be synchronized externally.

那么为什么会出现这种问题,SimpleDateFormat底层到底是怎么实现的?
跟踪一下SimpleDateFormat类中format方法的实现其实就能发现端倪。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Override
public StringBuffer format(Date date, StringBuffer toAppendTo,
FieldPosition pos)
{
pos.beginIndex = pos.endIndex = 0;
return format(date, toAppendTo, pos.getFieldDelegate());
}

// Called from Format after creating a FieldDelegate
private StringBuffer format(Date date, StringBuffer toAppendTo,
FieldDelegate delegate) {
// Convert input date to time field list
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来保存时间。这其实就是问题的关键。

由于我们在声明SimpleDateFormat的时候,使用的是static定义的。那么这个SimpleDateFormat就是一个共享变量,随之,SimpleDateFormat中的calendar也就可以被多个线程访问到。

假设线程1刚刚执行完calendar.setTime把时间设置成2018-11-11,还没等执行完,线程2又执行了calendar.setTime把时间改成了2018-12-12。这时候线程1继续往下执行,拿到的calendar.getTime得到的时间就是线程2改过之后的。

除了format方法以外,SimpleDateFormat的parse方法也有同样的问题。

3. 如何解决

解决方法有很多,先介绍三个比较常用的方法。

3.1 使用局部变量

SimpleDateFormat变成了局部变量,就不会被多个线程同时访问到了,就避免了线程安全问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (int i = 0; i < 100; i++) {
//获取当前时间
Calendar calendar = Calendar.getInstance();
int finalI = i;
pool.execute(() -> {
// SimpleDateFormat声明成局部变量
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//时间增加
calendar.add(Calendar.DATE, finalI);
//通过simpleDateFormat把时间转换成字符串
String dateString = simpleDateFormat.format(calendar.getTime());
//把字符串放入Set中
dates.add(dateString);
//countDown
countDownLatch.countDown();
});
}

3.2 加同步锁

除了改成局部变量以外,还有一种方法大家可能比较熟悉的,就是对于共享变量进行加锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
for (int i = 0; i < 100; i++) {
//获取当前时间
Calendar calendar = Calendar.getInstance();
int finalI = i;
pool.execute(() -> {
//时间增加
calendar.add(Calendar.DATE, finalI);
//通过simpleDateFormat把时间转换成字符串
//加锁
synchronized (simpleDateFormat) {
String dateString = simpleDateFormat.format(calendar.getTime());
}
//把字符串放入Set中
dates.add(dateString);
//countDown
countDownLatch.countDown();

});
}

通过加锁,使多个线程排队顺序执行。避免了并发导致的线程安全问题。

3.3 使用ThreadLocal

第三种方式,就是使用 ThreadLocal。 ThreadLocal 可以确保每个线程都可以得到单独的一个 SimpleDateFormat 的对象,那么自然也就不存在竞争问题了。

1
2
3
4
5
6
7
8
9
10
11
/**
* 使用ThreadLocal定义一个全局的SimpleDateFormat
*/
private static ThreadLocal<SimpleDateFormat> simpleDateFormatThreadLocal = new ThreadLocal<SimpleDateFormat>() {
@Override
protected SimpleDateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
}
};
//用法
String dateString = simpleDateFormatThreadLocal.get().format(calendar.getTime());

当然,以上代码也有改进空间,就是,其实SimpleDateFormat的创建过程可以改为延迟加载。这里就不详细介绍了。

4. 使用DateTimeFormatter

如果是Java8应用,可以使用DateTimeFormatter代替SimpleDateFormat,这是一个线程安全的格式化工具类。

1
2
3
4
5
6
7
8
9
10
//解析日期
String dateStr= "2016年10月25日";
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日");
LocalDate date= LocalDate.parse(dateStr, formatter);

//日期转换为字符串
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy年MM月dd日 hh:mm a");
String nowStr = now .format(format);
System.out.println(nowStr);

5. 总结

本文介绍了SimpleDateFormat的用法,SimpleDateFormat主要可以在String和Date之间做转换,还可以将时间转换成不同时区输出。同时提到在并发场景中SimpleDateFormat是不能保证线程安全的,需要开发者自己来保证其安全性。

主要的几个手段有改为局部变量、使用synchronized加锁、使用Threadlocal为每一个线程单独创建一个和使用Java8中的DateTimeFormatter类代替等。