2020-10 以jdk8的Date/Time API为例探讨时区相关的概念

本文主要探讨和时区相关的概念,并以jdk8的Date/Time API为例,明确我们在处理和时区相关的问题时,应当如何正确理解和使用已有的API,其他的编程语言和工具,应当也是可以触类旁通的。

提出问题

时间是我们最熟悉不过的概念,日常工作中也经常要处理和时间相关的业务需求,比如用户创建时间、最后更新时间等等,常用的编程语言一般都提供了成熟的工具类库和API,来协助我们实现时间的生成、转化、格式化显示等需求,因此大多数场景下时间的处理并不是一个复杂的问题。

但是时区正是其中比较复杂和容易出错的一类问题,这个复杂性主要来源于两个方面:一是和时区相关的概念纷繁复杂,常见的像如UTC时间、DST时间、GMT、CST、本地时间等等;另外一个原因是我们常见的应用不太会垮时区部署,因此也不太会关注时区的处理,一般都是用系统默认时区即可,因此一旦出现和时区相关的问题,往往感觉模棱两可。

虽然我们直接使用时区的场景不多,但是编写代码时常用的工具类库、数据库等系统,都隐含了对时区的处理,导致我们不经意之间一直在使用时区,如果不能对这些时区概念有正确的理解,就很容易触发一些隐含的问题。

本文将从一个最简单的时区转化问题出发,阐明处理时区问题时所涉及的概念。

  • 问题:欧冠决赛在北京时间2020年8月24日 03:00开始,那么比赛开始时,伦敦的当地时间是什么时候?

从日常经验来看,北京位于东八区,而伦敦位于零时区,北京时间应该比伦敦时间快8个小时,那真正的结果是这样吗?我们在代码中应当如何做这个时区的转化呢?

概念解读

分析时区问题时有一个最基本的准则,那就是:

  • 绝对时间 = 本地时间 & 时区偏移量 (AbsoluteTime = LocalDateTime & Offset)

具体含义是,本地时间和时区偏移量的信息组合可以描述一个绝对时间。

绝对时间、本地时间、时区偏移量的概念将在下面一一解读。

1.绝对时间(AbsoluteTime)

绝对时间可以理解为一个放之四海而皆准的时间,举个例子,UTC时间就是一个绝对时间,当我们记录一个时间为1970-01-01T00:00:00Z(UTC描述时间的标准格式)时,这个时间的定义是没有任何歧义的,它指向绝对时间线上的一个确定的时刻;从另一个角度说,在同一时刻,地球上的任何角落同时发生的事情,他们的UTC时间也一定是相同的,这就是绝对时间。

另外一个常见的绝对时间是Unix时间戳,它也指向绝对时间线上的一个确定的时刻,不受所在地的影响。

正是因为绝对时间对时间的描述不存在任何歧义,因此将时间转化为绝对时间来处理是非常可靠的,这也是我们在处理时区问题时的不二法门。

2.本地时间(LocalDateTime)

先举个本地时间的例子,上面的描述中“欧冠决赛在北京时间2020年8月24日 03:00开始“,其中的“北京时间2020年8月24日 03:00”是我们这里所说的本地时间吗?答案是否定的,这里的本地时间是指“2020年8月24日 03:00”,并不包含“北京时间”这个和时区相关的描述。本地时间仅仅是指的关于年月日、时分秒等信息的一个描述,并不包含所在的时区,这是一个非常容易混淆的地方。再次明确一遍,我们确实是在某一个时区下得到了“本地时间”,但是我们得到的“本地时间”本身是不包含时区的,这是理解时区问题时非常关键的一点。

为了进一步区分绝对时间和本地时间的概念,我们对常见的时间类型做些归类。

通用概念 java
绝对时间 UTC时间,Unix时间戳,1970-01-01T00:00:00Z Instant ZonedDateTime OffsetDateTime
本地时间 2020年8月24日 03:00 LocalDateTime

3.时区偏移量(Offset)

全球分为24个时区,每个时区和零时区相差了数个小时,也就是这里所说的时区偏移量Offset,比如东八区和零时区有+08:00的偏移量。还是回到上面的例子,“北京时间2020年8月24日 03:00”本质上是一个绝对时间,表示成UTC时间是2020-08-24T03:00:00+08:00,其中的2020-08-24T03:00:00是本地时间,而+08:00表示的是时区偏移量。UTC的表示方法正印证了上面所阐述的准则:绝对时间=本地时间&时区偏移量。

时区偏移量的概念比较简单,但是计算却比较复杂,其计算方式是:

  • 时区偏移量 = 地区 & 规则 (Offset = Zone & Rules)

