概述

当谈论时间时,我们其实是在谈论两件事:时间本身和时间的表示。

正常情况下,时间的流动是单调的,日常需要表达的往往是具体某个时间点(瞬时时间)、以某个或某两个时间点为分界的时间段。

从古至今,人们根据天气周期性变化发明了年;根据月相变化发明了月;根据昼夜交替循环发明了天;时分秒这种在一天以内的时间单位则是由日晷、水钟、机械钟等的发明才得以出现。年月日有强周期性的自然现象,很好区分,因此各种历法系统基本都有年月日,区别仅在于年的划分,称为纪元,比如公历划分为公元和公元前、日本历的民治/令和。天以内的划分则完全看各文明发展程度,比如古人用日晷天分成时辰、用漏刻将时辰分为刻、用水钟将刻分为分,于是农历的最小单位就是分;日本历在明治维新后直接引入西方的时分秒机制。

历法系统是各文化表示时间的直接工具,如下是几种历法系统针对同一时间的描述,其中公历是国际标准,能够与其他历法系统能够相互转换。

  • 公历:2023 年 10 月 23 日 15 点 30 分 24 秒。以公历为基础,加上对日期时间的格式限制、时区规定、时间间隔表示,构成了 ISO8601 标准

  • 中国农历:癸卯年九月廿九日午时三刻六分

  • 民国历:民国壹佰壹拾贰年玖月贰拾玖日 午時参刻陸分

  • 日本历:令和5年10月23日 午後3時30分24秒

  • 泰国佛历:佛统二五六六年 十月二十三日 下午三时三十分二十四秒

时间的表示是政治相关的:出于能源和经济方面考虑,部分地区政府会决定在某些时间段执行夏令时,这意味着对该地区的时间表示,不同时期的规则会不一样。

时间的表示通常是本地的:时区的划分就是为了让每个地方的人们都在早上七八点看到太阳升起,这个早上七八点是当地时间(即本地时间)。

  • 时区

    地球被划分成24个区域,每个区域时间统一,区域之间时间相差 1 小时;设立时区是为了让世界各地的人们针对同一太阳位置具有尽量一致的时间体验。

  • 夏令时

    夏天昼长夜短,天亮的早,还是九点上班岂不浪费了九点之前早就明亮的时间?

    那就把时间拨快一个小时,社畜们就能早一个小时起床去上班,下班的时候天还亮着,可以去消费促进经济。

java.time 思路

