SimpleDateFormat 如何安全的使用?

001

前言

为什么会写这篇文章?因为这些天在看《阿里巴巴开发手册详尽版》,没看过的可以关注微信公众号:zhisheng,回复关键字:阿里巴巴开发手册详尽版 就可以获得。

关注我

mark

转载请务必注明原创地址为:http://www.54tianzhisheng.cn/2018/06/19/SimpleDateFormat/

在看的过程中有这么一条:

【强制】SimpleDateFormat 是线程不安全的类,一般不要定义为 static 变量,如果定义为 static,必须加锁,或者使用 DateUtils 工具类。

看到这条我立马就想起了我实习的时候有个项目里面就犯了这个错误,记得当时是这样写的:

1
private static final SimpleDateFormat df = new SimpleDateFormat("yyyyMMddHHmmss");

所以才认真的去研究下这个 SimpleDateFormat,所以才有了这篇文章。

它是谁?

想必大家对 SimpleDateFormat 并不陌生。SimpleDateFormat 是 Java 中一个非常常用的类,他是以区域敏感的方式格式化和解析日期的具体类。 它允许格式化 (date -> text)、语法分析 (text -> date)和标准化。

SimpleDateFormat 允许以任何用户指定的日期-时间格式方式启动。 但是,建议使用 DateFormat 中的 getTimeInstancegetDateInstancegetDateTimeInstance 方法来创建一个日期-时间格式。 这几个方法会返回一个默认的日期/时间格式。 你可以根据需要用 applyPattern 方法修改格式方式。

日期时间格式

日期和时间格式由 日期和时间模式字符串 指定。在 日期和时间模式字符串 中,未加引号的字母 ‘A’ 到 ‘Z’ 和 ‘a’ 到 ‘z’ 被解释为模式字母,用来表示日期或时间字符串元素。文本可以使用单引号 (‘) 引起来,以免进行解释。所有其他字符均不解释,只是在格式化时将它们简单复制到输出字符串。

简单的讲:这些 A ——Z,a —— z 这些字母(不被单引号包围的)会被特殊处理替换为对应的日期时间,其他的字符串还是原样输出。

日期和时间模式(注意大小写,代表的含义是不同的)如下:

2018-06-19_11-17-43

怎么使用?

日期/时间格式模版样例:(给的时间是:2001-07-04 12:08:56 U.S. Pacific Time time zone)

2018-06-19_11-27-39

使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* Created by zhisheng_tian on 2018/6/19
*/
public class FormatDateTime {
public static void main(String[] args) {
SimpleDateFormat myFmt = new SimpleDateFormat("yyyy年MM月dd日 HH时mm分ss秒");
SimpleDateFormat myFmt1 = new SimpleDateFormat("yy/MM/dd HH:mm");
SimpleDateFormat myFmt2 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//等价于now.toLocaleString()
SimpleDateFormat myFmt3 = new SimpleDateFormat("yyyy年MM月dd日 HH时mm分ss秒 E ");
SimpleDateFormat myFmt4 = new SimpleDateFormat("一年中的第 D 天 一年中第w个星期 一月中第W个星期 在一天中k时 z时区");
Date now = new Date();
System.out.println(myFmt.format(now));
System.out.println(myFmt1.format(now));
System.out.println(myFmt2.format(now));
System.out.println(myFmt3.format(now));
System.out.println(myFmt4.format(now));
System.out.println(now.toGMTString());
System.out.println(now.toLocaleString());
System.out.println(now.toString());
}
}

结果是:

1
2
3
4
5
6
7
8
2018年06月19日 23时10分05秒
18/06/19 23:10
2018-06-19 23:10:05
2018年06月19日 23时10分05秒 星期二
一年中的第 170 天 一年中第25个星期 一月中第4个星期 在一天中23时 CST时区
19 Jun 2018 15:10:05 GMT
2018-6-19 23:10:05
Tue Jun 19 23:10:05 CST 2018

使用方法很简单,就是先自己定义好时间/日期模版,然后调用 format 方法(传入一个时间 Date 参数)。

上面的是日期转换成自己想要的字符串格式。下面反过来,将字符串类型装换成日期类型:

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
47
48
49
50
51
52
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* Created by zhisheng_tian on 2018/6/19
*/
public class StringFormatDate {

public static void main(String[] args) {
String time1 = "2018年06月19日 23时10分05秒";
String time2 = "18/06/19 23:10";
String time3 = "2018-06-19 23:10:05";
String time4 = "2018年06月19日 23时10分05秒 星期二";

SimpleDateFormat myFmt = new SimpleDateFormat("yyyy年MM月dd日 HH时mm分ss秒");
SimpleDateFormat myFmt1 = new SimpleDateFormat("yy/MM/dd HH:mm");
SimpleDateFormat myFmt2 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//等价于now.toLocaleString()
SimpleDateFormat myFmt3 = new SimpleDateFormat("yyyy年MM月dd日 HH时mm分ss秒 E");

Date date1 = null;
try {
date1 = myFmt.parse(time1);
} catch (ParseException e) {
e.printStackTrace();
}
System.out.println(date1);

Date date2 = null;
try {
date2 = myFmt1.parse(time2);
} catch (ParseException e) {
e.printStackTrace();
}
System.out.println(date2);

Date date3 = null;
try {
date3 = myFmt2.parse(time3);
} catch (ParseException e) {
e.printStackTrace();
}
System.out.println(date3);

Date date4 = null;
try {
date4 = myFmt3.parse(time4);
} catch (ParseException e) {
e.printStackTrace();
}
System.out.println(date4);
}
}

