读完这篇,你能做什么
能为日期、时刻和时长选类型,并写出独立于机器默认时区的计算。
学习理由、准备要求与知识点
为什么学它
时间类型和时区约定影响到期、统计和跨地区数据的一致解释。
先读这些内容
知识点清单
LocalDate/LocalTimeLocalDateTime无时区- 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"]按顺序看,每一步做了什么
- 选择表达日期、当地时间或瞬间的类型。
- 保存地区时区而非猜测固定偏移。
- 区分日历量与时间线时长。
- 为月末、缺口和重叠制定策略。
- 注入 Clock 并测试固定历史时间与边界。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/time/LocalDate.java · java.time.LocalDate#plusMonths(long)
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
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 就推断跨时区调度已经完成。
代码先调用谁,再调用谁
plusMonths将年和月转为连续月份计数- 增加偏移并检查新年份范围
floorDiv与floorMod恢复年和月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 项时,每个已生成日期都直接依据同一个原始锚点及其月份偏移计算。
每项执行固定范围的日历字段计算并追加一次,没有逐日扫描月份;不计输出时只保留锚点、下标和当前值。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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可重复测试
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
分别为生日、每天 09:00 的提醒、日志事件和未确定时区的预约选择时间类型,说明每种值缺少哪些信息才能定位时间线上的时刻。
- 提交内容
- 类型选择表与转换前置条件。
- 怎样算完成
- 日期时间与瞬时点区分
- 不默认所有值都属于系统时区
练到的知识点LocalDate/LocalTime · LocalDateTime 无时区 · Instant 时间线
- 作业 02排错实验
用 Europe
/Berlin检查 2024-03-31 02:30 与 2024-10-27 02:30 的有效偏移,记录所用 JDK 与规则;为预约系统制定冲突与缺口的处理合同。- 提交内容
- Java 查询、自写规则边界检查和业务合同。
- 怎样算完成
- 地区规则由 API 证据支持
- 不隐式接受未知歧义
练到的知识点ZoneId 与地区规则 · 夏令时重叠和缺口
综合应用
- 作业 03代码推演
选取跨夏令时转换的日期,分别增加一天 Period 与二十四小时 Duration;先定义比较的是本地时间还是实际经过时长,再推演并运行验证。
- 提交内容
- 时间线图、比较合同与实际记录。
- 怎样算完成
- 计量语义明确
- 时区前提记录完整
练到的知识点Duration 与 Period
- 作业 04排错实验
在年末年初附近选择至少六个日期,比较不同年份格式模式及解析方式,记录本机 Locale;设计一个不会误判的日志日期格式验收集。
- 提交内容
- 格式实验、自写往返测试及运行证据。
- 怎样算完成
- Locale 与模式都被固定
- 年边界样本完整
练到的知识点DateTimeFormatter 年份模式
- 作业 05编程实践
实现优惠券有效性函数,输入固定 Clock、开始 Instant 与到期 Instant;自行规定区间端点,测试开始前、开始时、到期前和到期时,禁止依赖长时间 sleep。
- 提交内容
- Java 实现、自写边界和无效区间测试、运行证据。
- 怎样算完成
- 时间可注入可重复
- 端点合同一贯
- 地区展示与判定时间线分开
练到的知识点Instant 时间线 · ZoneId 与地区规则 · Clock.fixed 可重复测试
- 作业 06编程实践
实现把注入 Clock 的当前时刻显示为用户指定地区日期的函数,用户地区与系统默认地区可不同;测试同一时刻下至少三个地区及非法地区标识。
- 提交内容
- Java 实现、自写午夜附近/非法输入测试和运行证据。
- 怎样算完成
- 时钟与地区分别可控
- 不依赖运行当天的偶然值
练到的知识点ZoneId 与地区规则 · Clock.fixed 可重复测试
- 作业 07排错实验
同一预约显示测试在两台机器结果不同。使用固定时钟和显式地区逐项检查默认地区、规则版本与格式设置,并保留修复前后对照。
- 提交内容
- 最小实验、环境差异表与稳定复现记录。
- 怎样算完成
- 每个环境因素单独改变
- 测试断言基于事先声明的合同
练到的知识点ZoneId 与地区规则 · Clock.fixed 可重复测试
进阶挑战 · 掌握前置后选做
- 作业 08设计评审
完成任务调度基础后:用户希望每天当地 02:30 执行提醒且一年内会旅行。设计地区变更、重复时刻、缺失时刻和补执行政策。
- 提交内容
- 调度合同、状态记录与时间规则测试矩阵。
- 怎样算完成
- “每天”含义明确
- 政策在规则变化时仍可解释
练到的知识点LocalDateTime 无时区 · ZoneId 与地区规则 · Duration 与 Period · 夏令时重叠和缺口
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 09思维拓展 · 条件变化
用户原设东京地区,出差后修改为纽约;过去日志与未来提醒是否都跟随改变?设计两种产品合同并给出能区分它们的固定时钟样本。
- 提交内容
- 历史/未来时间模型和测试矩阵。
- 怎样算完成
- 显示地区与事件身份分别定义
- 修改时刻与事件时刻可追踪
练到的知识点ZoneId 与地区规则 · Clock.fixed 可重复测试
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Java 17 API:package-summary (新标签页)↗
从类型表区分 Instant、
LocalDate、ZonedDateTime、Duration 与 Period。 - 实践参考文档英文
Java 17 API:DateTimeFormatter (新标签页)↗
查阅解析模式、严格解析与 uuuu
/yyyy的年份含义。 - 深入规范英文
Java 17 语言规范:类、方法与记录类 (新标签页)↗
按需阅读构造器、方法重载、static
/final、枚举和 record 的定义与约束。 - 实践实践指南英文
Java 时间:明天同一时刻,不一定是 24 小时后 (新标签页)↗
固定 America
/New_York的 2024-03-09 12:00,分别加一天和加 24 小时。再把起点改成 2024-11-02 12:00。记录终点、UTC 偏移和两个 Instant 的时间差;不要从这两个历史例子推断所有地区都会切换夏令时。 文档免费公开。练习按 Java 21 API 编写;若用其他 JDK,请先核对相应版本文档。记录日期、区域 ID 和 JDK 版本,因为时区数据会更新。 - 实践实践指南中文
Python 时区:同一个钟点为什么会出现两次 (新标签页)↗
先照着洛杉矶的例子列出 fold=0 与 fold=1 的 UTC 偏移,再把两个时刻转为 UTC 比较。要算实际间隔,先明确比较的是 UTC 时刻,别把“当地日期加一天”直接读成固定 24 小时。 中文文档免费公开。zoneinfo 是标准库,但不自带 IANA 时区数据;Windows 等环境可能需要 tzdata 包。系统数据和 tzdata 都缺失时会抛出
ZoneInfoNotFoundError。只读示例不需要安装依赖。 - 深入参考文档英文
OpenJDK 17u java.time.LocalDate (新标签页)↗
定位
plusMonths的连续月份计数、向下除模与有效日修正。 - 深入参考文档英文
Java17 LocalDate API (新标签页)↗
阅读
plusMonths对月末的调整规则与日期范围。 - 深入参考文档英文
Java17 ZonedDateTime API (新标签页)↗
阅读缺口、重叠和默认偏移选择。
- 深入参考文档英文
Java17 Clock API (新标签页)↗
学习固定时钟和时区注入以编写可重复测试。