java.time 包整个实现逻辑,都围绕以下三个点进行

  • 时间是单调流动不可逆的

    使用 Instant 表示时间线上的某个点。

  • 时间点需要对应到历法系统

    国际通用标准是 ISO 标准,所以其他历法系统都能够与 ISO 标准互转,因此国际标准被完整实现,其他历法系统基于国际标准进行转换。

    国际标准的实现:根据有无时区、时区和时间偏移量关系等,使用 ZoneRulesZoneIdZoneOffsetZoneRegion 表达时区规则;使用 LocaDateTimeOffsetDateTimeZonedDateTime 表示具体的时间。

    以日为精度实现了伊斯兰教历(HijraChronology)、中华民国历(MinguoChronology)、日本历(JapaneseChronology)、泰国佛历(ThaiBuddhistChronology

  • 无论时间点还是历法系统,都需要表示成人类可读的文本

    DateTimeFormatter 用于完成该任务

API 分析

分析 java.time 包下的 API

时间抽象

时间有哪些特性呢?时间之间需要可比、可计算,一个时间加上一定的量可以得到另一个时间。于是

  • 时间对象的抽象:Temporal,定义了时间的行为:可加可减可比较
  • 时间字段:TemporalField,时间的表示最终会被划分到年、月、日、时、分、秒,这是时间字段,时间字段的特点是其取值有范围有限,比如月的取值为 1-31,时为 1-60。
  • 时间单位:TemporalUnit,还是年、月、日、时、分、秒,时间单位限制了该单位的持续时长,如天为 86400秒。
  • 时间量:TemporalAmount,一个时间加上一个时间量可得到另一个时间,比如今天加上一天就是明天
  • 其它接口非必须,但有了之后操作更方面,个人认为可忽略,这里为了文章完整度列一下
    • 时间查询操作:TemporalQueryjava.time 包将时间查询操作抽象成了函数式接口,并预定义了一批通用实现,如查询时区等
    • 时间调整操作:TemporalAdjuster,也是一个函数式接口,用于调整某个 TemporalField 的值

temporal 包仅定义了时间行为和基础属性,未涉及时区的定义。

时区抽象

  • 全球任意地区的时间都可表示为:GMT 或 UTC 时间 + 时区偏移量计算规则,后者用 ZoneRules 表示。

  • 全球分为 24 个时区,因此可以只以偏移量如 +8 作为最终结果,这种直接以偏移量常量的方式,用 ZoneOffset 表示

  • 然而世界时区的表示不仅仅是偏移常量,还按照政治和地理因素划分了很多区域,称作时区标识符,一般以城市命名,如 Asia/Shanghai,时区标识符不仅包含时区偏移量,它还可以包含夏令时等更多规则,用 ZoneRegion 来表示,其内部持有 ZoneRules 对象

    时区标识符对应的规则,放在类路径下的 tzdb.dat 文件下,时间相关类加载时会读取。

  • +8、Asia/Shanghai 这些文本形式,可以统称id,使用 ZoneId 进行抽象。日常使用最多的也是它,由它根据我们给定的时区文本决定创建 ZoneOffset 还是 ZoneRegion

    1
    2
    println(ZoneId.of("+8").javaClass) // 输出: class java.time.ZoneOffset
    println(ZoneId.of("Asia/Shanghai").javaClass) // 输出: class java.time.ZoneRegion

历法系统

实现历法系统就是实现该历法系统上下文下时间的表示,如何创建、计算它们。主要构成如下

  • Chronology:核心接口,定义历法系统的行为:创建、计算历法系统的事件对象
  • ChronoLocalDate:历法系统的日期对象,典型的实现类如 LocalDate
  • ChronoLocalDateTime:历法系统的日期+时间对象,典型的实现类如 LocalDateTime
  • ChronoZonedDateTime:历法系统带有时区的日期+时间对象,典型的实现类如 ZonedDateTime
  • ChronoZonePeriod:在历法系统下的日期间隔,典型的实现类如 Period
  • Era:历法系统的纪元。公历是公元前、公元后;日本历是民治、令和等

几点说明

  • 最通用的国际标准也是一种历法系统,实现是 IsoChronology,其日期对象是 LocalDate、时间对象是 LocalDateTime
  • java 实现的历法系统仅到天,天以内均使用 LocalTime 表示。即使是日期对象如 JapaneseDate,其内部也是由 LocalDate 表示并换算而来。这是因为所有历法系统都能由 ISO 转换。

使用举例:

1
2
3
println(JapaneseDate.from(OffsetDateTime.parse("2021-10-11T10:28:00+08:00")))
// 输出: Japanese Reiwa 3-10-11
// 即 令和 3 年 10 月 11 日,这里的令和是日本历的纪元,即 Era

小知识:2019 年 5 月 1 日,日本新天皇继位,年号更名为 “令和”,此前为 “平成”

时间表示

时间点+时区规则,就能完整定义和表示时间。时间点就是 Intant,不要时区的就是本地时间 LocalDateTime,加上时区就是完整的时间 OffsetDateTimeZonedDateTime

  • Instant

    瞬时时间,是其它时间表示的来源,存储了 UTC 时间 1970-01-01:00:00:00 至今的纳秒数。

    Instant 也是几个时间类中唯一一个能够直接获取 epoch 毫秒数的类,如 OffsetDateTime 之类只能获取 epoch 秒数,这与他们的概念定位有关,作为年月日时分秒的概念实现,能够获取的最小单位当然是秒。

  • LocalDateTime

    本地时间,由 LocdalDateLocalTime 组成

    • LocalDate:本地日期,携带三个属性 year、month、day
    • LocalTime:本地时间,携带四个属性 hour、minute、second、nanoSecond

    可见本地时间的计算和表示就是针对这七个属性之间的计算和表示

  • OffsetDateTime

    带固定偏移量的时间,由 LocalDateTime ZoneOffset 组成。主要应对 2023-10-10T12:00:00+08:00 这种格式的时间。该时间的加减乘除就直接是 LocalDateTime 的加减乘除,只有转换为 Instant 这种绝对时间量时才需要偏移量参与计算

  • ZonedDateTime

    带时区标识符的时间,由 LocalDateTimeZoneOffsetZoneRegion组成,主要应对 2023-10-10T12:00:00+08:00[Asia/Shanghai] 这种格式的时间。

2023-10-10T12:00:00+08:002023-10-10T12:00:00+08:00[Asia/Shanghai] 有什么不一样?

前者偏移量永远是 +8 ,但不知道在哪个时区;而后者在 +8 的基础上可能还有与 Asia/Shanghai 相关的其他偏移量规则,比如 1991 年之前我国实行夏令时,那么在这些年份的夏季偏移量就会是 +9,而不是 +8,同时明确了这是在 Asia/Shanghai 时区。

注意:我们通常所说的时间表示,都是在 ISO 标准历法系统语境下进行的,本小节也是如此。但需要注意的是,历法系统只是 Java 中时间表示的基础而非全部,并不能认为下面描述的时间相关类就是对 ISO 历法系统的直接实现。LocalDateTime 才是对历法系统的直接实现,而 InstantOffsetDateTime 则是根据实际需要,以 LocalDateTime 为基础构建的类。

时间段表示

除了时间点的表示,时间段也需要表示:PeriodDuration

  • Period

    专门表示在日历系统上的时间跨度。如一个月、一年零两天。有几个注意事项

    • 它是有字符串表示的:P12Y3M5D 表示 12 年 3 个月 5 天。Period.parse("P12Y3M5D") 可以直接解析。

    • 它只表示在年、月、日这三个字段上的跨度,不管年、月、日之间的进制关系。

      P12Y3M5D 这个 Period 上加 30 天,将得到 P12Y3M35D,天不会进位到月,因为天和月之间的关系不固定,无法知道当前该进多少。而使用时只需要在时间对象对应的字段上应用对应的值即可。

    • 它能准确表达一个月之后这样的场景。比如 2021-10-01 的一个月后是 2021-11-01,增加了 31 天,2021-09-01 的一个月后是 2021-10-01,增加了30 天。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    val p = Period.parse("P12Y3M5D")
    val p2 = p.plusDays(30)
    println(p) // P12Y3M5D
    println(p2)// P12Y3M35D

    val oneMonth = Period.parse("P1M")
    val dt1 = LocalDate.parse("2021-10-01")
    val dt2 = LocalDate.parse("2021-11-01")
    println(dt1.plus(oneMonth)) // 2021-11-01
    println(dt2.plus(oneMonth)) // 2021-12-01
  • Duration

    专门表示准确的时间跨度,持有 seconds 和nanos 两个属性,时间长度均转换为秒和纳秒存储,能够准确表示两个时间的具体间隔。有几个注意事项

    • 因为天和月不确定的关系,Duration 仅能被换算为纳秒、毫秒、秒、分、时、天,无法换算成月。

其它

还有两个方面虽然不经常直接用到,但了解一下也不错

  • Clock

    Instant 表示时间点, Clock 用于创建 Instant,顾名思义,Clock 就是时钟。Instant.now() 内部调用了 Clock 生成 Instant,主要实现类是SystemClock:创建时钟时通过 System.currentTimeMillis()获取并存储为基础时间戳,在创建 Instant 时,调用 VM.getNanoTimeAdjustment(baseSeconds) 获取当前时间偏移量,加上基础时间戳,即可得到纳秒级别的当前时间。所以,Clock 并非自己在 count,而是调用了系统函数获取当前时间戳。

  • DateTimeFormatter

    时间格式化,介绍它的文章很多。这里我们不说别的,只需要记住 ISO8601 的三种默认格式:不带时区、仅带时区偏移量、带时区偏移量和时区。如果使用时都能够采用默认格式传输,代码中就几乎无需用到 DateTimeFormatter

    1
    2
    3
    4
    5
    6
    // 本地时间
    println(LocalDateTime.parse("2023-10-10T12:00:00"))
    // 带固定偏移量
    println(OffsetDateTime.parse("2023-10-10T12:00:00+08:00"))
    // 带时区
    println(ZonedDateTime.parse("2023-10-10T12:00:00+08:00[Asia/Shanghai]"))

深入研究

ZoneRules 如何计算偏移量

首先介绍一个网站: https://tzexplore.org ,可查看任意时区的偏移信息,它是对 tzdb 数据库的可视化。Asia/Shanghai 如下

image-20231019122325259

  • 可得到两个信息
    • 该时区固定偏移量为 +08:00
    • 在历史上有不同的转换规则
      • 1991-09-15 01:00:00 至今,无额外转换规则
      • 1991-04-14 03:00:00 至 1991-09-15 01:59:59,实行夏令时,转换规则为,偏移量加一,最终为 +09:00
      • 1990-09-16 01:00:00 至 1991-04-14 01:59:59,不执行夏令时,无转换规则,最终偏移量还是 +08:00
      • … …
      • 1901年之前的规则未定义,要增加一个额外的六分钟偏移量
  • 从中可以看到,Asia/Shanghai 这个时区只受时区偏移量和历史夏令时的影响。算是比较简单。

作为对比,我们再看一个比较复杂的时区 Pacific/Auckland ,它处于+12区,现在还在实行夏令时,1947 年以前的夏令时偏移量是 30 分钟而不是一小时

image-20231019124107090

image-20231019124207697

有了这些前置知识,就容易理解 ZoneRules 的逻辑了。首先来看 ZoneRules 如何存储这些转换规则,主要通过如下几个属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* The transitions between standard offsets (epoch seconds), sorted.
*/
private final long[] standardTransitions;
/**
* The standard offsets.
*/
private final ZoneOffset[] standardOffsets;
/**
* The transitions between instants (epoch seconds), sorted.
*/
private final long[] savingsInstantTransitions;
/**
* The transitions between local date-times, sorted.
* This is a paired array, where the first entry is the start of the transition
* and the second entry is the end of the transition.
*/
private final LocalDateTime[] savingsLocalTransitions;
/**
* The wall offsets.
*/
private final ZoneOffset[] wallOffsets;
/**
* The last rule.
*/
private final ZoneOffsetTransitionRule[] lastRules;
  • standardTransitions:标准时间转变列表,记录了该地区标准偏移量的转变瞬时时间,该时间是分界点。

  • standardOffsets:标准时间偏移量列表,表示与 UTC 的偏移量,与 standardTransitions 对应。

    Asia/Shanghai 为例 -2177481943(1900年)之前的标准偏移量为 +08:05:43,1900 年之后的标准偏移量为 +08:00

    image-20231019151140067

    Pacific/Auckland 为例,-3192435544(1868 年) 之前标准偏移量为 +11:39:04,-3192435544(1868 年) 到 -757425600(1945 年)之间的标准偏移量为 +11:30,-757425600(1945 年) 之后的标准偏移量为 +12:00

    image-20231019151736273

  • savingsInstantTransitions:夏令时转变列表,记录了标准时间和夏令时相互转换的顺势时间点

  • savingsLocalransitionssavingsInstantTransitionsLocalDateTime 表示,二者等效

  • wallOffsets: 时区墙偏移量列表,表示与 UTC 的偏移量,与夏令时转变列表对应。

    savingsInstantTransitionswallOffsets 数量比较大,可以打印出来方便查看,通过如下代码打印 Asia/Shanghai 的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    ZoneId.of("Pacific/Auckland").rules.transitions.forEach { println(it) }
    // 打印结果如下

    // 1901-01-01 偏移量从 +08:05:43 变为 +08:00,由于偏移量减小,该时刻会重复(overlap)
    Transition[Overlap at 1901-01-01T00:00+08:05:43 to +08:00]
    // 1919-04-13 偏移量从 +08:00 变为 +09:00,由于偏移量增加了,因此该时刻会出现跳跃(gap),1919-04-13T00:00 直接变成 1919-04-13T01:00
    Transition[Gap at 1919-04-13T00:00+08:00 to +09:00]
    // 下面的解读类似
    Transition[Overlap at 1919-10-01T00:00+09:00 to +08:00]
    Transition[Gap at 1940-06-01T00:00+08:00 to +09:00]
    Transition[Overlap at 1940-10-13T00:00+09:00 to +08:00]
    Transition[Gap at 1941-03-15T00:00+08:00 to +09:00]
    Transition[Overlap at 1941-11-02T00:00+09:00 to +08:00]
    Transition[Gap at 1942-01-31T00:00+08:00 to +09:00]
    Transition[Overlap at 1945-09-02T00:00+09:00 to +08:00]
    Transition[Gap at 1946-05-15T00:00+08:00 to +09:00]
    Transition[Overlap at 1946-10-01T00:00+09:00 to +08:00]
    Transition[Gap at 1947-04-15T00:00+08:00 to +09:00]
    Transition[Overlap at 1947-11-01T00:00+09:00 to +08:00]
    Transition[Gap at 1948-05-01T00:00+08:00 to +09:00]
    Transition[Overlap at 1948-10-01T00:00+09:00 to +08:00]
    Transition[Gap at 1949-05-01T00:00+08:00 to +09:00]
    Transition[Overlap at 1949-05-28T00:00+09:00 to +08:00]
    Transition[Gap at 1986-05-04T02:00+08:00 to +09:00]
    Transition[Overlap at 1986-09-14T02:00+09:00 to +08:00]
    Transition[Gap at 1987-04-12T02:00+08:00 to +09:00]
    Transition[Overlap at 1987-09-13T02:00+09:00 to +08:00]
    Transition[Gap at 1988-04-17T02:00+08:00 to +09:00]
    Transition[Overlap at 1988-09-11T02:00+09:00 to +08:00]
    Transition[Gap at 1989-04-16T02:00+08:00 to +09:00]
    Transition[Overlap at 1989-09-17T02:00+09:00 to +08:00]
    Transition[Gap at 1990-04-15T02:00+08:00 to +09:00]
    Transition[Overlap at 1990-09-16T02:00+09:00 to +08:00]
    Transition[Gap at 1991-04-14T02:00+08:00 to +09:00]
    Transition[Overlap at 1991-09-15T02:00+09:00 to +08:00]
  • lastRules:最新规则。上面几个字段记录的都是历史规则,该字段记录最新规则;如果有夏令时,则该字段记录夏令时规则,如果没有,则该字段为空

    同样可以打印来看,Asia/Shanghai 如今未应用夏令时所以不会有 lastRules,我们来看 Pacific/Auckland

    1
    2
    3
    4
    5
    6
    7
    ZoneId.of("Pacific/Auckland").rules.transitionRules.forEach{ println(it) }

    // 结果如下
    // 在四月一号之后的第一个周天的凌晨两点,偏移量从 +13:00 设置为 +12:00
    TransitionRule[Overlap +13:00 to +12:00, SUNDAY on or after APRIL 1 at 02:00 STANDARD, standard offset +12:00]
    // 在九月二十四号之后的第一个周天的凌晨两点,偏移量从 +12:00 拨回 +13:00
    TransitionRule[Gap +12:00 to +13:00, SUNDAY on or after SEPTEMBER 24 at 02:00 STANDARD, standard offset +12:00]

在不看代码的情况下,如果我们自己根据上述规则来判定某一天的偏移量,我想我大概会这么判断

  • 首先判断当前时间是否在 savingsInstantTransitions 指定的范围,如果在,则找到在哪个区间,直接取该区间的偏移量
  • 如果不在,且时间位于其范围前方,则根据 standardTransitions 确定处在哪个偏移量的时间范围,直接取即可
  • 如果不在,且时间位于其范围后方,则看是否存在 lastRules,如果不存在,则直接应用 standardTransitions 指定范围的偏移量
  • 如果存在,则应用 lastRules 指定的规则

ZoneRules 的实现位于java.time.zone.ZoneRules#getOffsetInfo 这里就不在详述,基本思路和上面一致。

GMT 和 UTC

简单总结

  • GMT 之规定:全世界以格林尼治天文台平太阳时(UT1)为基准,每隔 15° 划分一个时区,时区的时间为格林尼治时间加上偏移量
  • UTC 之规定
    • 在 GMT 的基础上做的修改
      • 全世界依旧以格林尼治天文台的时间为基准,每隔 15° 划分一个时区,时区的时间计算方式不变
      • 但不再取 平太阳时(UT1),而是由官方机构提供以 原子钟计时 (TAI 时间)为基础的时间。后者比前者更加精准稳定。
    • 增加的内容
      • 为时区编号,0 时区编码为 Z,其它时区以数字编号(GMT 是没有规定这一项的)
      • 闰秒:为了让 UTC 时间跟上 UT1,需要在二者相差过大时候手动调整一秒,称为闰秒。

小知识:GMT 在如今的很多语境下和 UTC+0 一样,表示零时区所在地的本地时间。

UT1

上文提到的 GMT 采用的 UT1,有时也直接称为 UT,即世界时。是基于天体观察计算出来的时间,是能够完全符合天文学的世界标准时间。

本来想探究 UT1 的详细原理以及 GMT 时代人们是如何在 UT1 下维护和校准生产环境的时间,奈何看得云里雾里,索性放弃。

UT 家族还有 UT0 和 UT2 ,这里一并略过。

相关链接:https://en.wikipedia.org/wiki/Universal_Time

关于闰秒

时间必须满足两个条件:走时精准平均、天文学强关联。

UT1 是根据实际天文观测得出的时间,符合天文学,但时间精度和平均性都不好,不利于生产中使用;原子钟精度高,平均性好,很实用,但与天文学无关,随着时间的流逝 TAI 时间和人们想要表达的那个天文学时间渐行渐远,于是引入闰秒机制,让 UTC 能够在 TAI 的基础上跟随 UT1:每当 UTC 时间与 UT1 时间相差超过 0.9 秒时,人为拨快或者拨慢一秒。使得可能出现 23:59:60 这种时间,或者 23:59:59 消失这种奇观。

所以,UTC 将 UT1 改为 TAI + 闰秒,相当于结合了 UT1 和 TAI 的优点。

闰秒和你我的关系

  • 如果让我们用 Java 编写一个时钟,那么我们需要考虑如何处理 23:59:60,使用 OffsetDateTime.parse 方法是无法解析的,因为 ISO8601 的秒最多到 59。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    val s1 = OffsetDateTime.parse("2016-12-31T23:59:60Z")

    // 输出如下
    Exception in thread "main" java.time.format.DateTimeParseException: Text '2016-12-31T23:59:60Z' could not be parsed: Invalid value for SecondOfMinute (valid values 0 - 59): 60
    at java.time.format.DateTimeFormatter.createError(DateTimeFormatter.java:1920)
    at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1855)
    at java.time.OffsetDateTime.parse(OffsetDateTime.java:402)
    at java.time.OffsetDateTime.parse(OffsetDateTime.java:387)
    at TimeExploreKt.main(TimeExplore.kt:11)
    at TimeExploreKt.main(TimeExplore.kt)

    此时我们有两种选择

    • Instant

      1
      2
      3
      4
      5
      // Instant 可以正确解析闰秒
      val s1 = Instant.parse("2016-12-31T23:59:60Z")
      val s2 = Instant.parse("2017-01-01T00:00:00Z")
      val duration = Duration.between(s1, s2)
      println(duration) // 输出 PT1S
    • 忽略闰秒

      依旧使用 OffsetDateTime,不过没有 23:59:60 这个时刻,取而代之的是 23:59:59 这个时刻会停留两秒。

  • 闰秒的存在会对生产有什么影响呢?

    影响的主要是时间差,假设一个集群有三个节点,正常情况下,三个节点定期同步标准时间,同步间隔期间使用节点本身的时钟驱动,节点之间的时差来自节点本身的时钟精度,可以说是很小了。但是当发生闰秒时,如果某个节点率先同步到了闰秒,将时间设为 23:59:60,但此刻未同步到闰秒的其他节点已经来到了次日的 00:00:00,就会造成该节点与其他节点之间至少有 1 秒的时间差。这在集群同步中比较严重。2012年7月1日发生在Reddit网站故障正式由于 Cassandra 集群节点之间闰秒不同步导致的时间差过大,集群宕机。

