qinghuan
← Java SE / 全部笔记

单元 24 / Java SE

P1 · 重点提升L1 · 基础

日期与时间:分清日历时间、时间点和时区

从生日、营业时间和日志时间出发选择类型,分清日期、确定时刻和时区,避免同一时间被解释成不同意思。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能为日期、时刻和时长选类型,并写出独立于机器默认时区的计算。

学习理由、准备要求与知识点

为什么学它

时间类型和时区约定影响到期、统计和跨地区数据的一致解释。

先读这些内容

知识点清单

  • LocalDate/LocalTime
  • LocalDateTime 无时区
  • Instant 时间线
  • ZoneId 与地区规则
  • Duration 与 Period
  • 夏令时重叠和缺口
  • DateTimeFormatter 年份模式
  • Clock.fixed 可重复测试

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

记录时间前,先看业务要表达什么。生日只要日期,可用 LocalDate;营业时刻可用 LocalTime;LocalDateTime 同时保存日期和钟表时间,却不含时区;日志事件通常需要用 Instant 表示时间线上确定的一刻;需要地区时区规则时再用 ZonedDateTime。不同含义应选不同类型,不能都塞进没有说明的字符串。

同一个时刻在不同地区会显示不同时间。ZoneId 表示地区或偏移标识,ZoneOffset 是相对 UTC 的具体偏移。地区的偏移可能随夏令时或历史规则变化,例如 Europe/Berlin 不能永远当作 +01:00。从 Instant 转成当地时间通常是确定的;反过来,当地钟表时间可能因前跳而不存在,或因回拨而出现两次,需要明确处理规则。

java.time 的主要值类型不可变,plusDays、plusMonths 等返回新值。Duration 表达时间线上的时长,Period 表达日历年、月、日的数量;跨夏令时的一天不一定等于二十四小时。格式化优先使用 DateTimeFormatter,并明确输入格式、时区与解析严格程度,避免系统默认时区或宽松解析悄悄改变业务含义。

文中这几个词,先认一认

瞬间
时间线上的某一刻,可用 Instant 表示,不因换一个显示时区而改变。
当地日期时间
日历日期加钟表读数;没有时区时,通常无法唯一确定是时间线上的哪一刻。
时区缺口
时钟向前调后,有一段当地钟表时间被跳过,从未出现。
时区重叠
时钟向后调后,同一段当地钟表时间出现两次,可能对应两个不同时刻。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

一个日期加一个月不能简单理解为加固定天数。LocalDate.plusMonths 先改变年和月,再在原日号超出目标月份时取该月最后有效日,例如一月三十一日加一个月得到二月末。这个选择适合某些日历需求,但如果每次都以上次结果为起点继续加月,二月的缩短日号会继续传播到三月。

固定“每月尽量同一天”的计划应保留原始锚点,例如原日号三十一,而不是让上次执行结果成为新的规则。第 k 次日期直接由原始日期加 k 个月推导,二月临时落在月末,三月仍能回到三十一日。若业务要求“永远月末”,应把月末意图作为独立规则保存,因为从四月三十日开始每月同日并不等于每月最后一天。

时间点转换还需要单独的时区策略。Java 在缺口或重叠处有默认解析行为,但预约、扣费和合规日志未必接受这些默认值。可以查看 ZoneRules 的有效偏移数量,零个表示缺口,两个表示重叠,然后明确拒绝、顺延或选择哪次发生。图把日历生成与时区解析拆开,使月度规则和夏令时规则各自可测试。

正在加载流程图…

月度计划先解决日历语义,再解决时区歧义;不能把两层都交给默认行为。
查看流程图源码(Mermaid)
flowchart TD
 A["保存原始日历锚点与月度规则"] --> B["计算第 k 个月"]
 B --> C["校正目标月份有效日号"]
 C --> D["组合当地执行时间"]
 D --> E["依据 ZoneId 检查偏移"]
 E --> F["缺口与重叠采用明确策略"]
 F --> G["得到可调度 Instant"]

按顺序看,每一步做了什么

  1. 选择表达日期、当地时间或瞬间的类型。
  2. 保存地区时区而非猜测固定偏移。
  3. 区分日历量与时间线时长。
  4. 为月末、缺口和重叠制定策略。
  5. 注入 Clock 并测试固定历史时间与边界。

03对照实现代码

月份移动如何保留有效日期 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/time/LocalDate.java · java.time.LocalDate#plusMonths(long)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import java.time.LocalDate;
import java.time.YearMonth;
public final class SourceModel {
    public static LocalDate addMonths(LocalDate source, long amount) {
        // 使用公开年月运算表达目标月份,不复制 JDK 内部字段运算。
        YearMonth target = YearMonth.from(source).plusMonths(amount);
        int day = Math.min(source.getDayOfMonth(), target.lengthOfMonth());
        return target.atDay(day); // 原日号不存在时取目标月最后有效日。
    }
}

