SimpleDateFormat.parse()方法中的时区设置缺陷

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点。

先上结论:DateFormatparse()会自动地将时间转换为系统默认时区的时间,在我这里,就是东八区时间,所以在前两次的输出中,前三行的时间都是东八区的时间

格式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),调用了DatetoString()方法,查看源码可以发现这样一段:

TimeZone zi = date.getZone();
if (zi != null) {
    sb.append(zi.getDisplayName(date.isDaylightTime(), TimeZone.SHORT, Locale.US));
} else {
    sb.append("GMT");
}

只要时区不为空,就在后面加上CST,否则加上GMT,不是美国就是格林尼治,还真是粗暴啊,看到其中调用CalendaDateisDaylightTime()方法好像还会判断夏令时(因为美国是使用夏冬双时间制的),如果是夏令时,或许还会显示CDT,也就是Central Daylight Time美国中部夏令时

接下来列出一些DateFormatparse()方法的源码,表明上述结论确实是源码的行为:
主要行为有两个:

  1. 日期字符串中给定时区对DateFormatsetTimeZone(TimeZone zone)方法的覆盖;
  2. parse(String source)方法将时间隐式转换为系统默认时区的时间。

第一点:日期字符串中给定时区对DateFormatsetTimeZone(TimeZone zone)方法的覆盖
DateFormatparse(String source)方法
parse(String source)第364行调用parse(String source, ParsePosition pos)方法(SimpleDateFormat实现类)
SimpleDateFormatparse(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。

回到SimpleDateFormatparse(String source, ParsePosition pos)方法
第1532行calb.establish(calendar).getTime()
这里的calendarSimpleDateFormat对象的属性,可以发现这个属性已经初始化,其zone属性就是通过setTimeZone()方法指定的时区
这里调用CalendarBuilderestablish(Calendar cal)方法建立calendar,然后再调用CalendargetTime()

CalendarBuilderestablish(Calendar cal)方法第114行cal.clear()先将calfield属性的所有元素置零
第120行cal.set()CalendarBuilder对象calb中的所有属性设置到Calendar对象cal(也就是SimpleDateFormat对象的calendar属性)中,其中就包括偏移量的毫秒值28800000,这个值储存在calfield属性中,field是一个int数组,偏移量的毫秒值放在第15个元素中,15用常量ZONE_OFFSET表示。如果日期字符串没有指定时区,那么偏移量的毫秒值为0。

CalendargetTime()调用了getTimeMills()方法
getTimeMills()调用了updateTime()方法
updateTime()调用了computeTime()方法(GregorianCalendar实现类)
GregorianCalendarcomputeTime()方法第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_OFFSETTimeZone对象覆盖了int[2] zoneOffsets属性的第0个元素,zoneOffsets[0]储存的就是偏移量的毫秒值,这就是日期字符串中给定时区对DateFormatsetTimeZone(TimeZone zone)方法的覆盖。

zoneOffsets[0]在第2813行会与millis做减法运算,而millis是给定字面时间(即不考虑时区,在这里是23点)转换成当地时区(也就是系统默认时区)时间的毫秒值(这里就是parse(String source)对时间的隐式时区变换)
这个运算等价于将setTimeZone(TimeZone zone)或日期字符串指定时区的时间转换成系统默认时区的时间。

第二点:parse(String source)方法将时间隐式转换为系统默认时区的时间
DateFormatparse(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值可能需要恢复,以进行更多操作。

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值