logo头像

不破不立

日期工具类SimpleDateFormat的正确使用

本文于 325 天之前发表,文中内容可能已经过时。

对于SimpleDateFormat类,想必大家肯定不陌生,我们常用它来处理日期的格式化或者日期字符串的解析。但是如果不正确使用好SimpleDateFormat是会出现问题的,本文就围绕用法和原理来分析下SimpleDateFormat类。

定义

我们看看Java8对于SimpleDateFormat的定义:

https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html

1
public class SimpleDateFormat extends DateFormat

SimpleDateFormat is a concrete class for formatting and parsing dates in a locale-sensitive manner. It allows for formatting (date → text), parsing (text → date), and normalization.

大致意思就是:该类是一个可以根据语言环境来格式化和解析日期的具体的类,它允许将日期格式化为自定义格式的文本、将字符串形式的日期解析为日期、以及规范化日期。

用法

日期和时间的模式表达

如官网描述,常用的时间元素和字母的对应表如下:1571465046429

1571465054582

date → text

将日期格式化为用户自定义格式的字符串:

1
2
3
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
Date date = new Date();
System.out.println(simpleDateFormat.format(date));

运行结果:

2018/12/11 20:50:25

text → date

将日期格式的字符串解析为日期:

1
2
3
4
5
6
7
8
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
String dateStr = "2018/12/12 00:47:40";
try {
Date result = simpleDateFormat.parse(dateStr);
System.out.println(result);
} catch (ParseException e) {
e.printStackTrace();
}

运行结果:

Wed Dec 12 00:47:40 CST 2018

关于时区

SimpleDateFormat 类存在一个设置时区的方法,即 setTimeZone,比如:

1
sdf.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));

对于时区的字符串名字,可以在ZoneId类中找到相关时区名字的字符串。

默认情况下,在创建日期的时候若不指明时区,会使用当前计算机所在的时区作为默认时区。

线程安全性

官方文档在最后写到了:

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的使用给出了明确的规定:

1571465071839

线程不安全示例

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
public class SimpleDateFormatTest {

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

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

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

public static void main(String[] args) throws InterruptedException {
//定义一个线程安全的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 = simpleDateFormat.format(calendar.getTime());
//把字符串放入Set中
dates.add(dateString);
//countDown
countDownLatch.countDown();
});
}
//阻塞,直到countDown数量为0
countDownLatch.await();
//输出去重后的时间个数
System.out.println(dates.size());
}
}

上述代码解释:循环一百次,每次循环都在当前时间的基础上加上一个天数(循环的第几次),然后把所有的日期放入一个线程安全的带有去重功能的set中,然后输出set中的元素个数。

例子出处: https://mp.weixin.qq.com/s/i2t0uYxbVeqRKGTc6qurag (作者:Hollis )

我们想象中的结果应该是100,但是实际执行过程中,会出现线程不安全的情况,这样就会导致实际结果是一个小于100的数字。

为什么呢?

线程不安全原因

我们看看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
// 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;
}

注意源码中的这一行:

1
calendar.setTime(date);

我们看看calendar来自哪里?它来自父类DateFormat,是一个成员变量:

1
2
3
4
5
6
7
8
9
10
11
/**
* The {@link Calendar} instance used for calculating the date-time fields
* and the instant of time. This field is used for both formatting and
* parsing.
*
* <p>Subclasses should initialize this field to a {@link Calendar}
* appropriate for the {@link Locale} associated with this
* <code>DateFormat</code>.
* @serial
*/
protected Calendar calendar;

由于我们在使用SimpleDateFormat的时候,使用了static定义,这样SimpleDateFormat就是一个多个线程共享的共享变量,那么其成员变量calendar也就可以被多个线程访问到了,到此大家应该就知道为什么SimpleDateFormat的format方法为什么不安全了吧。

同样,对于该来的parse方法,也存在同样的问题,我们可以在parse方法中调用的subParse方法中看到calendar变量的使用。

如何解决线程不安全问题

使用局部变量

1
2
3
4
// SimpleDateFormat声明成局部变量
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//通过simpleDateFormat把时间转换成字符串
String dateString = simpleDateFormat.format(new Date());

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

加同步锁

1
2
3
4
5
private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//...
synchronized (simpleDateFormat) {
String dateString = simpleDateFormat.format(new Date());
}

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

使用ThreadLocal

ThreadLocal 可以确保每个线程都可以得到单独的一个 SimpleDateFormat 的对象。

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 使用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(new Date());

使用其他类库的格式化类

  • Apache commons的FastDateFormat,宣称是既快又线程安全的SimpleDateFormat, 可惜它只能对日期进行format, 不能对日期串进行解析。

  • Joda-Time类库

对于上述方法,大家可以根据实际情况来选择。

从简单的角度来说,建议使用方法一或者方法二,如果在必要的时候,追求那么一点性能提升的话,可以考虑用方法三,用ThreadLocal做缓存。

据说Joda-Time类库对时间处理方式比较完美,建议使用(这个后续继续学习)。

支付宝打赏 微信打赏

赞赏是不耍流氓的鼓励