结果是:

1
2
3
4
Tue Jun 19 23:10:05 CST 2018
Tue Jun 19 23:10:00 CST 2018
Tue Jun 19 23:10:05 CST 2018
Tue Jun 19 23:10:05 CST 2018

这个转换方法也很简单。但是不要高兴的太早,主角不在这。

线程不安全

2018-06-19_23-56-29

在 SimpleDateFormat 类的 JavaDoc 中,描述了该类不能够保证线程安全,建议为每个线程创建单独的日期/时间格式实例,如果多个线程同时访问一个日期/时间格式,它必须在外部进行同步。那么在多线程环境下调用 format() 和 parse() 方法应该使用同步代码来避免问题。下面我们通过一个具体的场景来一步步的深入学习和理解SimpleDateFormat 类。

1、每个线程创建单独的日期/时间格式实例

大量的创建 SimpleDateFormat 实例对象,然后再丢弃这个对象,占用大量的内存和 JVM 空间。

2、创建一个静态的 SimpleDateFormat 实例,在使用时直接使用这个实例进行操作(我当时就是这么干的😄)

1
2
3
private static final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date date = new Date();
df.format(date);

当然,这个方法的确很不错,在大部分的时间里面都会工作得很好,但一旦在生产环境中一定负载情况下时,这个问题就出来了。他会出现各种不同的情况,比如转化的时间不正确,比如报错,比如线程被挂死等等。我们看下面的测试用例,拿事实说话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* Created by zhisheng_tian on 2018/6/20
*/
public class DateUtils {
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static String formatDate(Date date) throws ParseException {
return sdf.format(date);
}

public static Date parse(String strDate) throws ParseException {
return sdf.parse(strDate);
}
}
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
import java.text.ParseException;
/**
* Created by zhisheng_tian on 2018/6/20
*/
public class DateUtilsTest {
public static class TestSimpleDateFormatThreadSafe extends Thread {
@Override
public void run() {
while (true) {
try {
this.join(2000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
try {
System.out.println(this.getName() + ":" + DateUtils.parse("2018-06-20 01:18:20"));
} catch (ParseException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new TestSimpleDateFormatThreadSafe().start();
}
}
}

运行结果如下:

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
Exception in thread "Thread-0" Exception in thread "Thread-1" java.lang.NumberFormatException: For input string: ""
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:601)
at java.lang.Long.parseLong(Long.java:631)
at java.text.DigitList.getLong(DigitList.java:195)
at java.text.DecimalFormat.parse(DecimalFormat.java:2051)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at com.zhisheng.demo.date.DateUtils.parse(DateUtils.java:19)
at com.zhisheng.demo.date.DateUtilsTest$TestSimpleDateFormatThreadSafe.run(DateUtilsTest.java:19)
java.lang.NumberFormatException: For input string: ".1818"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:578)
at java.lang.Long.parseLong(Long.java:631)
at java.text.DigitList.getLong(DigitList.java:195)
at java.text.DecimalFormat.parse(DecimalFormat.java:2051)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at com.zhisheng.demo.date.DateUtils.parse(DateUtils.java:19)
at com.zhisheng.demo.date.DateUtilsTest$TestSimpleDateFormatThreadSafe.run(DateUtilsTest.java:19)
Thread-2:Sat Jun 20 01:18:20 CST 2201
Thread-2:Wed Jun 20 01:18:20 CST 2018
Thread-2:Wed Jun 20 01:18:20 CST 2018
Thread-2:Wed Jun 20 01:18:20 CST 2018

说明:Thread-1和Thread-0报java.lang.NumberFormatException: multiple points错误,直接挂死,没起来;Thread-2 虽然没有挂死,但输出的时间是有错误的,比如我们输入的时间是:2018-06-20 01:18:20 ,当会输出:Sat Jun 20 01:18:20 CST 2201 这样的灵异事件。

Why?

为什么会出现线程不安全的问题呢?

下面我们通过看 JDK 源码来看看为什么 SimpleDateFormat 和 DateFormat 类不是线程安全的真正原因:

SimpleDateFormat 继承了 DateFormat,在 DateFormat 中定义了一个 protected 属性的 Calendar 类的对象:calendar。只是因为 Calendar 类的概念复杂,牵扯到时区与本地化等等,JDK 的实现中使用了成员变量来传递参数,这就造成在多线程的时候会出现错误。

在 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
@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;
}

calendar.setTime(date) 这条语句改变了 calendar,稍后,calendar 还会用到(在 subFormat 方法里),而这就是引发问题的根源。想象一下,在一个多线程环境下,有两个线程持有了同一个 SimpleDateFormat 的实例,分别调用format 方法:

1
2
3
4
5
线程 1 调用 format 方法,改变了 calendar 这个字段。
线程 1 中断了。
线程 2 开始执行,它也改变了 calendar。
线程 2 中断了。
线程 1 回来了

此时,calendar 已然不是它所设的值,而是走上了线程 2 设计的道路。如果多个线程同时争抢 calendar 对象,则会出现各种问题,时间不对,线程挂死等等。

分析一下 format 的实现,我们不难发现,用到成员变量 calendar,唯一的好处,就是在调用 subFormat 时,少了一个参数,却带来了许多的问题。其实,只要在这里用一个局部变量,一路传递下去,所有问题都将迎刃而解。

这个问题背后隐藏着一个更为重要的问题–无状态:无状态方法的好处之一,就是它在各种环境下,都可以安全的调用。衡量一个方法是否是有状态的,就看它是否改动了其它的东西,比如全局变量,比如实例的字段。format 方法在运行过程中改动了 SimpleDateFormat 的 calendar 字段,所以,它是有状态的。

这也同时提醒我们在开发和设计系统的时候注意下一下三点:

1.自己写公用类的时候,要对多线程调用情况下的后果在注释里进行明确说明

2.多线程环境下,对每一个共享的可变变量都要注意其线程安全性

3.我们的类和方法在做设计的时候,要尽量设计成无状态的

解决方法

1、需要的时候创建新实例

说明:在需要用到 SimpleDateFormat 的地方新建一个实例,不管什么时候,将有线程安全问题的对象由共享变为局部私有都能避免多线程问题,不过也加重了创建对象的负担。在一般情况下,这样其实对性能影响比不是很明显的。

2、使用同步:同步 SimpleDateFormat 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

public class DateSyncUtil {

private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static String formatDate(Date date) throws ParseException {
synchronized(sdf) {
return sdf.format(date);
}
}

public static Date parse(String strDate) throws ParseException {
synchronized(sdf) {
return sdf.parse(strDate);
}
}
}

说明:当线程较多时,当一个线程调用该方法时,其他想要调用此方法的线程就要 block 等待,多线程并发量大的时候会对性能有一定的影响。

3、使用 ThreadLocal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

public class ConcurrentDateUtil {

private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>() {
@Override
protected DateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
}
};

public static Date parse(String dateStr) throws ParseException {
return threadLocal.get().parse(dateStr);
}

public static String format(Date date) {
return threadLocal.get().format(date);
}
}

