java.util.Date:Sat Jan 16 21:50:36 CST 2021
java.sql.Date:2021-01-16
java.sql.Time:21:50:36
java.sql.Timestamp:2021-01-16 21:50:36.733
国际化支持得并不是好,比如跨时区操作、夏令时等等
Java 自己也实在忍不了这么难用的日期时间API了,于是在2014年随着Java 8的发布引入了全新的JSR 310日期时间。JSR-310源于精品时间库joda-time打造,解决了上面提到的所有问题,是整个Java 8最大亮点之一。
JSR 310日期/时间 所有的 API都在java.time这个包内,没有例外。
当然喽,本文重点并不在于讨论JSR 310日期/时间体系,而是看看JSR 310日期时间类型是如何处理上面Date类型遇到的那些case的。
时区/偏移量ZoneId
在JDK 8之前,Java使用java.util.TimeZone
来表示时区。而在JDK 8里分别使用了ZoneId表示时区,ZoneOffset表示UTC的偏移量。
值得提前强调,时区和偏移量在概念和实际作用上是有较大区别的,主要体现在:
UTC偏移量仅仅记录了偏移的小时分钟而已,除此之外无任何其它信息。举个例子:+08:00的意思是比UTC时间早8小时,没有地理/时区含义,相应的-03:30代表的意思仅仅是比UTC时间晚3个半小时
时区是特定于地区而言的,它和地理上的地区(包括规则)强绑定在一起。比如整个中国都叫东八区,纽约在西五区等等
中国没有夏令时,所有东八区对应的偏移量永远是+8;纽约有夏令时,因此它的偏移量可能是-4也可能是-5哦
综合来看,时区更好用。令人恼火的夏令时问题,若你使用UTC偏移量去表示那么就很麻烦,因为它可变:一年内的某些时期在原来基础上偏移量 +1,某些时期 -1;但若你使用ZoneId时区去表示就很方便喽,比如纽约是西五区,你在任何时候获取其当地时间都是能得到正确答案的,因为它内置了对夏令时规则的处理,也就是说啥时候+1啥时候-1时区自己门清,不需要API调用者关心。
UTC偏移量更像是一种写死偏移量数值的做法,这在天朝这种没有时区规则(没有夏令时)的国家不会存在问题,东八区和UTC+08:00效果永远一样。但在一些夏令时国家(如美国、法国等等),就只能根据时区去获取当地时间喽。所以当你不了解当地规则时,最好是使用时区而非偏移量。
ZoneId
它代表一个时区的ID,如Europe/Paris。它规定了一些规则可用于将一个Instant时间戳转换为本地日期/时间LocalDateTime。
上面说了时区ZoneId是包含有规则的,实际上描述偏移量何时以及如何变化的实际规则由java.time.zone.ZoneRules
定义。ZoneId则只是一个用于获取底层规则的ID。之所以采用这种方法,是因为规则是由政府定义的,并且经常变化,而ID是稳定的。
对于API调用者来说只需要使用这个ID(也就是ZoneId)即可,而需无关心更为底层的时区规则ZoneRules,和“政府”同步规则的事是它领域内的事就交给它喽。如:夏令时这条规则是由各国政府制定的,而且不同国家不同年一般都不一样,这个事就交由JDK底层的ZoneRules机制自行sync,使用者无需关心。
ZoneId在系统内是唯一的,它共包含三种类型的ID:
最简单的ID类型:ZoneOffset,它由'Z'和以'+'或'-'开头的id组成。如:Z、+18:00、-18:00
另一种类型的ID是带有某种前缀形式的偏移样式ID,例如'GMT+2'或'UTC+01:00'。可识别的(合法的)前缀是'UTC', 'GMT'和'UT'
第三种类型是基于区域的ID(推荐使用)。基于区域的ID必须包含两个或多个字符,且不能以'UTC'、'GMT'、'UT' '+'或'-'开头。基于区域的id由配置定义好的,如Europe/Paris
概念说了一大推,下面给几个代码示例感受下吧。
1、获取系统默认的ZoneId:
@Test
public void test1() {
// JDK 1.8之前做法
System.out.println(TimeZone.getDefault());
// JDK 1.8之后做法
System.out.println(ZoneId.systemDefault());
Asia/Shanghai
sun.util.calendar.ZoneInfo[id="Asia/Shanghai",offset=28800000,dstSavings=0,useDaylight=false,transitions=29,lastRule=null]
二者结果是一样的,都是Asia/Shanghai。因为ZoneId方法底层就是依赖TimeZone,如图:
2、指定字符串得到一个ZoneId:
@Test
public void test2() {
System.out.println(ZoneId.of("Asia/Shanghai"));
// 报错:java.time.zone.ZoneRulesException: Unknown time-zone ID: Asia/xxx
System.out.println(ZoneId.of("Asia/xxx"));
很明显,这个字符串也是不能随便写的。那么问题来了,可写的有哪些呢?同样的ZoneId提供了API供你获取到所有可用的字符串id,有兴趣的同学建议自行尝试:
@Test
public void test3() {
ZoneId.getAvailableZoneIds();
3、根据偏移量得到一个ZoneId:
@Test
public void test4() {
ZoneId zoneId = ZoneId.ofOffset("UTC", ZoneOffset.of("+8"));
System.out.println(zoneId);
// 必须是大写的Z
zoneId = ZoneId.ofOffset("UTC", ZoneOffset.of("Z"));
System.out.println(zoneId);
UTC+08:00
这里第一个参数传的前缀,可用值为:"GMT", "UTC", or "UT"。当然还可以传空串,那就直接返回第二个参数ZoneOffset。若以上都不是就报错
注意:根据偏移量得到的ZoneId内部并无现成时区规则可用,因此对于有夏令营的国家转换可能出问题,一般不建议这么去做。
4、从日期里面获得时区:
@Test
public void test5() {
System.out.println(ZoneId.from(ZonedDateTime.now()));
System.out.println(ZoneId.from(ZoneOffset.of("+8")));
// 报错:java.time.DateTimeException: Unable to obtain ZoneId from TemporalAccessor:
System.out.println(ZoneId.from(LocalDateTime.now()));
System.out.println(ZoneId.from(LocalDate.now()));
虽然方法入参是TemporalAccessor,但是只接受带时区的类型,LocalXXX是不行的,使用时稍加注意。
ZoneOffset
距离格林威治/UTC的时区偏移量,例如+02:00。值得注意的是它继承自ZoneId,所以也可当作一个ZoneId来使用的,当然并不建议你这么去做,请独立使用。
时区偏移量是时区与格林威治/UTC之间的时间差。这通常是固定的小时数和分钟数。世界不同的地区有不同的时区偏移量。在ZoneId类中捕获关于偏移量如何随一年的地点和时间而变化的规则(主要是夏令时规则),所以继承自ZoneId。
1、最小/最大偏移量:因为偏移量传入的是数字,这个是有限制的哦
@Test
public void test6() {
System.out.println("最小偏移量:" + ZoneOffset.MIN);
System.out.println("最小偏移量:" + ZoneOffset.MAX);
System.out.println("中心偏移量:" + ZoneOffset.UTC);
// 超出最大范围
System.out.println(ZoneOffset.of("+20"));
最小偏移量:-18:00
最小偏移量:+18:00
中心偏移量:Z
java.time.DateTimeException: Zone offset hours not in valid range: value 20 is not in the range -18 to 18
2、通过时分秒构造偏移量(使用很方便,推荐):
@Test
public void test7() {
System.out.println(ZoneOffset.ofHours(8));
System.out.println(ZoneOffset.ofHoursMinutes(8, 8));
System.out.println(ZoneOffset.ofHoursMinutesSeconds(8, 8, 8));
System.out.println(ZoneOffset.ofHours(-5));
// 指定一个精确的秒数 获取实例(有时候也很有用处)
System.out.println(ZoneOffset.ofTotalSeconds(8 * 60 * 60));
// 输出:
+08:00
+08:08
+08:08:08
-05:00
+08:00
看来,偏移量是能精确到秒的哈,只不过一般来说精确到分钟已经到顶了。
设置默认时区
ZoneId并没有提供设置默认时区的方法,但是通过文章可知ZoneId获取默认时区底层依赖的是TimeZone.getDefault()
方法,因此设置默认时区方式完全遵照TimeZone的方式即可(共三种方式,还记得吗?)。
让人恼火的夏令时
因为有夏令时规则的存在,让操作日期/时间的复杂度大大增加。但还好JDK尽量的屏蔽了这些规则对使用者的影响。因此:推荐使用时区(ZoneId)转换日期/时间,一般情况下不建议使用偏移量ZoneOffset去搞,这样就不会有夏令时的烦恼啦。
JSR 310时区相关性
java.util.Date类型它具有时区无关性,带来的弊端就是一旦涉及到国际化时间转换等需求时,使用Date来处理是很不方便的。
JSR 310解决了Date存在的一系列问题:对日期、时间进行了分开表示(LocalDate、LocalTime、LocalDateTime),对本地时间和带时区的时间进行了分开管理。LocalXXX表示本地时间,也就是说是当前JVM所在时区的时间;ZonedXXX表示是一个带有时区的日期时间,它们能非常方便的互相完成转换。
@Test
public void test8() {
// 本地日期/时间
System.out.println("================本地时间================");
System.out.println(LocalDate.now());
System.out.println(LocalTime.now());
System.out.println(LocalDateTime.now());
// 时区时间
System.out.println("================带时区的时间ZonedDateTime================");
System.out.println(ZonedDateTime.now()); // 使用系统时区
System.out.println(ZonedDateTime.now(ZoneId.of("America/New_York"))); // 自己指定时区
System.out.println(ZonedDateTime.now(Clock.systemUTC())); // 自己指定时区
System.out.println("================带时区的时间OffsetDateTime================");
System.out.println(OffsetDateTime.now()); // 使用系统时区
System.out.println(OffsetDateTime.now(ZoneId.of("America/New_York"))); // 自己指定时区
System.out.println(OffsetDateTime.now(Clock.systemUTC())); // 自己指定时区
运行程序,输出:
================本地时间================
2021-01-17
09:18:40.703
2021-01-17T09:18:40.703
================带时区的时间ZonedDateTime================
2021-01-17T09:18:40.704+08:00[Asia/Shanghai]
2021-01-16T20:18:40.706-05:00[America/New_York]
2021-01-17T01:18:40.709Z
================带时区的时间OffsetDateTime================
2021-01-17T09:18:40.710+08:00
2021-01-16T20:18:40.710-05:00
2021-01-17T01:18:40.710Z
本地时间的输出非常“干净”,可直接用于显示。带时区的时间显示了该时间代表的是哪个时区的时间,毕竟不指定时区的时间是没有任何意义的。LocalXXX因为它具有时区无关性,因此它不能代表一个瞬间/时刻。
另外,关于LocalDateTime、OffsetDateTime、ZonedDateTime三者的跨时区转换问题,以及它们的详解,因为内容过多放在了下文专文阐述,保持关注。
读取字符串为JSR 310类型
一个独立的日期时间类型字符串如2021-05-05T18:00-04:00它是没有任何意义的,因为没有时区无法确定它代表那个瞬间,这是理论当然也适合JSR 310类型喽。
遇到一个日期时间格式字符串,要解析它一般有这两种情况:
不带时区/偏移量的字符串:要么不理它说转换不了,要么就约定一个时区(一般用系统默认时区),使用LocalDateTime来解析
@Test
public void test11() {
String dateTimeStrParam = "2021-05-05T18:00";
LocalDateTime localDateTime = LocalDateTime.parse(dateTimeStrParam);
System.out.println("解析后:" + localDateTime);
解析后:2021-05-05T18:00
带时区字/偏移量的符串:
@Test
public void test12() {
// 带偏移量 使用OffsetDateTime
String dateTimeStrParam = "2021-05-05T18:00-04:00";
OffsetDateTime offsetDateTime = OffsetDateTime.parse(dateTimeStrParam);
System.out.println("带偏移量解析后:" + offsetDateTime);
// 带时区 使用ZonedDateTime
dateTimeStrParam = "2021-05-05T18:00-05:00[America/New_York]";
ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateTimeStrParam);
System.out.println("带时区解析后:" + zonedDateTime);
带偏移量解析后:2021-05-05T18:00-04:00
带时区解析后:2021-05-05T18:00-04:00[America/New_York]
请注意带时区解析后这个结果:字符串参数偏移量明明是-05,为毛转换为ZonedDateTime后偏移量成为了-04呢???
这里是我故意造了这么一个case引起你的重视,对此结果我做如下解释:
如图,在2021.03.14 - 2021.11.07期间,纽约的偏移量是-4,其余时候是-5。本例的日期是2021-05-05处在夏令时之中,因此偏移量是-4,这就解释了为何你显示的写了-5最终还是成了-4。
JSR 310格式化
针对JSR 310日期时间类型的格式化/解析,有个专门的类java.time.format.DateTimeFormatter
用于处理。
DateTimeFormatter也是一个不可变的类,所以是线程安全的,比SimpleDateFormat靠谱多了吧。另外它还内置了非常多的格式化模版实例供以使用,形如:
@Test
public void test13() {
System.out.println(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now()));
System.out.println(DateTimeFormatter.ISO_LOCAL_TIME.format(LocalTime.now()));
System.out.println(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now()));
2021-01-17
22:43:21.398
2021-01-17T22:43:21.4
若想自定义模式pattern,和Date一样它也可以自己指定任意的pattern 日期/时间模式。由于本文在Date部分详细介绍了日期/时间模式,各个字母代表什么意思以及如何使用,这里就不再赘述了哈。
虽然DateTimeFormatter支持的模式比Date略有增加,但大体还保持一致,个人觉得这块无需再花精力。若真有需要再查官网也不迟
@Test
public void test14() {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("第Q季度 yyyy-MM-dd HH:mm:ss", Locale.US);
// 格式化输出
System.out.println(formatter.format(LocalDateTime.now()));
// 解析
String dateTimeStrParam = "第1季度 2021-01-17 22:51:32";
LocalDateTime localDateTime = LocalDateTime.parse(dateTimeStrParam, formatter);
System.out.println("解析后的结果:" + localDateTime);
Q/q:季度,如3; 03; Q3; 3rd quarter。
弃用Date,拥抱JSR 310
每每说到JSR 310日期/时间时我都会呼吁,保持惯例我这里继续啰嗦一句:放弃Date甚至禁用Date,使用JSR 310日期/时间吧,它才是日期时间处理的最佳实践。
另外,在使用期间关于制定时区(默认时区时)依旧有一套我心目中的最佳实践存在,这里分享给你:
永远显式的指定你需要的时区,即使你要获取的是默认时区
// 方式一:普通做法
LocalDateTime.now();
// 方式二:最佳实践
LocalDateTime.now(ZoneId.systemDefault());
如上代码二者效果一模一样。但是方式二是最佳实践。
理由是:这样做能让代码带有明确的意图,消除模棱两可的可能性,即使获取的是默认时区。拿方式一来说吧,它就存在意图不明确的地方:到底是代码编写者忘记指定时区欠考虑了,还是就想用默认时区呢?这个答案如果不通读上下文是无法确定的,从而造成了不必要的沟通维护成本。因此即使你是要获取默认时区,也请显示的用ZoneId.systemDefault()写上去。
使用JVM的默认时区需当心,建议时区和当前会话保持绑定
这个最佳实践在特殊场景用得到。这么做的理由是:JVM的默认时区通过静态方法TimeZone#setDefault()可全局设置,因此JVM的任何一个线程都可以随意更改默认时区。若关于时间处理的代码对时区非常敏感的话,最佳实践是你把时区信息和当前会话绑定,这样就可以不用再受到其它线程潜在影响了,确保了健壮性。
说明:会话可能只是当前请求,也可能是一个Session,具体case具体分析
通过上篇文章 对日期时间相关概念的铺垫,加上本文的实操代码演示,达到弄透Java对日期时间的处理基本不成问题。
两篇文章的内容较多,信息量均比较大,消化起来需要些时间。一方面我建议你先搜藏留以当做参考书备用,另一方面建议多实践,代码这东西只有多写写才能有更深体会。
后面会再用3 -4篇文章对这前面这两篇的细节、使用场景进行补充,比如如何去匹配ZoneId和Offset的对应关系,LocalDateTime、OffsetDateTime、ZonedDateTime跨时区互转问题、在Spring MVC场景下使用的最佳实践等等,敬请关注,一起进步。
本文思考题
看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:
Date类型如何处理夏令时?
ZoneId和ZoneOffset有什么区别?
平时项目若遇到日期时间的处理,有哪些最佳实践?
GMT UTC CST ISO 夏令时 时间戳,都是些什么鬼?
分享、成长,拒绝浅藏辄止。关注【BAT的乌托邦】回复关键字专栏有Spring技术栈、中间件等小而美的纯原创专栏。本文已被 https://www.yourbatman.cn 收录。
本文所属专栏:JDK日期时间,公号后台回复专栏名即可获取全部内容。
A哥(YourBatman):Spring Framework/Boot开源贡献者,Java架构师。非常注重基本功修养,相信底层基础决定上层建筑,坚实基础才能焕发程序员更强生命力。文章特点为以小而美专栏形式重构知识体系,抽丝剥茧,致力于做人人能看懂的最好的专栏系列。可加我好友(fsx1056342982)共勉哦!