这里的规则(Rules)可能是一个变化的值,如果我们单纯地认为中国的时区偏移量是8个小时,就出错了,事实是,中国采用的不一定总是东八区时间,也可能是东九区时间,出现这个情况因为夏令时的存在(夏令时即DST时间,1992年之后中国已经没有再实行过夏令时了,所以大家对这个概念并不熟悉,这是一个比较有意思的时间规定,感兴趣的同学可以参看文末附录),当实行夏令时的时候,中国标准时间的时区偏移量就是+09:00。因此,一个地区的时区偏移量是多少,是由当地的政策决定的,可能会随着季节而发生变化,这就是上面所说的“规则”(Rules)。

好在,这些复杂的规则一般都被我们的API支持了,但是我们必须意识到的一点是,“Asia/Shanghai”和“+08:00”是不同的时区描述,“Asia/Shanghai”只是描述了地区,而“+08:00”描述了准确的时区偏移量,不能将它们看作等价,比如在实行夏令时的时候,这两种描述得到的本地时间就是不同的。

实际上,时区还有更多复杂的规定,我们不再去深究,目前已知的这些足够我们处理日常的需求了。

Java jdk8的Date/Time API介绍

jdk8之前的Date/Time API被吐槽已久,如果你不幸是从jdk8之前的Date Calendar类开始了解时间类的处理,很可能会一头雾水,其用法和缺点我们这里不再探讨,强烈推荐直接使用jdk8提供了新的Date/Time API。

1.Date/Time API常用类的介绍

jdk8 Date/Time API中常用的类完全符合我们上述提到的概念:

  • 绝对时间:java.time.Instant, java.time.OffsetDateTime, java.time.ZonedDateTime

  • 本地时间:java.time.LocalDateTime

  • 时区偏移量:java.time.ZoneOffset

1.1 java类库中的绝对时间

Instant本质上是一个Unix时间戳,Unix时间戳是可以表示绝对时间的,只不过Instant内部用了两个long的对象分别表示秒和纳秒,从而将精度支持到了纳秒级别。

OffsetDateTime是一个包含了时区(offset)信息的时间类型,内部保存了LocalDateTime(本地时间)和offset,由于本地时间和时区信息都是完整的,所以OffsetDateTime是一个绝对时间。

ZonedDateTime是一个包含了地区(zone)信息的时间类型,内部保存了LocalDateTime(本地时间)和zone,由上述两个值可以计算出时区(offset),因此也是一个绝对时间。

这里有两个问题可以探讨:

  • 问题一:这三个类都是绝对时间,那么他们是否可以互相转化呢?

  • 问题二:鉴于ZonedDateTime和OffsetDateTime定义是很接近的,那么使用时该如何选择呢?

问题一我们在这里直接回答:答案是只能进行有限的转化,因为三者所包含的信息量是不同的,如图。

image-20201227203413024

从外围向内部的转化是支持的,而从内部向外围的转化必需要补充一些额外的信息(当然这些信息可能是隐含补入的,如本机所在的时区);否则只能进行一些特殊的转化,比如将OffsetDateTime直接转成ZonedDateTime的API文档说明了这一点:This creates the simplest possible ZonedDateTime using the offset as the zone ID.

问题二我们在后面单独讨论。

1.2 java类库中的本地时间

java中对本地时间的描述非常简单明确,LocalDateTime类,内部包含了LocalDate和LocalTime两个成员变量,其中LocalDate表示年月日的日期组合,而LocalTime表示时分秒纳秒的时间组合,本质上,LocalDateTime只是一个关于日期和时间的描述,没有任何时区信息,符合我们对本地时间的定义。

当我们描述的只是一个关于日期或时间的组合,而不是一个绝对时间的时候,直接使用本地时间即可,比如记录一个人的生日。

1.3 java类库中的时区偏移量

ZoneOffset对应了一个确定的时区偏移量。至于上面提到的

  • 时区偏移量 = 地区 & 规则(Offset = Zone & Rules)

其中地区(Zone)在java中对应着ZoneRegion类,Rules对应ZoneRules类。同时,为了简化用户的使用,Java对此做了进一步抽象,设计了ZoneId类用来统筹ZoneOffset和ZoneRegion,通过ZoneId.of("Asia/Shanghai")可以直接生成ZoneRegion,而ZoneId.of("+8")可以生成ZoneOffset。

其他使用具体API的最佳实践,java提供的官方文档十分明确,且网络上随处可查,本文不再赘述,相信明确了上述概念之后,再见到这些繁多的API时已经能做到心中有数。

2.使用ZonedDateTime还是OffsetDateTime?

这个问题OffsetDateTime的官方文档中的解答是

It is intended that {@code ZonedDateTime} or {@code Instant} is used to model data in simpler applications. 
This class(OffsetDateTime) may be used when modeling date-time concepts in more detail, or when communicating to a database or in a network protocol.

