Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

阿里妹导读

在计算机领域作者重新梳理了计算机世界里日期时间体系的前世今生。

突击检查

如下代码输出什么,机器当下所设定的时区为美国时区,在北京时间 2024-12-07 11:20:51 时,传入字符串 “2024-12-07 11:46:36”。最终输出应该是 true,还是 false 呢?

前言

约 38 亿年前地球出现生命体,约 46 亿年前太阳系形成,大约 138 亿年前宇宙大爆炸,那再往前呢?想起吕秀才对姬无命发出灵魂之问『时间是否有开端,宇宙是否有尽头』。施一公曾经在一次演讲中说,宇宙中从来不存在时间,只存在运动。地球公转太阳一圈是一年,这是运动,地球自转一圈是一天,这也是运动。从来就没有时间,或者说时间就是空间。

『三十年春,秦晋围郑。郑伯使烛之武如秦』两千多年前我们就以时间记事,在造物主已经缔造的这一片井然有序的世界里,我们凭空创建出一个新的概念,并不断尝试融入这个世界体系 -- 沙漏、水钟、日晷等等。今天站在计算机这个领域,也让我们重新梳理一遍,计算机世界里日期时间体系的前世今生。

日期从 1970 年 1 月 1 日说起

任何一个软件开发人员对这个时间应该都不陌生,有时我们忘记初始化或者忘记赋值时,日期就会显示为 1970-01-01,我们也叫日期初始值。那为什么日期的初始值是从 1970-01-01 开始呢?有一个说法是说遵循了 Unix 的时间计数,Unix 认为 1970 年 1 月 1 日 0 点 [1] 是时间纪元,那为什么 Unix 要以这个时间为准呢?

有一处说法是说,当时操作性系统都是 32 位,如果每一个数值代表一秒,那么最多可以表示 2^32-1,也就是 2147483647 秒,换算成年大概是 68 年。而 Unix 系统就是由 Ken Thompson、Dennis Ritchie 和 Douglas McIlroy 等人在贝尔实验室开发于 1969 年开发的,他们为了让时间尽可能的多利用起来,便用了下一年,即 1970 年 1 月 1 日作为开始,然后这个约定也逐步延伸到其他各个计算机领域。

时间从 GMT 与 UTC 说起

聊完日期我们再来看时间,爱好体育的应该都知道,看欧冠得半夜起来看,看 NBA 得早上起来看,现在是北京时间的 14 点,同时也是纽约时间的凌晨 1 点半。那是因为我们各地处不同时区,那时区以什么为初始划分的呢?

GMT 格林威治时间

GMT 的全称是 Greenwich Mean Time [2] 即格林威治标准时间,是一种与地球自转相关、以太阳日为单位的时间标准。在十七世纪,格林威治皇家天文台为了海上霸权的扩张计划,选择了穿过英国伦敦格林威治天文台子午仪中心的一条经线作为零度参考线,也就是我们教科书上记载的本初子午线。

并约定从本初子午线起,经度每向东或者向西间隔 15°,就划分一个新的时区 [3],每个时区间隔 1 小时,在这个区域内,大家使用同样的标准时间。但各个国家也会基于各个国家的情况拆分或合并时区,比如中国横跨 5 个时区,但我们统一使用东八区;而美国则有东部时间、西部时间、夏威夷时间等等。

从 1924 年开始,格林威治天文台每小时就会向全世界播报时间,最终截止到 1979 年。至于为什么会终止,自然有它的缺点和局限性,那我们就得聊聊 UTC 时间了。

UTC 世界协调时间

UTC 的全称是 Coordinated Universal Time [4] 协调世界时间,也称世界标准时间。据说按英语的简称是 CUT,按法语的简称是 TUC,然后大家相互拉扯一波后,统一叫了 UTC。

上述所说 GMT 时间是以地球自转与围太阳公转来计时的,GMT 时间认为地球自转一圈是 243600 秒,而地球的运动轨迹受很多方面影响,比如潮汐摩擦、气象变化、地震及地质活动等等,运动的时间周期并不是完全规律和相同的。这样会导致其实一天并不完全是 243600 秒,这样平均算下来 GMT 的一秒就不是完全意义上最精确的一秒。但偏差通常也不会很大,基本为毫秒级偏差,但日积月累如果不加以扶正,就会越差越远。

而 UTC 的计数是基于 原子钟(Atomic Clock) [5] 的计数,比如铯原子钟采用铯 -133 原子的特性,在特定能级跃迁时会产生一个非常确定的频率 9,192,631,770 赫兹。然后基于铯 -133 原子的运动经过换算确定出我们需要的时间周期,据说这种误差可达每百万年内不到一秒。

