java.text.SimpleDateFormat和java.util包下的Date、Calendar、TimeZone这些类现在很多地方已经不再推荐使用了(sonar异味),JDK8可以使用java.time包下的Instant、LocalDate、LocalTime、LocalDateTime、DateTimeFormatter等替代
很多地方都会提到SimpleDateFormat线程不安全,这确实是一个非常严重的问题,但是这几个类的问题或者说设计缺陷不止于此:
Date的名字有误导性,它表达的语义其实是一个时刻而不是一个日期;
很多地方隐式地将时区变换为系统默认时区,比如SimpleDateFormat继承的DateFormat的parse()方法;
月份从0开始(0表示1月),而日期从1开始(1表示1号),很容易混淆。
这次我就遇到了隐式变换时区的问题
先看一段代码
SimpleDateFormat df1 = new SimpleDateFormat("yyyy-MM-dd hh:MM:ss");
SimpleDateFormat df2 = new SimpleDateFormat("yyyy-MM-dd hh:MM:ss");
SimpleDateFormat df3 = new SimpleDateFormat("yyyy-MM-dd hh:MM:ss");
TimeZone timezone1 = TimeZone.getTimeZone("GMT"); // GMT
TimeZone timezone2 = TimeZone.getTimeZone("GMT+7"); //东七区
TimeZone timezone3 = TimeZone.getTimeZone("GMT+8"); //东八区
df1.setTimeZone(timezone1);
df2.setTimeZone(timezone2);
df3.setTimeZone(timezone3);
Date date1 = df1.parse("2012-01-01 23:00:00 +0800");
Date date2 = df2.parse("2012-01-01 23:00:00 +0800");
Date date3 = df3.parse("2012-01-01 23:00:00 +0800");
System.out.println(date1);
System.out.println(date2);
System.out.println(date3);
System.out.println(df1.format(date1));
System.out.println(df2.format(date2));
System.out.println(df3.format(date3));
输出结果
MON JAN 02 07:00:00 CST 2012
MON JAN 02 00:00:00 CST 2012
SUN JAN 01 23:00:00 CST 2012
2012-01-01 11:00:00
2012-01-01 11:00:00
2012-01-01 11:00:00
有没有一头雾水,为什么输入东八区23点,转换成格林尼治时间(GMT)就变成了次日7点?按说是GMT时间加上8个小时才是东八区时间呀,东八区23点应该是GMT15点呀。还有,为什么使用format()
方法转换成字符串再输出,又变成了11点?
首先这段代码有些问题,第一个问题是Java日期和时间格式中,hh
代表的是12小时制,24小时制应该用HH
,所以前面输出的11点其实是晚上11点;
使用格式pattern“yyyy-MM-dd HH:MM:ss
”再试一下:
输出结果
MON JAN 02 07:00:00 CST 2012
MON JAN 02 00:00:00 CST 2012
SUN JAN 01 23:00:00 CST 2012
2012-01-01 23:00:00
2012-01-01 23:00:00
2012-01-01 23:00:00
第二个问题是字符串中代表东八区的“+0800”其实并没有被处理,需要在格式pattern后面加上Z
代表时区;
使用格式pattern"yyyy-MM-dd HH:MM:ss Z
"再试一下:
输出结果
SUN JAN 01 23:00:00 CST 2012
SUN JAN 01 23:00:00 CST 2012
SUN JAN 01 23:00:00 CST 2012
2012-01-01 15:00:00 +0000
2012-01-01 22:00:00 +0700
2012-01-01 23:00:00 +0800
似乎只有最后三行输出解释得通:
东八区23点,相当于GMT15,相当于东七区22点。
先上结论:DateFormat
的parse()
会自动地将时间转换为系统默认时区的时间,在我这里,就是东八区时间,所以在前两次的输出中,前三行的时间都是东八区的时间
格式pattern为“yyyy-MM-dd HH:MM:ss
”的情况:
此时相当于df.parse("2012-01-01 23:00:00")
,日期字符串中没有给出时区,这时df.setTimeZone(timezone)
语句所设置的时区生效,相当于分别输入了GMT的23点、东七区的23点和东八区的23点,转换为东八区时间后,分别为次日7点、次日0点和23点;
那为什么后三行输出全部是23点呢,这是由于东八区次日7点转换为GMT时间、东八区次日0点转换为东七区时间和东八区23点转换为和东八区时间,正好都是23点。
格式pattern为“yyyy-MM-dd HH:MM:ss Z
”的情况:
对于前三行:
由于日期字符串中给出了时区东八区,这时df.setTimeZone(timezone)
语句不生效了,三个东八区23点全部转换为东八区23点,原样输出,我们可以验证这一点
输入东七区23点:
Date date1 = df1.parse("2012-01-01 23:00:00 +0700");
转换为东八区就是次日0点,输出结果和预测一致:
MON JAN 02 00:00:00 CST 2012
对于后三行:
三个东八区23点分别转换为GMT、东七区和东八区时间,结果就是15点、22点和23点。
这种隐式的系统默认时区的转换,真的让人摸不着头脑。
留意到上面输出里的CST了吗,CST的意思是Central Standard Time,即美国中部标准时间,带有CST的输出对应System.out.println(date)
,调用了Date
的toString()
方法,查看源码可以发现这样一段:
TimeZone zi = date.getZone();
if (zi != null) {
sb.append(zi.getDisplayName(date.isDaylightTime(), TimeZone.SHORT, Locale.US));
} else {
sb.append("GMT");
}
只要时区不为空,就在后面加上CST,否则加上GMT,不是美国就是格林尼治,还真是粗暴啊,看到其中调用CalendaDate
的isDaylightTime()
方法好像还会判断夏令时(因为美国是使用夏冬双时间制的),如果是夏令时,或许还会显示CDT,也就是Central Daylight Time美国中部夏令时
接下来列出一些DateFormat
的parse()
方法的源码,表明上述结论确实是源码的行为:
主要行为有两个:
- 日期字符串中给定时区对
DateFormat
的setTimeZone(TimeZone zone)
方法的覆盖; parse(String source)
方法将时间隐式转换为系统默认时区的时间。
第一点:日期字符串中给定时区对DateFormat
的setTimeZone(TimeZone zone)
方法的覆盖
DateFormat
的parse(String source)
方法
parse(String source)
第364行调用parse(String source, ParsePosition pos)
方法(SimpleDateFormat
实现类)
SimpleDateFormat
的parse(String source, ParsePosition pos)
第1514行调用subParse()
方法
subParse()
第2099行调用subParseNumericZone()
方法
如果日期字符串指定了时区,时区就会以偏移量的毫秒值(即偏离GMT时间的毫秒值,如东八区就是+8*60*60*1000=+28800000)的形式储存在CalendarBuilder
对象中:
subParseNumericZone()
第1791行calb.set()
将CalendarBuilder
对象的field
属性(int
数组)的第15个元素设置为8,第33个元素设置为偏移量的毫秒值28800000。
如果日期字符串没有指定时区,第15个元素和第33个元素均为0。
回到SimpleDateFormat
的parse(String source, ParsePosition pos)
方法
第1532行calb.establish(calendar).getTime()
这里的calendar
是SimpleDateFormat
对象的属性,可以发现这个属性已经初始化,其zone
属性就是通过setTimeZone()
方法指定的时区
这里调用CalendarBuilder
的establish(Calendar cal)
方法建立calendar
,然后再调用Calendar
的getTime()
。
CalendarBuilder
的establish(Calendar cal)
方法第114行cal.clear()
先将cal
的field
属性的所有元素置零
第120行cal.set()
将CalendarBuilder
对象calb
中的所有属性设置到Calendar
对象cal
(也就是SimpleDateFormat
对象的calendar
属性)中,其中就包括偏移量的毫秒值28800000,这个值储存在cal
的field
属性中,field
是一个int
数组,偏移量的毫秒值放在第15个元素中,15用常量ZONE_OFFSET
表示。如果日期字符串没有指定时区,那么偏移量的毫秒值为0。
Calendar
的getTime()
调用了getTimeMills()
方法
getTimeMills()
调用了updateTime()
方法
updateTime()
调用了computeTime()
方法(GregorianCalendar
实现类)
GregorianCalendar
的computeTime()
方法第2787行注释提到:
We use TimeZone object, unless the user has explicitly set the ZONE_OFFSET or DST_OFFSET fields; then we use those fields.
我们使用TimeZone对象,除非用户显式设置了第
ZONE_OFFSET
个字段或者是第DST_OFFSET
个字段
这说明ZONE_OFFSET
会覆盖TimeZone
对象,这里的第ZONE_OFFSET
个字段就是先前提到的field[15]
,TimeZone
对象就是zone
属性。
覆盖具体表现为:ZONE_OFFSET
或TimeZone
对象覆盖了int[2] zoneOffsets
属性的第0个元素,zoneOffsets[0]
储存的就是偏移量的毫秒值,这就是日期字符串中给定时区对DateFormat
的setTimeZone(TimeZone zone)
方法的覆盖。
zoneOffsets[0]
在第2813行会与millis
做减法运算,而millis
是给定字面时间(即不考虑时区,在这里是23点)转换成当地时区(也就是系统默认时区)时间的毫秒值(这里就是parse(String source)
对时间的隐式时区变换)
这个运算等价于将setTimeZone(TimeZone zone)
或日期字符串指定时区的时间转换成系统默认时区的时间。
第二点:parse(String source)
方法将时间隐式转换为系统默认时区的时间
在DateFormat
的parse(String source, ParsePosition pos)
方法的注释上也有提到:
This parsing operation uses the calendar to produce a Date. As a result, the calendar’s date-time fields and the TimeZone value may have been overwritten, depending on subclass implementations. Any TimeZone value that has previously been set by a call to setTimeZone may need to be restored for further operations.
该parse操作使用calendar生成一个Date。calendar的date-time字段和TimeZone值可能会被覆盖,这取决于子类的实现。先前通过调用setTimeZone所设置的TimeZone值可能需要恢复,以进行更多操作。