真实 LocalDate.plusMonths 将年月折算成连续月份计数,加上偏移后用 floorDiv 与 floorMod 恢复年和月,再调用 resolvePreviousValid 修正日号。模型通过公开 YearMonth API 组织同一日历意图,省略内部字段、零偏移快捷返回与异常路径细节,不声称是源码逐行翻译。

使用向下整除与非负模关系,对跨越公元前后或负月份偏移的计算很重要。普通向零截断的除法不能随意替换这两个方法,否则负月份下的年、月组合可能错误。初读可先用跨年减月样本理解效果,再回到源码验证数学分解。

最后有效日修正规则解释了一月三十一日到二月末的行为,但它不保存“原来想要三十一日”的长期意图。返回对象只表示当前日期,不携带生成历史;因此连续调用 plusMonths 会依据新的日号继续计算。计划系统必须把规则与某次计算结果分开持久化。

LocalDate 不含时区,所以这段实现不会查询夏令时偏移,也不会产生瞬间。它解决的是日历日期合法性,不能据此保证某个当地凌晨执行时间存在。把源码方法职责限定在日期层,有助于避免看到 plusMonths 就推断跨时区调度已经完成。

代码先调用谁,再调用谁

  1. plusMonths 将年和月转为连续月份计数
  2. 增加偏移并检查新年份范围
  3. floorDiv 与 floorMod 恢复年和月
  4. resolvePreviousValid 修正目标日号

04动手写一个例子

从固定锚点生成每月同日计划

输入 LocalDate 锚点与零到一千二百的数量,包含锚点本身,按每月尽量同日生成日期。目标月没有该日时落到月末,后续月份重新按原始锚点计算;越出日期范围抛异常。

输入 1
2024-01-31,3
输出 1
[2024-01-31,2024-02-29,2024-03-31]
输入 2
2023-01-31,3
输出 2
[2023-01-31,2023-02-28,2023-03-31]
输入 3
任意日期,0
输出 3
[]

状态只需要原始锚点和当前月偏移,不更新一个滚动日期变量。第 i 项直接计算 anchor.plusMonths(i),因此每次日号修正都基于原日号。二月落在二十九日只是那一项的结果,不会永久把后续计划改成二十九日。

正确性来自逐项定义:第 i 项必须位于锚点之后 i 个月,且日号是原日号与该月最大日号的较小者。每一项独立满足这项规则,因此不用从上一项推断。数量为零返回空列表,数量上限控制输出资源,null 锚点则属于非法输入而不是缺省为今天。

测试选择闰年和非闰年一月三十一日,专门验证三月能恢复三十一日。再加入跨年、零数量和日期最大值附近输入。算法只返回日期,不设置真实定时任务,也不选择当地执行时刻;将结果用于调度时还需应用前面讨论的时区与重复执行规则。

为什么这样写是对的生成到第 i 项时,每个已生成日期都直接依据同一个原始锚点及其月份偏移计算。

时间 O(n),n 为输出数量。额外空间 O(n) 输出,辅助状态 O(1)。