UTC 最终由两部分构成:原子时间与世界时间。原子时间基于原子钟,来标准化我们钟表中每一秒时间前进的数据;世界时间是结合 GMT 时间,我们用多少个原子时来决定一个地球日的时间长度。从 1972 年开始,UTC 被正式采用为国际标准时间。这年实施了一种新的时间调整机制,包括使用闰秒 [6] 以便对齐地球自转与原子时间。

JDK 时间日期的发展史

糟糕的 java.util.Date

说起 Date 那可是 JDK 的正牌嫡系,从 1.0 开始就一直存在并延续至今。但只要大家用过一些代码扫描工具,基本都是在提示你尽量不要使用 Date。在 oracle 的官方 JDK 文档中,有超过一半的函数都是 deprecated,要细说 Date 的问题,那可真是一言难尽。

不能单独表示日期或时间

Sat Dec 07 17:36:58 CST 2024 这是我们输出 new Date() 之后的数据,因为 Date 本质是某一个时刻的时间戳,导致它不能单独表示日期,更不能表示不带日期的时间。

令人捉摸不透的 API

单就 Date 的方法名来看,应该是非常友好的。它提供了 getYear(), getDay() 等等,你但凡用过一次,一定让你抓狂。

public static void main(String[] args) {
    Date date = new Date();
    // 输出 6
    System.out.println(date.getDay());
    // 输出 11
    System.out.println(date.getMonth());
    // 输出 124
    System.out.println(date.getYear());
}

day 和 month 是从 0 开始计数的,所以月最大是 11,日最大是 30,年输出 124 是因为 2024 年距离 1900 年有 124 年。至于为什么是减 1900,有知道的小伙伴评论区打出来😂。

不支持时区设定

Date now = Calendar.getInstance(Locale.CHINA).getTime();

曾经写过一段这样的代码,取当前的中国时间,被老板臭骂一顿。。。Date 的本质是一个时间戳。当前此时此刻,全球任何一个地方的时间戳都是同一个,Date 本身不支持时区。PS. 本质上这行代码也指定不了时区哦~

Date 是可变的

Date 是一个非常基础底层的类,但它却设计为可变。当我们计算这个 data3 天后是不是周末,如果程序计算中把这个 date 加了 3 天,那么你手上拿着得 date 也变成了 3 天后的日期。相比同为底层基础类的 String,做得就优秀多了。

难当大任的 Calendar

JDK 刚推出就发现了问题,于是赶紧在 1.1 版本推出了 Calendar,尝试用来解决令人诟病的 Date,并将 Date 一众函数都标记为了 deprecated。但 Calendar 依然是可变对象、最多也只能精确到毫秒、线程不安全、API 的使用复杂且笨重等等,Calendar 整体而言并没有挽回颓势。

曙光来临之 JSR310

在聊 JSR310 之前,不得不先提一提 Joda-Time [7] 这个开源 Java 库。Joda-Time 以清晰的 API、良好的时区支持、不可变性、强类型化等特性,得到了开发者社区的广泛好评,并在很多项目中被采用,被视为改善 Java 日期和时间处理的标杆库。Joda-Time 如此优秀,Oracle 也开启了收编之旅。2013 年 Java8 发布,其中针对日期时间带来一套全新的标准规约 JSR310 [8],而 JSR310 的核心制作者就是 Joda-Time 的作者 Stephen Colebourne。

Instant

/**
 * The number of seconds from the epoch of 1970-01-01T00:00:00Z.
 */
private final long seconds;
/**
 * The number of nanoseconds, later along the time-line, from the seconds field.
 * This is always positive, and never exceeds 999,999,999.
 */
private final int nanos;

Instant 这个单词的中文含义是『瞬间』,严格来说 Java8 之前的 Date 就应该是现在的 Instant。Instant 类有维护 2 个核心字段,当前距离时间纪元的秒数以及秒中的纳秒部分。它指代当前这个时刻,全球任一位置这一时刻都是同一时刻。这一时刻川建国同学在高床软枕打着呼,这一时刻我泡着龙井写着文稿。

LocalDateTime

/******************** LocalDate ********************/
    /**
     * The year.
     */
    private final int year;
    /**
     * The month-of-year.
     */
    private final short month;
    /**
     * The day-of-month.
     */
    private final short day;