总之,闰秒产生了时间跳跃或时间停滞,在严格依赖时间差的分布式系统和对秒敏感的程序中,影响较大。

UTC 与 TAI

UTC 与 TAI 可以说是强关联的,UTC 秒直接采用了 TAI 秒。但 TAI 时间与 UTC 时间并不完全一致,迄今为止二者相差 37 秒。产生该秒差的原因如下

  • UTC 确立之前,采用 UT1 标准时间,UTC 继承了该时间,继承时,其已经与 TAI 存在 10 秒差距
  • 1972 年确立 UTC 至今,共计产生过 27 次闰秒,又增加了 27 秒差距

于是,37 秒差距产生了

java.time 如何跟进实事变化

有几个疑问

  • java8 是 2014 年发布的,日本历年号令和是 2019 年确定的,但是翻看最新的 JapaneseDate 代码,令和却已包含,那这是什么时候更新的呢?

    image-20231019110401854

    答:通过小版本补丁打进去的。我的 JDK8 版本是 corretto-1.8.0_372,其基于 OpenJDK 8,而 OpenJDK 8u212 已经应用了该补丁,因此我的 372 版本就有了令和。但据说 Oracle JDK 8 并不支持令和,需要升级到 JDK12,不过并未验证。

  • 闰年闰月是通过规则计算的,但何时闰秒需要从外界获取,那如何知道什么时候需要闰秒呢?

    java 时间库并不需要知道什么时候闰秒,前文提到的 Clock 用于产生 UTC 瞬时时间,而改瞬时时间的 epoch 毫秒数来自操作系统,操作系统从权威时间源处获得的时间戳是已经闰秒过的。