说明:使用 ThreadLocal, 也是将共享变量变为独享,线程独享肯定能比方法独享在并发环境中能减少不少创建对象的开销。如果对性能要求比较高的情况下,一般推荐使用这种方法。

Java 8 中的解决办法

Java 8 提供了新的日期时间 API,其中包括用于日期时间格式化的 DateTimeFormatter,它与 SimpleDateFormat 最大的区别在于:DateTimeFormatter 是线程安全的,而 SimpleDateFormat 并不是线程安全。

DateTimeFormatter 如何使用:

解析日期

1
2
3
String dateStr= "2018年06月20日";
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日");
LocalDate date= LocalDate.parse(dateStr, formatter);

日期转换为字符串

1
2
3
LocalDateTime now = LocalDateTime.now();  
DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy年MM月dd日 hh:mm a");
String nowStr = now .format(format);

由 DateTimeFormatter 的静态方法 ofPattern() 构建日期格式,LocalDateTime 和 LocalDate 等一些表示日期或时间的类使用 parse 和 format 方法把日期和字符串做转换。

使用新的 API,整个转换过程都不需要考虑线程安全的问题。

总结

SimpleDateFormat 是线程不安全的类,多线程环境下注意线程安全问题,如果是 Java 8 ,建议使用 DateTimeFormatter 代替 SimpleDateFormat。

参考资料

http://www.cnblogs.com/peida/archive/2013/05/31/3070790.html

相关文章

20 个案例教你在 Java 8 中如何处理日期和时间?

×

纯属好玩

扫码支持
扫码打赏,你说多少就多少

打开支付宝扫一扫,即可进行扫码打赏哦

文章目录
  1. 1. 前言
  2. 2. 关注我
  3. 3. 它是谁?
  4. 4. 日期时间格式
  5. 5. 怎么使用?
  6. 6. 线程不安全
  7. 7. Why?
  8. 8. 解决方法
  9. 9. Java 8 中的解决办法
  10. 10. 总结
  11. 11. 参考资料
  12. 12. 相关文章