/******************** LocalTime ********************/
    /**
     * The hour.
     */
    private final byte hour;
    /**
     * The minute.
     */
    private final byte minute;
    /**
     * The second.
     */
    private final byte second;
    /**
     * The nanosecond.
     */
    private final int nano;

LocalDateTime 由 LocalDate 和 LocalTime 组成,分别日期和时间,以此来解决 Date 中不能单独表示日期和时间的问题。它们都与时区无关,只客观代表一个无时区的时间,比如 2024-12-08 13:46:21,LocalDateTime 记录着它的年、月、日、时、分、秒、纳秒。但具体是北京时间的 13 点还是伦敦时间的 13 点,由上下文语境自行处理。

Duration

Duration 中文含义译为『期间』,通常用来计算 2 个时间之前相差的周期,不得不说这一套时间 JDK 确实定义得语义非常清晰。

Instant startInstant = xxx;
Instant endInstant = xxx;
Duration.between(startInstant, endInstant).toMinutes();

这个很好理解,比较 2 个时间戳时间的相差分钟数。但如果换成 LocalDateTime,会是怎样呢?

LocalDateTime startTime = xxx;
LocalDateTime endTime = xxx;
Duration.between(startTime, endTime).toMinutes();

因为 LocalDateTime 是不带时区的,所以 LocalDateTime 是不能直接换成成 Instant 的。而 Duration 的比较也是不带时区的,或者你可以理解它是把时间放在同一个时区进行比较,来抹去时区的影响。