每项执行固定范围的日历字段计算并追加一次,没有逐日扫描月份;不计输出时只保留锚点、下标和当前值。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 34 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public class Main {
    static List<LocalDate> monthly(LocalDate anchor, int count) {
        if (anchor == null || count < 0 || count > 1200)
            throw new IllegalArgumentException("input");
        List<LocalDate> result = new ArrayList<>(count);
        for (int i = 0; i < count; i++) {
            // 每项重新使用原始锚点,避免二月日号修正传播到三月。
            result.add(anchor.plusMonths(i));
        }
        return result;
    }
    static void check(String date, int count, String... expected) {
        List<String> actual = monthly(LocalDate.parse(date),count)
                .stream().map(LocalDate::toString).toList();
        if (!actual.equals(List.of(expected))) throw new AssertionError(actual);
    }
    public static void main(String[] args) {
        // 闰年、非闰年、跨年和空计划的预期明确固定。
        check("2024-01-31",3,"2024-01-31","2024-02-29","2024-03-31");
        check("2023-01-31",3,"2023-01-31","2023-02-28","2023-03-31");
        check("2024-12-15",2,"2024-12-15","2025-01-15");
        check("2024-04-30",0);
        check("2024-04-30",2,"2024-04-30","2024-05-30");
        // 日期范围越界必须显式失败,不能循环回一个看似合法的年份。
        boolean rejected = false;
        try { monthly(LocalDate.MAX,2); } catch (DateTimeException e) { rejected = true; }
        if (!rejected) throw new AssertionError("date range");
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 数量零返回空计划。
  • 闰年二月与普通二月不同。
  • 四月三十日起步表示每月三十日,不自动表示永远月末。
  • 超出 LocalDate 支持范围时失败。
进阶选读 · 可选深入:跨时区重复调度

场景每月当地时间凌晨执行,遇到夏令时缺口、重叠或时区数据库更新。

做法持久化日历规则、ZoneId、执行策略与执行唯一标识;每次计算候选 Instant 时显式解析偏移,避免简单加固定秒数。

复杂度生成 n 次候选通常 O(n) 次规则查询,实际规则表查找成本由实现决定。

好处与代价保留原地区规则便于解释,时区政策变化又可能改变未来瞬间,需要版本和重算策略。

05项目里怎么用

事件日志和跨服务时间戳通常保存 Instant 或明确偏移的标准表示,再按用户地区显示。生日、营业日期等纯日历数据则不应为了统一格式硬转成某个午夜瞬间,否则跨时区显示可能变成前一天。数据类型应跟随语义,数据库字段映射也需明确是否保存偏移或地区信息。

依赖当前时间的代码可接受 Clock 参数,测试使用固定 Clock,避免每次运行都碰到不同日期。仅把系统时间改成常量字符串不够,还应控制时区、解析器和夏令时规则。测试“今天”相关业务时记录边界,例如当地午夜前后和月底,而不是依赖执行测试那一刻的环境。

算对下次预约或账单时间,还不代表任务只会执行一次。系统暂停、网络问题和重复投递仍可能导致漏执行或重复执行,需要记录处理结果并设计重试。把“生成日历日期”“换算为确定时刻”“实际执行任务”分开记录和验证,才知道失败发生在哪里。

容易踩的坑

  • 把 LocalDateTime 当作天然唯一瞬间。
  • 用固定二十四小时替代所有日历一天。
  • 连续按修正后的日期加月,导致日号漂移。
  • 依赖系统默认时区使测试和部署行为不同。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
Instant适合跨地区事件顺序和时间线计算不直接表达当地日历意图日志、过期时刻与事件记录
LocalDate 加日历规则清楚表达生日和月度日期调度前还需时区与时刻账期、日历和营业日期
ZonedDateTime保留地区规则与本地显示需理解缺口、重叠及规则更新地区相关预约和调度

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么 plusMonths 不是加固定天数?

展开参考答案与推导

月份长度不同,日历月移动先改变年月,再修正不存在的日号。加三十天会沿时间线日期推进,可能落在不同月日,不能代替按月账期规则。

02Duration 一天与 Period 一天是否总等价?

展开参考答案与推导

不总等价。Duration 的天按固定秒数表达,Period 的天是日历单位;在带时区日期时间上跨夏令时变化时,一天可能不是二十四小时。应根据业务选择。

03为什么 LocalDateTime 还不能唯一定位事件?

展开参考答案与推导

它没有地区或偏移。同样的钟表读数在不同地区对应不同瞬间,某地区夏令时重叠还可能对应两个瞬间;需要明确时区与歧义处理策略。

换个情况,还会处理吗

01月度账单从一月三十一日变成每月二十八日,怎么查?

展开参考答案与推导

检查是否以上次 plusMonths 的结果继续滚动。二月修正日号被当成新规则后会持续传播;保留原始锚点,每次按月份偏移独立计算,并补三月恢复日号的测试。

02本地测试通过,服务器显示日期早一天,可能原因?

展开参考答案与推导

查看是否把日期错误转为午夜瞬间,或解析和显示使用了不同默认时区。记录源类型、偏移和地区,区分纯日期与事件瞬间,避免通过随意加八小时补丁掩盖语义错误。

03预约时间落在夏令时缺口,应该怎么办?

展开参考答案与推导

先依据业务选择拒绝、顺延或其它明确策略,再使用地区规则验证。不能默认所有当地时间都存在,也不能让库默认调整悄悄成为用户未被告知的预约规则。

写代码试一试

01生成永远月末的计划

输入 / 条件
2024-04-30,2
输出 / 预期
[2024-04-30,2024-05-31]

提示 用目标 YearMonth 的月末。

展开参考答案与推导

对每个偏移计算 YearMonth.from(anchor).plusMonths(i).atEndOfMonth。它与每月同日算法是不同契约,应使用独立方法名并保留四月到五月的对照测试。

02让计划不包含锚点当月

输入 / 条件
2024-01-31,2
输出 / 预期
[2024-02-29,2024-03-31]

提示 偏移从一开始。

展开参考答案与推导

循环输出仍为 count 项,但月份偏移使用 i+1,继续直接以原始锚点计算。同步更新零数量、跨年和最大日期边界,不能只删第一项导致数量减少。

03让依赖今天的调用可测试

输入 / 条件
固定今天2024-01-31
输出 / 预期
固定相同计划

提示 注入 Clock。

展开参考答案与推导

上层方法接收 Clock,用 LocalDate.now(clock) 得到锚点,再调用纯 monthly。测试传固定时钟与明确时区,避免读取真实当前时间导致样本随日期变化。

读完之后,独立完成

课后作业:自己做一遍

本篇共 9 道作业,先完成 7 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能为日期、时刻和时长选类型,并写出独立于机器默认时区的计算。

本篇重点加练

重点加练地区规则与可控时钟,让时间功能在环境和日期变化后仍可重复验证。

  • ZoneId 与地区规则
  • Clock.fixed 可重复测试

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    分别为生日、每天 09:00 的提醒、日志事件和未确定时区的预约选择时间类型,说明每种值缺少哪些信息才能定位时间线上的时刻。

    提交内容
    类型选择表与转换前置条件。
    怎样算完成
    • 日期时间与瞬时点区分
    • 不默认所有值都属于系统时区

    练到的知识点LocalDate/LocalTime · LocalDateTime 无时区 · Instant 时间线

  2. 作业 02排错实验

    用 Europe/Berlin 检查 2024-03-31 02:30 与 2024-10-27 02:30 的有效偏移,记录所用 JDK 与规则;为预约系统制定冲突与缺口的处理合同。

    提交内容
    Java 查询、自写规则边界检查和业务合同。
    怎样算完成
    • 地区规则由 API 证据支持
    • 不隐式接受未知歧义

    练到的知识点ZoneId 与地区规则 · 夏令时重叠和缺口

综合应用

  1. 作业 03代码推演

    选取跨夏令时转换的日期,分别增加一天 Period 与二十四小时 Duration;先定义比较的是本地时间还是实际经过时长,再推演并运行验证。

    提交内容
    时间线图、比较合同与实际记录。
    怎样算完成
    • 计量语义明确
    • 时区前提记录完整

    练到的知识点Duration 与 Period

  2. 作业 04排错实验

    在年末年初附近选择至少六个日期,比较不同年份格式模式及解析方式,记录本机 Locale;设计一个不会误判的日志日期格式验收集。

    提交内容
    格式实验、自写往返测试及运行证据。
    怎样算完成
    • Locale 与模式都被固定
    • 年边界样本完整

    练到的知识点DateTimeFormatter 年份模式

  3. 作业 05编程实践

    实现优惠券有效性函数,输入固定 Clock、开始 Instant 与到期 Instant;自行规定区间端点,测试开始前、开始时、到期前和到期时,禁止依赖长时间 sleep。

    提交内容
    Java 实现、自写边界和无效区间测试、运行证据。
    怎样算完成
    • 时间可注入可重复
    • 端点合同一贯
    • 地区展示与判定时间线分开

    练到的知识点Instant 时间线 · ZoneId 与地区规则 · Clock.fixed 可重复测试

  4. 作业 06编程实践

    实现把注入 Clock 的当前时刻显示为用户指定地区日期的函数,用户地区与系统默认地区可不同;测试同一时刻下至少三个地区及非法地区标识。

    提交内容
    Java 实现、自写午夜附近/非法输入测试和运行证据。
    怎样算完成
    • 时钟与地区分别可控
    • 不依赖运行当天的偶然值

    练到的知识点ZoneId 与地区规则 · Clock.fixed 可重复测试

  5. 作业 07排错实验

    同一预约显示测试在两台机器结果不同。使用固定时钟和显式地区逐项检查默认地区、规则版本与格式设置,并保留修复前后对照。

    提交内容
    最小实验、环境差异表与稳定复现记录。
    怎样算完成
    • 每个环境因素单独改变
    • 测试断言基于事先声明的合同

    练到的知识点ZoneId 与地区规则 · Clock.fixed 可重复测试

进阶挑战 · 掌握前置后选做

  1. 作业 08设计评审

    完成任务调度基础后:用户希望每天当地 02:30 执行提醒且一年内会旅行。设计地区变更、重复时刻、缺失时刻和补执行政策。

    提交内容
    调度合同、状态记录与时间规则测试矩阵。
    怎样算完成
    • “每天”含义明确
    • 政策在规则变化时仍可解释

    练到的知识点LocalDateTime 无时区 · ZoneId 与地区规则 · Duration 与 Period · 夏令时重叠和缺口

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 09思维拓展 · 条件变化

    用户原设东京地区,出差后修改为纽约;过去日志与未来提醒是否都跟随改变?设计两种产品合同并给出能区分它们的固定时钟样本。

    提交内容
    历史/未来时间模型和测试矩阵。
    怎样算完成
    • 显示地区与事件身份分别定义
    • 修改时刻与事件时刻可追踪

    练到的知识点ZoneId 与地区规则 · Clock.fixed 可重复测试

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