当前位置 博文首页 > YourBatman:全网最全!彻底弄透Java处理GMT/UTC日期时间

    YourBatman:全网最全!彻底弄透Java处理GMT/UTC日期时间

    作者:YourBatman 时间:2021-01-30 18:31

    2万字,呕心沥血收藏

    目录
    • 前言
      • 本文提纲
      • 版本约定
    • 正文
      • Date类型实现
        • 时区/偏移量TimeZone
          • 设置默认时区
        • 让人恼火的夏令时
        • Date时区无关性
        • 读取字符串为Date类型
        • SimpleDateFormat格式化
      • JSR 310类型
        • 时区/偏移量ZoneId
          • ZoneId
          • ZoneOffset
            • 设置默认时区
        • 让人恼火的夏令时
        • JSR 310时区相关性
        • 读取字符串为JSR 310类型
        • JSR 310格式化
      • 最佳实践
    • 总结
      • 本文思考题
      • 推荐阅读
      • 关注我

    前言

    你好,我是A哥(YourBatman)。

    本系列的目的是明明白白、彻彻底底的搞定日期/时间处理的几乎所有case。上篇文章 铺设所有涉及到的概念解释,例如GMT、UTC、夏令时、时间戳等等,若你还没看过,不仅强烈建议而是强制建议你前往用花5分钟看一下,因为日期时间处理较为特殊,实战必须基于对概念的了解,否则很可能依旧雾里看花。

    说明:日期/时间的处理是日常开发非常常见的老大难,究其原因就是对日期时间的相关概念、应用场景不熟悉,所以不要忽视它

    上篇概念,本文落地实操,二者相辅相成,缺一不可。本文内容较多,文字较长,预计超2w字,旨在全面的彻底帮你搞定Java对日期时间的处理,建议你可收藏,作为参考书留以备用。

    本文提纲

    版本约定

    • JDK:8

    正文

    上文铺了这么多概念,作为一枚Javaer最关心当然是这些“概念”在Java里的落地。平时工作中遇到时间如何处理?用Date还是JDK 8之后的日期时间API?如何解决跨时区转换等等头大问题。A哥向来管生管养,管杀管埋,因此本文就带你领略一下,Java是如何实现GMT和UTC的?

    众所周知,JDK以版本8为界,有两套处理日期/时间的API:

    虽然我一直鼓励弃用Date而支持在项目中只使用JSR 310日期时间类型,但是呢,由于Date依旧有庞大的存量用户,所以本文也不落单,对二者的实现均进行阐述。

    Date类型实现

    java.util.Date在JDK 1.0就已存在,用于表示日期 + 时间的类型,纵使年代已非常久远,并且此类的具有职责不单一,使用很不方便等诸多毛病,但由于十几二十年的历史原因存在,它的生命力依旧顽强,用户量巨大。

    先来认识下Date,看下这个例子的输出:

    @Test
    public void test1() {
        Date currDate = new Date();
        System.out.println(currDate.toString());
        // 已经@Deprecated
        System.out.println(currDate.toLocaleString());
        // 已经@Deprecated
        System.out.println(currDate.toGMTString());
    }
    

    运行程序,输出:

    Fri Jan 15 10:22:34 CST 2021
    2021-1-15 10:22:34
    15 Jan 2021 02:22:34 GMT
    

    第一个:标准的UTC时间(CST就代表了偏移量 +0800)
    第二个:本地时间,根据本地时区显示的时间格式
    第三个:GTM时间,也就是格林威治这个时候的时间,可以看到它是凌晨2点(北京时间是上午10点哦)

    第二个、第三个其实在JDK 1.1就都标记为@Deprecated过期了,基本禁止再使用。若需要转换为本地时间 or GTM时间输出的话,请使用格式化器java.text.DateFormat去处理。

    时区/偏移量TimeZone

    在JDK8之前,Java对时区和偏移量都是使用java.util.TimeZone来表示的。

    一般情况下,使用静态方法TimeZone#getDefault()即可获得当前JVM所运行的时区,比如你在中国运行程序,这个方法返回的就是中国时区(也叫北京时区、北京时间)。

    有的时候你需要做带时区的时间转换,譬如:接口返回值中既要有展示北京时间,也要展示纽约时间。这个时候就要获取到纽约的时区,以北京时间为基准在其上进行带时区转换一把:

    @Test
    public void test2() {
        String patternStr = "yyyy-MM-dd HH:mm:ss";
        // 北京时间(new出来就是默认时区的时间)
        Date bjDate = new Date();
    
        // 得到纽约的时区
        TimeZone newYorkTimeZone = TimeZone.getTimeZone("America/New_York");
        // 根据此时区 将北京时间转换为纽约的Date
        DateFormat newYorkDateFormat = new SimpleDateFormat(patternStr);
        newYorkDateFormat.setTimeZone(newYorkTimeZone);
        System.out.println("这是北京时间:" + new SimpleDateFormat(patternStr).format(bjDate));
        System.out.println("这是纽约时间:" + newYorkDateFormat.format(bjDate));
    }
    

    运行程序,输出:

    这是北京时间:2021-01-15 11:48:16
    这是纽约时间:2021-01-14 22:48:16
    

    (11 + 24) - 22 = 13,北京比纽约快13个小时没毛病。

    注意:两个时间表示的应该是同一时刻,也就是常说的时间戳值是相等的

    那么问题来了,你怎么知道获取纽约的时区用America/New_York这个zoneId呢?随便写个字符串行不行?

    答案是当然不行,这是有章可循的。下面我介绍两种查阅zoneId的方式,任你挑选:

    方式一:用Java程序把所有可用的zoneId打印出来,然后查阅

    @Test
    public void test3() {
        String[] availableIDs = TimeZone.getAvailableIDs();
        System.out.println("可用zoneId总数:" + availableIDs.length);
        for (String zoneId : availableIDs) {
            System.out.println(zoneId);
        }
    }
    

    运行程序,输出(大部分符合规律:/前表示所属州,/表示城市名称):

    可用zoneId总数:628
    Africa/Abidjan
    Africa/Accra
    ...
    Asia/Chongqing // 亚洲/重庆
    Asia/Shanghai // 亚洲/上海
    Asia/Dubai // 亚洲/迪拜
    ...
    America/New_York // 美洲/纽约
    America/Los_Angeles // 美洲/洛杉矶
    ...
    Europe/London // 欧洲/伦敦
    ...
    Etc/GMT
    Etc/GMT+0
    Etc/GMT+1
    ...
    

    值得注意的是并没有 Asia/Beijing 哦。

    说明:此结果基于JDK 8版本,不同版本输出的总个数可能存在差异,但主流的ZoneId一般不会有变化

    方式二
    zoneId的列表是jre维护的一个文本文件,路径是你JDK/JRE的安装路径。地址在.\jre\lib目录的为未tzmappings的文本文件里。打开这个文件去ctrl + f找也是可以达到查找的目的的。

    这两种房子可以帮你找到ZoneId的字典方便查阅,但是还有这么一种情况:当前所在的城市呢,在tzmappings文件里根本没有(比如没有收录),那要获取这个地方的时间去显示怎么破呢?虽然概率很小,但不见得没有嘛,毕竟全球那么多国家那么多城市呢~

    Java自然也考虑到了这一点,因此也是有办法的:指定其时区数字表示形式,其实也叫偏移量(不要告诉我这个地方的时区都不知道,那就真没救了),如下示例

    @Test
    public void test4() {
        System.out.println(TimeZone.getTimeZone("GMT+08:00").getID());
        System.out.println(TimeZone.getDefault().getID());
    
        // 纽约时间
        System.out.println(TimeZone.getTimeZone("GMT-05:00").getID());
        System.out.println(TimeZone.getTimeZone("America/New_York").getID());
    }
    

    运行程序,输出:

    GMT+08:00 // 效果等同于Asia/Shanghai
    Asia/Shanghai
    GMT-05:00 // 效果等同于America/New_York
    America/New_York 
    

    值得注意的是,这里只能用GMT+08:00,而不能用UTC+08:00,原因下文有解释。

    设置默认时区

    一般来说,JVM在哪里跑,默认时区就是哪。对于国内程序员来讲,一般只会接触到东八区,也就是北京时间(本地时间)。随着国际合作越来越密切,很多时候需要日期时间国际化处理,举个很实际的例子:同一份应用在阿里云部署、在AWS(海外)上也部署一份供海外用户使用,此时同一份代码部署在不同的时区了,怎么破?

    倘若时区不同,那么势必影响到程序的运行结果,很容易带来计算逻辑的错误,很可能就乱套了。Java让我们有多种方式可以手动设置/修改默认时区:

    1. API方式: 强制将时区设为北京时区TimeZone.setDefault(TimeZone.getDefault().getTimeZone("GMT+8"));
    2. JVM参数方式:-Duser.timezone=GMT+8
    3. 运维设置方式:将操作系统主机时区设置为北京时区,这是推荐方式,可以完全对开发者无感,也方便了运维统一管理

    据我了解,很多公司在阿里云、腾讯云、国内外的云主机上部署应用时,全部都是采用运维设置统一时区:中国时区,这种方式来管理的,这样对程序来说就消除了默认时区不一致的问题,对开发者友好。

    让人恼火的夏令时

    你知道吗,中国曾经也使用过夏令时。

    什么是夏令时?戳这里

    离现在最近是1986年至1991年用过夏令时(每年4月中旬的第一个周日2时 - 9月中旬的第一个星期日2时止):
    1986年5月4日至9月14日
    1987年4月12日至9月13日
    1988年4月10日至9月11日
    1989年4月16日至9月17日
    1990年4月15日至9月16日
    1991年4月14日至9月15日

    夏令时是一个“非常烦人”的东西,大大的增加了日期时间处理的复杂度。比如这个灵魂拷问:若你的出生日期是1988-09-11 00:00:00(夏令时最后一天)且存进了数据库,想一想,对此日期的格式化有没有可能就会出问题呢,有没有可能被你格式化成1988-09-10 23:00:00呢?

    针对此拷问,我模拟了如下代码:

    @Test
    public void test5() throws ParseException {
        String patterStr = "yyyy-MM-dd";
        DateFormat dateFormat = new SimpleDateFormat(patterStr);
    
        String birthdayStr = "1988-09-11";
        // 字符串 -> Date -> 字符串
        Date birthday = dateFormat.parse(birthdayStr);
        long birthdayTimestamp = birthday.getTime();
        System.out.println("老王的生日是:" + birthday);
        System.out.println("老王的生日的时间戳是:" + birthdayTimestamp);
    
        System.out.println("==============程序经过一番周转,我的同时 方法入参传来了生日的时间戳=============");
        // 字符串 -> Date -> 时间戳 -> Date -> 字符串
        birthday = new Date(birthdayTimestamp);
        System.out.println("老王的生日是:" + birthday);
        System.out.println("老王的生日的时间戳是:" + dateFormat.format(birthday));
    }
    

    这段代码,在不同的JDK版本下运行,可能出现不同的结果,有兴趣的可copy过去自行试试。

    关于JDK处理夏令时(特指中国的夏令时)确实出现过问题且造成过bug,当时对应的JDK版本是1.8.0_2xx之前版本格式化那个日期出问题了,在这之后的版本貌似就没问题了。这里我提供的版本信息仅供参考,若有遇到类似case就升级JDK版本到最新吧,一般就不会有问题了。

    发生这个情况是在JDK非常小的版本号之间,不太好定位精确版本号界限,所以仅供参考

    总的来说,只要你使用的是较新版本的JDK,开发者是无需关心夏令时问题的,即使全球仍有很多国家在使用夏令时,咱们只需要面向时区做时间转换就没问题。

    Date时区无关性

    类Date表示一个特定的时间瞬间,精度为毫秒。既然表示的是瞬间/时刻,那它必然和时区是无关的,看下面代码:

    @Test
    public void test6() {
        String patterStr = "yyyy-MM-dd HH:mm:ss";
        Date currDate = new Date(System.currentTimeMillis());
    
        // 北京时区
        DateFormat bjDateFormat = new SimpleDateFormat(patterStr);
        bjDateFormat.setTimeZone(TimeZone.getDefault());
        // 纽约时区
        DateFormat newYorkDateFormat = new SimpleDateFormat(patterStr);
        newYorkDateFormat.setTimeZone(TimeZone.getTimeZone("America/New_York"));
        // 伦敦时区
        DateFormat londonDateFormat = new SimpleDateFormat(patterStr);
        londonDateFormat.setTimeZone(TimeZone.getTimeZone("Europe/London"));
    
        System.out.println("毫秒数:" + currDate.getTime() + ", 北京本地时间:" + bjDateFormat.format(currDate));
        System.out.println("毫秒数:" + currDate.getTime() + ", 纽约本地时间:" + newYorkDateFormat.format(currDate));
        System.out.println("毫秒数:" + currDate.getTime() + ", 伦敦本地时间:" + londonDateFormat.format(currDate));
    }
    

    运行程序,输出:

    毫秒数:1610696040244, 北京本地时间:2021-01-15 15:34:00
    毫秒数:1610696040244, 纽约本地时间:2021-01-15 02:34:00
    毫秒数:1610696040244, 伦敦本地时间:2021-01-15 07:34:00
    

    也就是说,同一个毫秒值,根据时区/偏移量的不同可以展示多地的时间,这就证明了Date它的时区无关性。

    确切的说:Date对象里存的是自格林威治时间( GMT)1970年1月1日0点至Date所表示时刻所经过的毫秒数,是个数值。

    读取字符串为Date类型

    这是开发中极其常见的一种需求:client请求方扔给你一个字符串如"2021-01-15 18:00:00",然后你需要把它转为Date类型,怎么破?

    问题来了,光秃秃的扔给我个字符串说是15号晚上6点时间,我咋知道你指的是北京的晚上6点,还是东京的晚上6点呢?还是纽约的晚上6点呢?

    因此,对于字符串形式的日期时间,只有指定了时区才有意义。也就是说字符串 + 时区 才能精确知道它是什么时刻,否则是存在歧义的。

    也许你可能会说了,自己平时开发中前端就是扔个字符串给我,然后我就给格式化为一个Date类型,并没有传入时区参数,运行这么久也没见出什么问题呀。如下所示:

    @Test
    public void test7() throws ParseException {
        String patterStr = "yyyy-MM-dd HH:mm:ss";
    
        // 模拟请求参数的时间字符串
        String dateStrParam = "2020-01-15 18:00:00";
    
        // 模拟服务端对此服务换转换为Date类型
        DateFormat dateFormat = new SimpleDateFormat(patterStr);
        System.out.println("格式化器用的时区是:" + dateFormat.getTimeZone().getID());
        Date date = dateFormat.parse(dateStrParam);
        System.out.println(date);
    }
    

    运行程序,输出:

    格式化器用的时区是:Asia/Shanghai
    Wed Jan 15 18:00:00 CST 2020
    

    看起来结果没问题。事实上,这是因为默认情况下你们交互双发就达成了契约:双方均使用的是北京时间(时区),既然是相同时区,所以互通有无不会有任何问题。不信你把你接口给海外用户调试试?

    对于格式化器来讲,虽然说编程过程中一般情况下我们并不需要给DateFormat设置时区(那就用默认时区呗)就可正常转换。但是作为高手的你必须清清楚楚,明明白白的知道这是由于交互双发默认有个相同时区的契约存在

    SimpleDateFormat格式化

    Java中对Date类型的输入输出/格式化,推荐使用DateFormat而非用其toString()方法。

    DateFormat是一个时间格式化器抽象类,SimpleDateFormat是其具体实现类,用于以语言环境敏感的方式格式化和解析日期。它允许格式化(日期→文本)、解析(文本→日期)和规范化。

    划重点:对语言环境敏感,也就是说对环境Locale、时区TimeZone都是敏感的。既然敏感,那就是可定制的

    对于一个格式化器来讲,模式(模版)是其关键因素,了解一下:

    日期/时间模式
    格式化的模式由指定的字符串组成,未加引号的大写/小写字母(A-Z a-z)代表特定模式,用来表示模式含义,若想原样输出可以用单引号''包起来,除了英文字母其它均不解释原样输出/匹配。下面是它规定的模式字母(其它字母原样输出):

    字母 含义 匹配类型 示例
    y Year 2020,20
    M Month July; Jul; 07
    d 月中的天数(俗称日,最大值31) Number 10
    H 小时(0-23) Number 0,23
    m 分钟(0-59) Number 30,59
    s 秒(0-59) Number 30,59
    --- --- --- yyyy-MM-dd HH:mm:ss(分隔符可以是任意字符,甚至汉字)
    Y 当前周所在的年份 Year 2020(不建议使用,周若跨年有坑)
    S 毫秒数(1-999) Number 999
    a am/pm Text PM
    z 时区 通用时区 Pacific Standard Time; PST; GMT-08:00
    Z 时区 RFC 822时区 -0800,+0800
    X 时区 ISO 8601时区 -08; -0800; -08:00
    G 年代 Text AD(公元)、BC(公元前)
    D 年中的天数(1-366) Number 360
    w 年中的周数(1-54) Number 27
    W 月中的周数(1-5) Number 3
    E 星期几名称 Text Tuesday; Tue
    u 星期几数字(1=Monday...) Number 1
    k 小时(1-24) Number 不建议使用
    K/h am/pm小时数字 Number 一般配合a一起使用

    这个表格里出现了一些“特殊”的匹配类型,做如下解释:

    • Text:格式化(Date -> String),如果模式字母的数目是4个或更多,则使用完整形式;否则,如果可能的话,使用简短或缩写形式。对于解析(String -> Date),这两种形式都一样,与模式字母的数量无关
    @Test
    public void test9() throws ParseException {
        String patternStr = "G GG GGGGG E EE EEEEE a aa aaaaa";
        Date currDate = new Date();
    
        System.out.println("↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓中文地区模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓");
        System.out.println("====================Date->String====================");
        DateFormat dateFormat = new SimpleDateFormat(patternStr, Locale.CHINA);
        System.out.println(dateFormat.format(currDate));
    
        System.out.println("====================String->Date====================");
        String dateStrParam = "公元 公元 公元 星期六 星期六 星期六 下午 下午 下午";
        System.out.println(dateFormat.parse(dateStrParam));
    
        System.out.println("↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓英文地区模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓");
        System.out.println("====================Date->String====================");
        dateFormat = new SimpleDateFormat(patternStr, Locale.US);
        System.out.println(dateFormat.format(currDate));
    
        System.out.println("====================String->Date====================");
        dateStrParam = "AD ad bC Sat SatUrday sunDay PM PM Am";
        System.out.println(dateFormat.parse(dateStrParam));
    }
    

    运行程序,输出:

    ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓中文地区模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    ====================Date->String====================
    公元 公元 公元 星期六 星期六 星期六 下午 下午 下午
    ====================String->Date====================
    Sat Jan 03 12:00:00 CST 1970
    ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓英文地区模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    ====================Date->String====================
    AD AD AD Sat Sat Saturday PM PM PM
    ====================String->Date====================
    Sun Jan 01 00:00:00 CST 1970
    

    观察打印结果,除了符合模式规则外,还能在String -> Date解析时总结出两点结论:

    1. 英文单词,不分区大小写。如SatUrday sunDay都是没问题,但是不能有拼写错误
    2. 若有多个part表示一个意思,那么last win。如Sat SatUrday sunDay最后一个生效

    对于Locale地域参数,因为中文不存在格式、缩写方面的特性,因此这些规则只对英文地域(如Locale.US生效)

    • Number:格式化(Date -> String),模式字母的数量是数字的【最小】数量,较短的数字被零填充到这个数量。对于解析(String -> Date),模式字母的数量将被忽略,除非需要分隔两个相邻的字段
    • Year:对于格式化和解析,如果模式字母的数量是4个或更多,则使用特定于日历的长格式。否则,使用日历特定的简短或缩写形式
    • Month:如果模式字母的数量是3个或更多,则被解释为文本;否则,它将被解释为一个数字。
    • 通用时区:如果该时区有名称,如Pacific Standard Time、PST、CST等那就用名称,否则就用GMT规则的字符串,如:GMT-08:00
    • RFC 822时区:遵循RFC 822格式,向下兼容通用时区(名称部分除外)
    • ISO 8601时区:对于格式化,如果与GMT的偏移值为0(也就是格林威治时间喽),则生成“Z”;如果模式字母的数量为1,则忽略小时的任何分数。例如,如果模式是“X”,时区是“GMT+05:30”,则生成“+05”。在进行解析时,“Z”被解析为UTC时区指示符。一般时区不被接受。如果模式字母的数量是4个或更多,在构造SimpleDateFormat或应用模式时抛出IllegalArgumentException。
      • 这个规则理解起来还是比较费劲的,在开发中一般不太建议使用此种模式。若要使用请务必本地做好测试

    SimpleDateFormat的使用很简单,重点是了解其规则模式。最后关于SimpleDateFormat的使用再强调这两点哈:

    1. SimpleDateFormat并非线程安全类,使用时请务必注意并发安全问题
    2. 若使用SimpleDateFormat去格式化成非本地区域(默认Locale)的话,那就必须在构造的时候就指定好,如Locale.US
    3. 对于Date类型的任何格式化、解析请统一使用SimpleDateFormat

    JSR 310类型

    曾经有个人做了个很有意思的投票,统计对Java API的不满意程度。最终Java Date/Calendar API斩获第二烂(第一烂是Java XML/DOM),体现出它烂的点较多,这里给你例举几项:

    1. 定义并不一致,在java.util和java.sql包中竟然都有Date类,而且呢对它进行格式化/解析类竟然又跑到java.text去了,精神分裂啊
    2. java.util.Date等类在建模日期的设计上行为不一致,缺陷明显。包括易变性、糟糕的偏移值、默认值、命名等等
    3. java.util.Date同时包含日期和时间,而其子类java.sql.Date却仅包含日期,这是什么神继承?

    @Test
    public void test10() {
        long currMillis = System.currentTimeMillis();
    
        java.util.Date date = new Date(currMillis);
        java.sql.Date sqlDate = new java.sql.Date(currMillis);
        java.sql.Time time = new Time(currMillis);
        java.sql.Timestamp timestamp = new Timestamp(currMillis);
    
        System.out.println("java.util.Date:" + date);
        System.out.println("java.sql.Date:" + sqlDate);
        System.out.println("java.sql.Time:" + time);
        System.out.println("java.sql.Timestamp:" + timestamp);
    }
    

    运行程序,输出:

    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的偏移量。

    值得提前强调,时区和偏移量在概念和实际作用上是有较大区别的,主要体现在:

    1. UTC偏移量仅仅记录了偏移的小时分钟而已,除此之外无任何其它信息。举个例子:+08:00的意思是比UTC时间早8小时,没有地理/时区含义,相应的-03:30代表的意思仅仅是比UTC时间晚3个半小时
    2. 时区是特定于地区而言的,它和地理上的地区(包括规则)强绑定在一起。比如整个中国都叫东八区,纽约在西五区等等

    中国没有夏令时,所有东八区对应的偏移量永远是+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:

    1. 最简单的ID类型:ZoneOffset,它由'Z'和以'+'或'-'开头的id组成。如:Z、+18:00、-18:00
    2. 另一种类型的ID是带有某种前缀形式的偏移样式ID,例如'GMT+2'或'UTC+01:00'。可识别的(合法的)前缀是'UTC', 'GMT'和'UT'
    3. 第三种类型是基于区域的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[,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
    UTC
    

    这里第一个参数传的前缀,可用值为:"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
    
    
    下一篇:没有了