/********************* JDK Duration.between 部分源码 *******************************/
@Override
public long until(Temporal endExclusive, TemporalUnit unit) {
    LocalDateTime end = LocalDateTime.from(endExclusive);
    if (unit instanceof ChronoUnit) {
        if (unit.isTimeBased()) {
            long amount = date.daysUntil(end.date);
            if (amount == 0) {
                return time.until(end.time, unit);
            }
            long timePart = end.time.toNanoOfDay() - time.toNanoOfDay();
            if (amount > 0) {
                amount--;  // safe
                timePart += NANOS_PER_DAY;  // safe
            } else {
                amount++;  // safe
                timePart -= NANOS_PER_DAY;  // safe
            }
// 余下省略
}

上述是 Duration 部分源码,它首先计算出 2 个时间相差多少天,再比较当天的时间里相差多少纳秒,再进行累加。所以你传过来 2024-12-08 和 2024-12-04,那就是相差 4 天,至于是北京时间的 12-08 还是伦敦时间的 12-04,在 Duration 里都被抹去了时区的概念。看到这里,上面的编程题里做对了吗?

ZonedDateTime

真正需要使用时区,我们就需要用到 ZonedDateTime。「zoned」这个单词在英汉词典中是 zone 的过去分时,译为『划为区域的』。

// 输出:2024-12-08T14:18:32.554144+08:00[Asia/Shanghai]
ZonedDateTime defaultZoneTime = ZonedDateTime.now(); // 默认时区
// 输出:2024-12-08T01:18:32.560931-05:00[America/New_York]
ZonedDateTime usZoneTime = ZonedDateTime.now(ZoneId.of("America/New_York")); // 用指定时区获取当前时间

因为 LocalDateTime 是没有时区的,如果我们需要将 LocalDateTime 转成 ZonedDateTime,就需要带上时区信息。

LocalDateTime localDateTime = LocalDateTime.of(2024, 12, 8, 14, 21, 17);
ZonedDateTime zonedDateTime = localDateTime.atZone(ZoneId.systemDefault());
ZonedDateTime usZonedDateTime = localDateTime.atZone(ZoneId.of("America/New_York"));

随着 JDK 不断地发布演进,Time 模块确实得到了质的提升,这里不一一细说 Java 日期时间相关 API。如果你还在苦于对 Date 做各种 Utils 的花式包装,请拥抱 java.time 吧。

时间日期引起的惨案

夏令时与冬令时

曾经小 A 做了一个鉴权系统,用于对请求做加密解密,保证每一次都是真实合法有效的接口请求。其中做了一个判定,如果请求的时间距现在已经超过 10 分钟,就会拒绝该次请求。从逻辑上来说,这很合理,但问题的雪崩却出现在 3 月的那个晚上。。。

什么是夏令时

夏令时 [9] 又称夏时制,英文原文为 Daylight Saving Time,从名字上可以看出,夏令时诞生的背景是为了更好的利用白天的时间。夏令时概念的提出最早可以追溯到 1895 年,新西兰昆虫学家乔治 · 哈德逊向惠灵顿哲学学会提出,提前 2 小时的日光节约提案,以此在工作结束后,可以获得多出一段的白昼时间。

具体夏令时的实施,以美国为例,美国会在每年 3 月的第二个星期日的凌晨 2:00,时钟会往前调 1 个小时变为 3:00。再在每年 11 月的第一个星期日的凌晨 2:00,将时钟在往后调 1 个小时变成 1:00,此时的回拨也被称为 “冬令时”。

夏令时实施的国家与地区

蓝色为正在实施夏令时的国家和地区
灰色为曾经实施但现在已经取消夏令时的国家和地区
黑色为从未实施夏令时的过去和地区

1916 年 4 月 30 日,德国与奥匈帝国成为世界上第一组实施夏时制的国家,目的是为了能在战争期间节约煤炭消耗。在 1970 年代,由于美洲与欧洲地区也受到能源危机影响,至此夏令时开始广泛被实施。当下全球有共约 70 多个国家和时区在使用夏令时,我国也曾短暂使用过夏令时,但因节约能源效果不显著,以及对日常生活工作等带来的一些影响,到 1992 年全国宣布取消夏令时。

闰年与闰秒

2008 年是闰年存在 2 月 29 日,但微软一些软件在处理部分任务的时候会因为闰年导致处理错误。微软甚至在 SQL Server 2008 CTP 发布后曾经宣读了一份证明,建议用户不要在 2 月 29 日安装和运行软件,以减少影响。并且在 Windows Small Business Server 上还会出现更严重的错误:因为在微软的日历里根本没那么一天,因此就无法颁发证书。

为什么要闰年

闰年大家比较熟悉,闰年的设置是为了使日历年与太阳年(即地球绕太阳公转一周的时间)更精准地一致。严格来说地球绕太阳一圈的时间,大约是 365.2422 天。经过大约四年,累计误差将接近一天(0.2422 * 4 ≈ 0.9688 天),但如果每 4 年就加 1 天,这样每 128 年又会多算出 1 天。所以基于此定义出了普通闰年与世纪闰年。

  • 普通闰年:公历年份是 4 的倍数,且不是 100 的倍数的,为闰年(如 2004 年、2020 年等就是闰年)。

  • 世纪闰年:公历年份是整百数的,必须是 400 的倍数才是闰年(如 1900 年不是闰年,2000 年是闰年)。

为什么要闰秒

闰秒 [10] 本质上和闰年的作用是一样的,也是解决时间解释运动中所存在的偏差。闰秒的调整是为了确保协调世界时(UTC)与地球自转时间(UT1)[11] 保持一致。由于地球自转速度的不均匀性和减慢,UTC 需要定期添加或删除一秒钟来进行调整,这一秒钟称为 “闰秒”。

国际地球自转与参考系统服务(IERS)是负责监测和发布闰秒调整的机构。ERS 会根据地球自转的实际变化和测量数据,决定是否需要调整闰秒。闰秒通常在 6 月 30 日或 12 月 31 日的最后一秒添加或删除。这意味着在某些年份,时间序列可能会变为:23:59:59 → 23:59:60 → 00:00:00。

写在最后

『存在不一定合理,但一定有原因』这是曾经我的主管跟我说的,至今我也受益其中。对所有事情怀有一丝怀疑心态,搞懂它的前世今生,或许它不那么合理,但至少当时这样做解决了一定的问题,我们在做新设计的时候可以提前考虑与规避。水多了加面,面多了加水,如果我们只是看到当下的混乱就指着 “前人” 没有设计思想没有技术匠心,却不了解最初 “前人” 这样做的意图与背景,骂着 “前人” 的我们终有一天也会成为后人眼中的“前人”。

参考链接:

[1]https://en.wikipedia.org/wiki/Unix_time

[2]https://baike.baidu.com/item / 世界时 / 692237

[3]https://www.timeanddate.com/time/zones/

[4]https://www.utctime.net/

[5]https://baike.baidu.com/item / 原子钟 / 765460

[6]https://baike.baidu.com/item / 闰秒

[7]https://www.joda.org/joda-time/

[8]https://jcp.org/en/jsr/detail

[9]https://baike.baidu.com/item / 夏令时 / 1809579

[10]https://baike.baidu.com/item / 闰秒 / 696742

[11]https://zh.wikipedia.org/wiki / 世界时

高效构建安全合规的企业新账号

通过此方案可以统一企业内不同账号内的基线,灵活适配不同企业对账号初始化的个性需求。

点击阅读原文查看详情。

Released under the MIT License.