SimpleDateFormat、Date和Calendar这三个类现在很多地方已经不再推荐使用了,JDK8可以使用Instant代替Date,LocalDateTime代替Calendar,DateTimeFormatter代替SimpleDateFormat
很多地方都会提到SimpleDateFormat线程不安全,这确实是一个非常严重的问题,但是这几个类的问题或者说设计缺陷不止于此: Date的名字有误导性,它表达的语义其实是一个时刻而不是一个日期; 很多地方隐式地将时区变换为系统默认时区,比如SimpleDateFormat的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");
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点。
先上结论:SimpleDateFormat 的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,不是美国就是格林尼治,还真是粗暴啊,看到其中的isDaylightTime() 方法好像还会判断夏令时(因为美国是使用夏冬双时间制的),如果是夏令时,或许还会显示CDT,也就是Central Daylight Time美国中部夏令时
接下来列出一些parse() 方法的源码,表面上述结论确实是源码的行为: 主要行为有两个:
- 日期字符串中给定时区对
setTimeZone() 方法的覆盖; parse() 方法将时间隐式转换为系统默认时区的时间。
第一点 parse(String source) 方法 parse(String source) 第363行调用parse(String source, ParsePosition pos) 方法(SimpleDateFormat 实现类) parse(String source, ParsePosition pos) 第1514行调用subParse() 方法 subParse() 第2099行调用subParseNumericZone() 方法 如果日期字符串指定了时区,时区就会以偏移量的毫秒值(即偏离GMT时间的毫秒值,如东八区就是+86060*1000=+28800000)的形式储存在CalendarBuilder 对象中:
subParseNumericZone() 第1791行calb.set() 将CalendarBuilder 对象的field 属性(int 数组)的第15个元素设置为8,第33个元素设置为偏移量的毫秒值。 如果日期字符串没有指定时区,第15个元素和第33个元素均为0。
第1532行calb.establish(calendar).getTime() 这里的calendar 是SimpleDateFormat 对象的属性,可以发现在这个属性已经初始化,其zone 属性就是通过setTimeZone() 方法指定的时区 这里调用CalendarBuilder 的establish() 方法建立calendar ,然后再调用Calendar 的getTime() 。
establish() 第114行cal.clear() 先将cal 的field 属性的所有元素置零 第120行cal.set() 将CalendarBuilder对象calb 中的所有属性设置到Calendar 对象cal (也就是SimpleDateFormat 对象的calendar 属性)的中,其中就包括偏移量的毫秒值,这个值储存在cal 的field 属性中,field 是一个int 数组,偏移量的毫秒值放在第15个元素中,15用常量ZONE_OFFSET 表示。如果日期字符串没有指定时区,那么偏移量的毫秒值为0。
getTime() 调用了getTimeMills() 方法 getTimeMills() 调用了updateTime() 方法 updateTime() 调用了computeTime() 方法(GregorianCalendar 实现类) computeTime() 方法第2787行注释提到:
We use TimeZone object, unless the user has explicitly set the ZONE_OFFSET or DST_OFFSET; then we use those fields.
我们用TimeZone对象,除非用户显式设置了ZONE_OFFSET或者是DST_OFFSET
这说明ZONE_OFFSET 会覆盖TimeZone 对象,这里的ZONE_OFFSET 就是先前提到的field[15] ,TimeZone 对象就是zone 属性。 覆盖具体表现为:到底是ZONE_OFFSET 还是TimeZone 对象覆盖了zoneOffsets 属性的第0个元素,zoneOffsets[0] 储存的就是偏移量的毫秒值,这就是日期字符串中给定时区对setTimeZone() 方法的覆盖。
zoneOffsets[0] 在第2813行会与millis 做减法运算,而millis 是给定字面时间(即不考虑时区,在这里是23点)转换成当地时区(也就是系统默认时区)时间的毫秒值(这里就是parse() 对时间的隐式时区变换) 这个运算等价于将setTimeZone() 或日期字符串指定时区的时间转换成系统默认时区的时间。
第二点在parse() 方法的注释上也有提到:
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值可能需要恢复,以进行更多操作。
|