如果项目需要考虑国际化的时候,时间和日期是代码中必须关注的一个内容。

基础知识

  • GMT(Greenwich Mean Time):英国格林威治时间(世界标准时间),是 本初子午线 上的地方时,是 0 时区的区时。

    • 本初子午线是指地球上的零度经线。国际上将通过英国伦敦格林尼治天文台原址的那条经线称为 0° 经线,也叫本初子午线
    • 太阳 为基础(类似日晷)
  • UTC:通用协调时 (UTC, Universal Time Coordinated),理论上与 GMT 时间相等(除非出现了 闰秒 ,日常使用上可以忽略不计)。

    • 以原子时的秒长为基础
  • CST:时区的缩写,由于英文首字母相同,所以 CST 可能表示的时区是不唯一的:

    • 美国中部时间:Central Standard Time (USA) UT-6:00
    • 澳大利亚中部时间:Central Standard Time (Australia) UT+9:30
    • 中国标准时间:China Standard Time UT+8:00
    • 古巴标准时间:Cuba Standard Time UT-4:00
  • PST(Pacific Standard Time): UTC-8,太平洋标准时间(西八区)

夏令时 DST

夏时制(美国及加拿大英语:daylight time),又称夏令时、日光节约时间(美国及加拿大称为daylight saving time,简称 DST;英国与其他地区称为 Summer Time,是一种在夏季月份牺牲正常的日出时间,而将时间调快的做法。

通常使用夏时制的地区,会在接近春季开始的时候,将时间调快一小时,并在秋季调回正常时间。实际上,夏时制会造成在春季 转换当日 的睡眠时间减少一小时,而在秋季转换当日则会多出一小时的睡眠时间。

闰秒

闰秒是偶尔用于调整协调世界时(UTC),经由增加或减少一秒,以消弥精确的时间(使用原子钟测量)和不精确的观测太阳时(称为UT1),之间的差异。

地球的旋转速度会随着气候和地质事件的变化而变化,因此 UTC 的闰秒间隔不规则且不可预知。每个 UTC 闰秒的插入,通常由国际地球自转服务(IERS)提前约六个月决定,以确保 UTC 和 UT1 读数之间的差值永远不会超过 0.9 秒。

考虑到闰秒的影响,心跳包的间隔时间小于 1 秒钟是没有意义的。闰秒会让两台机器的相对时差发生跳变,可能造成误报警。

时区

如果时间是以协调世界时(UTC)表示,则在时间后面直接加上一个 Z(不加空格)。Z 是协调世界时中 0 时区的标志。比如 09:30 UTC 相当于 09:30Z

UTC 偏移量

UTC 偏移量代表了某个具体的时间值与UTC时间之间的差异。用 ±[hh]:[mm] 或者 ±[hh]:[mm] 或者 ±[hh] 的形式表示。比如北京时间的时区会表达成 +08:00 / +0800 / UTC+8

Java 8 中的日期和时间 API

Java 1.0 中,对日期和时间的支持只能依赖 java.util.Date 类。这个类无法表示日期,只能以毫秒精度表示时间。而且由于某些未知的设计决策,这个类非常难用(反人类),比如:年份的起始年份选择的是 1900 年,月份是从 0 开始的。

Java 8 的发布日期是 2014 年 3 月 18 日,那么就要表示成:

1
Date date = new Date(114, 2, 18);

它的打印输出为:

1
Tue Mar 18 00:00:00 CET 2014

CET 是 Java 默认的时区,即中欧时间(Central Europe Time)。

Java 1.1 中,Date 类中的很多方法被废弃了,取而代之的是 java.util.Calendar。不幸的是,Calender 也有类似的设计缺陷。

比如,月份仍然是从 0 开始计算的。不过,Calender 取消掉了从 1900 年开始计算年份的设计。同时存在 DateCalender,也增加了程序员的困惑——到底该用哪个?

因为有一些特性只有某一个类才有,比如语言无关的格式化和解析日期和时间的 DateFormat 方法只在 Date 类里有。

DateFormat 也有问题,比如,它不是线程安全的。这意味的如果两个线程尝试使用同一个 formatter 解析日期,可能会得到无法预期结果。

种种缺陷让用户转向了第三方的日期和时间库,比如 Joda-Time。

为了解决上述问题,Java 8 在 java.time 包中整合了很多 Joda-Time 的特性。

LocalDate、LocalTime、Instant、Duration 以及 Period

LocalDate 是不可变的对象,只提供了简单的日期,并不含当天的时间信息。另外,它也不附带任何与时区相关的信息。

可以使用静态工厂方法 of 创建一个 LocalDate 实例,它提供了多种方法来读取常用的值,比如年份、月份、星期几。

1
2
3
4
5
6
7
8
LocalDate date = LocalDate.of(2022, 8, 16);
int year = date.getYear();
int day = date.getDayOfMonth();
DayOfWeek dow = date.getDayOfWeek();
boolean leap = date.isLeapYear();

LocalDate date = LocalDate.parse("2014-03-18");
LocalTime time = LocalTime.parse("13:45:20")

一旦传递的字符串参数无法被解析为合法的 LocalDateLocalTime 对象,这两个 parse 方法都会抛出一个继承自 RuntimeExceptionDateTimeParseException 异常。

如果你需要以年、月或者日的方式对多个时间单位建模,可以使用 Period 类。使用该类的工厂方法 between ,你可以使用得到两个 LocalDate 之间的时长。

1
2
3
Period tenDays = Period.between(LocalDate.of(2014, 3, 8),
LocalDate.of(2014, 3, 18)
)

合并日期和时间

LocalDateTimeLocalDateLocalTime 的合体,同时表示了日期和时间,但不带有时区信息

1
2
// 2014-03-18T13:45:20
LocalDateTime dt1 = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45, 20)