最佳实践

  • 时间存储

    只有带时区的时间类型才能无损地存储时间

  • 时间传输

    只有三种格式能够不带歧义无损地传输日期

    • 时间戳(1697784305
    • 带偏移量的时间表示(2023-10-10T00:00:00+08:00
    • 带时区的时间表示(2023-10-10T00:00:00+08:00[Asia/Shanghai]
  • 服务端内部的时间计算和流转

    主要保证时间反序列化和序列化时的时区一致,时间就不会产生丢失,至于使用什么时间对象,都是可以的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 假设接收到一个时间戳
    val inputTimestamp = 1697784305

    // 如果使用 LocalDateTime。
    val instant = Instant.ofEpochSecond(inputTimestamp)
    val localDateTime = LocalDateTime.ofInstant(instant, ZoneId.of("Asia/Shanghai"))
    ... ...
    val outputTimestamp = localDateTime.atZone(ZoneId.of("Asia/Shanghai")).toInstant().epochSecond

    // 如果使用 OffsetDateTime
    val instant = Instant.ofEpochSecond(inputTimestamp)
    val offsetDateTime = OffsetDateTime.from(instant)
    ... ...
    val outputTimestamp = offsetDateTime.toInstant().epochSecond
  • 过期时间计算

    在订阅业务中,根据订阅历史计算订阅生效时间是常规需求,涉及到日期的计算。通常是订阅年产品和月产品

    1
    2
    3
    4
    5
    6
    7
    8
    val baseDateTime = OffsetDateTime.now();
    // 订阅年
    val yearlyProductPeriod = Period.ofYears(1);
    val expireDateTime = baseDateTime.plus(yearlyProductPeriod);
    // 订阅月
    val monthlyProductPeriod = Period.ofMonths(1);
    val expireDateTime = baseDateTime.plus(monthlyProductPeriod);

留言

2023-10-20

⬆︎TOP