具体来看,其中modeling date-time concepts in more detail应当是针对Instant来说的,OffsetDateTime比Instant信息更丰富(多了时区信息),而communicating to a database or in a network protocol是针对ZonedDateTime来说的,也就是当我们使用数据库以及网络通信时要使用OffsetDateTime,那么原因是什么呢?

因为ZonedDateTime存储的是地区(Zone)信息,再通过Rules计算出具体的offset,而问题就在于,Rules是一个政策性的规则,是不稳定的,存在不同的版本,不同客户端使用的Rules也可能是不同的,尤其是当我们处理一个未来时间的时候这个问题更加突出,毕竟未来的政策又有谁能预测呢?这就导致我们发送的时间和其他应用解读到的时间可能会产生差异,我们保存在数据库中的时间,当未来读取出来时也可能做出不同的解读,所以这种这种情况下,推荐使用OffsetDateTime,因为直接传递和保存确定的offset是不会产生歧义的。

那么ZonedDateTime在什么情况下使用呢?当我们必须要使用夏令时或其他地区性规则的时候,应当转化为ZonedDateTime来使用。

问题分析和解决

那么我们回到最开始的问题:

  • 问题:欧冠决赛在北京时间2020年8月24日 03:00开始,那么比赛开始时,伦敦的当地时间是什么时候?

在明确了上述概念的基础上,我们可以对问题做进一步分析,因为比赛开始的绝对时间是相同的,所以有:

欧冠决赛开始的绝对时间 = 北京的本地时间 & 北京的时区偏移量 = 伦敦的本地时间 & 伦敦的时区偏移量

因此只要确定北京和伦敦的时区偏移量,就可以求得伦敦的本地时间了。分析到这里,我们已经可以通过Java提供的API来解决上述问题了,Java工具类内部会进行时区偏移量的计算,不需要我们亲自处理。

1.Java jdk8的Date/Time API解决时区转化问题

// Step#1:北京地区
// 地区:
ZoneId zoneOfBeijing = ZoneId.of("Asia/Shanghai");
// 本地时间:2020-08-24 03:00
LocalDateTime localDateTimeOfBeijing = LocalDateTime.of(2020, 8, 24, 3, 0);
// 绝对时间:ZonedDateTime表示
ZonedDateTime zonedDateTimeOfBeijing = ZonedDateTime.of(localDateTimeOfBeijing, zoneOfBeijing);

// Step#2:绝对时间转化成Instant类型(时间戳类型)
Instant absoluteInstant = zonedDateTimeOfBeijing.toInstant();

// Step#3:伦敦地区
// 地区:
ZoneId zoneOfLondon = ZoneId.of("Europe/London");
// 绝对时间:将Instant绝对时间转化成伦敦地区的ZonedDateTime
ZonedDateTime zonedDateTimeOfLondon = ZonedDateTime.ofInstant(absoluteInstant, zoneOfLondon);
// 本地时间:
LocalDateTime localDateTimeOfLondon = zonedDateTimeOfLondon.toLocalDateTime();

System.out.println("北京本地时间:" + localDateTimeOfBeijing);
System.out.println("北京时区偏移量:" + zonedDateTimeOfBeijing.getOffset());
System.out.println("伦敦本地时间:" + localDateTimeOfLondon);
System.out.println("伦敦时区偏移量:" + zonedDateTimeOfLondon.getOffset());

/*
北京本地时间:2020-08-24T03:00
北京时区偏移量:+08:00
伦敦本地时间:2020-08-23T20:00
伦敦时区偏移量:+01:00
 */

最终得出的结果,伦敦的本地时间是2020年8月23日 20:00,北京时间比伦敦时间快了7个小时,这是因为伦敦当时正在实行夏令时,时区偏移量是+01:00。

2.普通的计算方法解决时区转化问题

如果抛开API,用普通的算数方法,我们需要进一步明确上述关系中的&该如何具体化,直接给出一种方法,我们利用零时区时间表示的绝对时间来做一个过渡,已知满足:

  • 零时区的时间(绝对时间) = 本地时间 - 本地时区偏移量

可以得到:

北京的本地时间 - 北京的时区偏移量 = 伦敦的本地时间 - 伦敦的时区偏移量

即:

伦敦的本地时间 = 北京的本地时间 - (北京的时区偏移量 - 伦敦的时区偏移量)

对于时区偏移量我们直接给出结论:北京时间2020年8月24日 03:00,北京的时区偏移量为+08:00,而伦敦的时区偏移量为+01:00(伦敦当时正在实行夏令时)。

因此:

伦敦的本地时间 = 2020年8月24日 03:00 - (+08:00 - +01:00) = 2020年8月23日 20:00

结论