机器的日期和时间格式

作为人,我们习惯于以星期几、几号、几点、几分这样的方式理解日期和时间。毫无疑问,这种方式对于计算机而言并不容易理解。从计算机的角度来看,建模时间最自然的格式是表示一个持续时间段上某个点的单一大整型数。这也是新的 java.time.Instant 类对时间建模的方式,基本上它是以 Unix 元年时间(传统的设定为 UTC 时区 1970 年 1 月 1 日午夜时分)开始所经历的秒数进行计算。

Instant 的设计初衷是为了便于机器使用,它包含的是由秒及纳秒所构成的数字。所以,它无法处理那些我们非常容易理解的时间单位。

比如下面这个语句:

1
int day = Instant.now().get(ChronoField.DAY_OF_MONTH); // UnsupportedTemporalTypeException

TemporalAdjuster

比如,将日期调整到下个周日、下个工作日,或者是本月的最后一天。这时,你可以使用重载版本的 with 方法,向其传递一个提供了更多定制化选择的 TemporalAdjuster 对象,更加灵活地处理日期。

1
2
3
4
import static java.time.temporal.TemporalAdjusters.*;
LocalDate date1 = LocalDate.of(2014, 3, 18); // 2014-03-18
LocalDate date2 = date1.with(nextOrSame(DayOfWeek.SUNDAY)); // 2014-03-23
LocalDate date3 = date2.with(lastDayOfMonth()); // 2014-03-31

打印输出及解析时间对象

处理日期和时间对象时,格式化以及解析日期-时间对象是另一个非常重要的功能。新的 java.time.format 包就是特别为这个目的而设计的。这个包中,最重要的类是 DateTimeFormatter

DateTimeFormatter 也预定义了像 BASIC_ISO_DATEISO_LOCAL_DATE 这样的常量。

1
2
3
LocalDate date = LocalDate.of(2014, 3, 18);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE); // 20140318
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE); // 2014-03-18

时区

新的 java.time.ZoneId 类是老版 java.util.TimeZone 的替代品。它的设计目标就是要让你无需为时区处理的复杂和繁琐而操心,比如处理日光时(DaylightSaving Time,DST)这种问题。跟其他日期和时间类一样,ZoneId 类也是无法修改的。

每个特定的 ZoneId 对象都由一个地区 ID 标识,比如

1
ZoneId romeZone = ZoneId.of("Europe/Rome");

地区 ID 都为 {Zone}/{City} 的格式,这些地区集合的设定都由英特网编号分配机构(IANA)的时区数据库提供。

一旦得到一个 ZoneId 对象,我们就可以将它与 LocalDateLocalDateTime 或者是 Instant 对象整合起来,构造为一个 ZonedDateTime 实例。

另一种比较通用的表达时区的方式是利用当前时区和 UTC/GMT 的固定偏差。比如,基于这个理论,你可以说“纽约落后于伦敦5小时”:

1
ZoneOffset newYorkOffset = ZoneOffset.of("-05:00");

-05:00 的偏差实际上对应的是美国东部标准时间。注意,使用这种方式定义的 ZoneOffset 并未考虑任何日光时的影响,所以在大多数情况下,不推荐使用。

参考资料

  1. 前端国际化时间日期概述与业务实践
  2. Urma R G, Fusco M, Mycroft A. Java 8 in action[M]. Manning publications, 2014.
  3. 维基百科. 夏时制
  4. 维基百科. 闰秒
  5. ISO 8601
  6. RFC 2822
  7. 《程序中的日期与时间》第一章 日期计算
  8. 陈硕. Linux 多线程服务端编程[J]. 2009.