本文看似是上绕了一个很大的圈子,解决了一个非常简单的时区转化问题,过程中唯一要注意的坑不过是一个夏令时而已,是但在分析这个问题的过程中,把相关的概念做了详细的介绍,我们也看到了jdk8 Date/Time的API设计在概念上是非常清晰的,理解这些概念也便于我们正确使用API,避免做一些想当然的转化。

在实际应用中,和时区相关的还有一些更重要的问题,比如在数据库中如何保存时间,以及可能存在哪些隐含的问题等,这些问题的分析也要建立在充分理解上述概念的基础上,篇幅所限,将在后面的文章中做进一步探讨。

随着人认知的不断加深、测量方法的不断提升,时间的表示规范也在不断演化,也引入了众多的和时间相关的标准,但是不管怎么样,这些标准的目标都是为了规范时间的表示,使我们从类似于2020-02-02 20:20:20Z这样的描述下,能定位到一个公认的、确定的时刻。下面就主要介绍一下当前使用的主要时间标准,主要介绍的概念是UTC、GMT、DST、CST,其他的仅做了解即可。

当前,使用的最广泛的世界时间标准是世界协调时(Coordinated Universal Time),简称UTC,这个简称是由英文缩写(CUT)和法文缩写(TUC)妥协而来。

UTC有个比较特别的地方,存在一个闰秒的机制,闰秒是为了将UTC时间和世界时(UT,下面会介绍世界时)对齐,确切的说是保证UTC和UT1(世界时的一种形式)相差不超过0.9s。那么UTC和UT1为什么会出现差距呢?简单说是因为二者对1s的计量方式不同,UTC采用的是原子时(TAI)的1秒(这也是当前对于1秒的标准定义),而UT1的1s和地球的自转速度有关,观测发现,相对于原子时,地球的自转速度是逐渐变慢的,导致UT1会逐渐慢于UTC,因此UTC每隔一段时间(几年)就要加1s等一等UT1,上一次闰秒就出现在2015年,当时我们可以看到2015年6月30日23:59:60这样的时间。

另外一个问题,为什么UTC一定要和UT1对齐呢,直接使用UTC时间不就可以了?我认为,这是因为UTC是要服从于历法的,UTC仍旧是采用了年月日时分秒这样的格式,这显然是历法规定的格式,而太阳东升西落一次就是一天,四季轮回一次就是一年,这是历法的要求,也符合人类的认知习惯,UTC只是一套按照历法的格式描述时间的标准而已,不应当打破历法本身。因此,UTC引入了不太优雅的闰秒机制来修正和历法之间的冲突,当然也有一些声音希望能有一套不需要闰秒的时间标准取代UTC,现在仍然存在着争议。

上面提到了世界时(UT)和原子时(TAI)只需要简单理解就可以,因为日常生活中我们不会直接使用到它们。世界时有多个形式,UT0、UT1等等,分别考虑了不同的因素,但他们最终的结果和UTC都相差不大,我们只要知道世界时主要是和地球自转相关的时间标准就可以了;而原子时(TAI)就是原子钟定义的时间,实际上,出于某些原因,原子时当前和UTC时间相差30多秒,所以我们日常生活中更不太可能使用到它。

另外,不得不提的两个时间是GMT(Greenwich Mean Time)和DST(daylight saving time)。

GMT时间和UTC时间很容易混淆,二者从时间表示的结果上来看是一样的,所以也经常看到UTC/GMT时间这样的说法,但是二者其实是不一样的概念。从历史上来说,UTC出现之前,GMT时间就是世界标准时间,后来这个地位被UTC取代了,GMT和UTC的差别主要在于,GMT实际上是指的一个时区,而UTC是一套世界标准,不指代任何时区,因此,我们可以讨论英国当前是处在GMT时区还是BST时区,却不会说是处在UTC时区(参考:https://www.timeanddate.com/time/gmt-utc-time.html)。但是在一般的理解上,把GMT和UTC时间看作相同的时间,也是没什么问题的。

DST即夏令时,是一套相对比较复杂的时间标准,大体上是在夏季时拨快1小时的时钟,通过影响人的作息安排,以达到节省照明用电的目的。我们之所以对夏令时比较陌生,是因为中国1992年之后就没有再使用过夏令时了,但是世界上仍然还有70多个国家在使用夏令时,以欧洲和北美国家为主。

另外我们经常见到的一个时间是CST时间,比如我们在命令行中执行date命令,可以看到

~ date
Mon Dec 28 23:21:19 CST 2020

CST一般表示的是中国标准时间(China Standard Time),是一个时区的缩写,和GMT时区、BST时区等属于同一类,但是CST作为时区是存在歧义的,可以指代多个时区,美国中部时间、澳大利亚中部时间、古巴标准时间、中国的标准时间的缩写都是CST。编写代码时用CST表示时区可能会引发错误,不建议使用。

上述归纳有作者个人理解,如有问题欢迎指正。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容