日期时间:Instant 单位、时区规则与解析边界
结论基线:API 语义以 Java SE 17 为准,实现分析以 OpenJDK 17u
jdk-17.0.12+7为准;时区规则来自运行时内置 tzdb(本轮 Oracle JDK 17.0.12 实测为 2024a),换发行版/版本需重跑。实验在本机 Oracle JDK 17.0.12(x86_64)实际运行。
时间模型:Instant、本地时间与 Clock
java.time 把"时间轴上的一个点"与"人读的墙钟时间"分开建模:
| 类型 | 表达 | 有无时区 | 典型用途 |
|---|---|---|---|
Instant | 距 epoch(1970-01-01T00:00:00Z)的秒+纳秒 | UTC 固定 | 存储、排序、跨服务传输 |
LocalDateTime | 年月日时分秒,无偏移 | 无 | 业务字段本身不带时区语义时 |
ZonedDateTime/OffsetDateTime | 本地时间 + 区域规则/固定偏移 | 有 | 展示、合规时间戳 |
Duration | 固定时长(秒/纳秒刻度) | 无 | 超时、间隔 |
Period | 日历量(年月日) | 无 | 日期字段加减 |
高频错误是单位混用:Instant.ofEpochMilli(1704067200) 把"秒"当毫秒,得到 1970-01-20 附近的时间,不抛任何异常。Instant 内部是 seconds + nanos 两个字段,秒与毫秒相差 10³ 倍,只有调用方知道刻度。
Instant 支持的加法单位在源码里枚举得很清楚:
原码连续摘录(仅统一公共缩进,截取方法局部):Instant.java,jdk-17.0.12+7,L850–L865。
public Instant plus(long amountToAdd, TemporalUnit unit) {
if (unit instanceof ChronoUnit) {
switch ((ChronoUnit) unit) {
case NANOS: return plusNanos(amountToAdd);
case MICROS: return plus(amountToAdd / 1000_000, (amountToAdd % 1000_000) * 1000);
case MILLIS: return plusMillis(amountToAdd);
case SECONDS: return plusSeconds(amountToAdd);
case MINUTES: return plusSeconds(Math.multiplyExact(amountToAdd, SECONDS_PER_MINUTE));
case HOURS: return plusSeconds(Math.multiplyExact(amountToAdd, SECONDS_PER_HOUR));
case HALF_DAYS: return plusSeconds(Math.multiplyExact(amountToAdd, SECONDS_PER_DAY / 2));
case DAYS: return plusSeconds(Math.multiplyExact(amountToAdd, SECONDS_PER_DAY));
}
throw new UnsupportedTemporalTypeException("Unsupported unit: " + unit);
}
return unit.addTo(this, amountToAdd);
}注意 DAYS 被支持且固定按 86400 秒折算——Instant.plus(Period.ofDays(1)) 不抛异常但语义是"固定一天"(跳过夏令时位移,见下文时区段);MONTHS/YEARS 这类无法折算成固定秒数的日历单位才抛 UnsupportedTemporalTypeException。本票实验先按"days 也拒绝"的预期写断言,实测翻车后修正为"月拒绝、天=86400 秒",这正是单位语义该有的验证方式。
Clock 是可注入的时间源:Clock.fixed(instant, zone) 让 Instant.now(clock)/LocalDate.now(clock) 可复现,是单测里替代 System.currentTimeMillis() 静态依赖的标准做法。与旧 API 的边界:java.util.Date 内部就是毫秒(Date.from(instant).toInstant() 往返无损,毫秒精度以内),LocalDateTime 只有配 ZoneId(local.atZone(zone))才能解析成 Instant——同一本地时间在不同时区对应不同时间轴点。
日期算术与日历边界
LocalDate 的加减按日历规则解析,两个典型分支:月末截断与范围溢出。
plusMonths 把"月"作为可变长度日历单位处理,落到 resolvePreviousValid:
原码连续摘录(仅统一公共缩进,截取方法局部):LocalDate.java,jdk-17.0.12+7,L1324–L1333。
public LocalDate plusMonths(long monthsToAdd) {
if (monthsToAdd == 0) {
return this;
}
long monthCount = year * 12L + (month - 1);
long calcMonths = monthCount + monthsToAdd; // safe overflow
int newYear = YEAR.checkValidIntValue(Math.floorDiv(calcMonths, 12));
int newMonth = Math.floorMod(calcMonths, 12) + 1;
return resolvePreviousValid(newYear, newMonth, day);
}原码连续摘录(仅统一公共缩进,截取方法局部):LocalDate.java,jdk-17.0.12+7,L476–L489。
private static LocalDate resolvePreviousValid(int year, int month, int day) {
switch (month) {
case 2:
day = Math.min(day, IsoChronology.INSTANCE.isLeapYear(year) ? 29 : 28);
break;
case 4:
case 6:
case 9:
case 11:
day = Math.min(day, 30);
break;
}
return new LocalDate(year, month, day);
}所以 2024-01-31.plusMonths(1) 是 2024-02-29(闰年),2025-01-31.plusMonths(1) 是 2025-02-28——不抛异常,静默截断。需要"月底"语义时用 YearMonth.atEndOfMonth() 显式表达,不要靠加一个月猜。需要"下个周一"这类规则用 TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY)。
溢出边界:LocalDate.MAX.plusDays(1) 抛 DateTimeException(超出支持范围),不是回绕到 MIN——与整数溢出的静默回绕不同,日期范围溢出是显式失败。
周与日历系统是另一类"不本地化就错"的边界:ISO 周制下 2021-01-01(周五)属于 2020-W53(WeekFields.ISO.weekBasedYear()=2020),用"日历年"做周年报表的分组会跨年错位;非 ISO 日历如 ThaiBuddhistDate.from(LocalDate.of(2024,1,1)) 的 YEAR 字段是 2567(佛历),LocalDate.from(thai) 才回到 ISO 2024。跨日历系统比较前必须先转换到同一系统。
时区规则:间隙、重叠与两种"一天"
ZoneId(如 Europe/Paris)携带 tzdb 规则。本地时间到时间轴的映射有三种情况,源码分支集中在这里:
原码连续摘录(仅统一公共缩进,截取方法局部):ZoneRules.java,jdk-17.0.12+7,L611–L618。
public List<ZoneOffset> getValidOffsets(LocalDateTime localDateTime) {
// should probably be optimized
Object info = getOffsetInfo(localDateTime);
if (info instanceof ZoneOffsetTransition) {
return ((ZoneOffsetTransition) info).getValidOffsets();
}
return Collections.singletonList((ZoneOffset) info);
}正常时刻一个偏移;间隙(gap,春季跳变如巴黎 2024-03-31 02:00→03:00)零个偏移——02:30 这个本地时间不存在;重叠(overlap,秋季回拨如 2024-10-27 03:00→02:00)两个偏移——02:30 对应两个不同的时间轴点。API 的默认策略各不相同,必须显式选择:
| 输入 | API | 结果 |
|---|---|---|
gap 中 02:30 + Paris | atZone | 顺移 1 小时得 03:30+02:00(默认策略,无异常) |
gap 中 02:30 + 显式 +01:00 | ZonedDateTime.ofStrict | 抛 DateTimeException(拒绝无效组合) |
overlap 中 02:30 + Paris | atZone | 默认取较早偏移 +02:00;withLaterOffsetAtOverlap() 换取 +01:00 |
同一 ZonedDateTime | withZoneSameInstant(UTC) vs withZoneSameLocal(UTC) | 前者同一时间轴点换时区;后者换墙钟、时间轴点改变 |
Period 与 Duration 的差异在 DST 边界最直观:巴黎 2024-03-30 12:00 加 Period.ofDays(1) 得 03-31 12:00(日历天,当天只有 23 小时,Duration.between = 23h);加 Duration.ofHours(24) 得 03-31 13:00(固定时长,墙钟显示 13 点)。"加一天"到底是哪种语义,是定时任务/对账窗口的必答项。tzdb 版本(本轮实测 ZoneRulesProvider.getVersions("Europe/Paris") 为 2024a)决定历史与未来规则——同一个"2024-03-31"在不同 tzdb 版本下规则一致,但更早年份或未来年份可能随 tzdb 更新变化,跨版本对账要固定 tzdb 或容忍规则变更。
解析与格式化:SMART、STRICT 与 Locale
DateTimeFormatter.ofPattern 解析有三个解析风格,差异集中在"字段组合如何变成日期":
原码连续摘录(仅统一公共缩进,截取方法局部):IsoChronology.java,jdk-17.0.12+7,L602–L622。
LocalDate resolveYearOfEra(Map<TemporalField, Long> fieldValues, ResolverStyle resolverStyle) {
Long yoeLong = fieldValues.remove(YEAR_OF_ERA);
if (yoeLong != null) {
if (resolverStyle != ResolverStyle.LENIENT) {
YEAR_OF_ERA.checkValidValue(yoeLong);
}
Long era = fieldValues.remove(ERA);
if (era == null) {
Long year = fieldValues.get(YEAR);
if (resolverStyle == ResolverStyle.STRICT) {
// do not invent era if strict, but do cross-check with year
if (year != null) {
addFieldValue(fieldValues, YEAR, (year > 0 ? yoeLong: Math.subtractExact(1, yoeLong)));
} else {
// reinstate the field removed earlier, no cross-check issues
fieldValues.put(YEAR_OF_ERA, yoeLong);
}
} else {
// invent era
addFieldValue(fieldValues, YEAR, (year == null || year > 0 ? yoeLong: Math.subtractExact(1, yoeLong)));
}yyyy 解析出 YEAR_OF_ERA:STRICT 下没有 era 字段就原样退回("不凭空发明时代"),最终无法组成日期而抛 DateTimeParseException;SMART/默认则直接当作 proleptic year。所以 ISO 解析的正确写法是 uuuu(proleptic year)配 withResolverStyle(ResolverStyle.STRICT)。
SMART 截断发生在日组合阶段:非闰年的 2 月 29 日 在 SMART 下被截成 2 月 28 日 并解析成功(默认风格),STRICT 下抛 DateTimeParseException。本票实验用同一输入 2025-02-29 证明两者分岔,再用 2024-02-29(真闰日)证明 STRICT + uuuu 恢复成功。
Locale 影响文本字段:模式 dd MMM uuuu 配 Locale.ENGLISH 能解析 29 Feb 2024;同一模式配 Locale.FRANCE 解析 29 févr. 2024 之外的英文输入会失败。服务端解析外部输入时必须显式 Locale.ROOT/指定 Locale + STRICT,不能依赖 JVM 默认 Locale(部署环境一变就碎)。
设计案例:跨时区对账与定时任务(算例,非实测)
负载假设:总部(Asia/Shanghai)与欧洲站点(Europe/Paris)各产生活动流水;财务在 T+1 00:00 上海时间跑对账;巴黎门店营业报表按"巴黎的自然日"分组;系统每小时跑一次清理任务,要求间隔 60 分钟。不变量:同一笔流水在两套报表中归属同一业务日(按各自站点时区);清理任务相邻两次执行间隔 ≥55 分钟(防重叠预算)。
存储选择 1:统一存 Instant(UTC)。 排序、去重、跨站点合并零歧义;展示与"业务日"分组在读取侧按站点 ZoneId 计算。成本:所有"按日"查询要带时区参数,索引按 UTC 聚簇会使"巴黎自然日"范围扫描分散;恢复:tzdb 更新只影响读取侧规则,历史数据不用迁移。责任:读取方正确携带 ZoneId。
存储选择 2:按站点存 LocalDateTime + 固定偏移列。 "巴黎自然日"查询直接索引扫描;成本:偏移列写错一次就整体错位,DST 切换日的本地时间有 gap/overlap 歧义(02:30 可能不存在或有两个时间轴点),历史规则变更需要重写数据。恢复:需要迁移脚本与对账,成本显著高于方案 1。两方案都以"业务日按站点时区定义"为不变量,选型差异在扫描效率与歧义恢复成本。
定时任务选择:Period/日历语义(LocalDate.plusDays)在 DST 日常得到 23/25 小时间隔,破坏"≥55 分钟"预算的下界需另测;Duration.ofHours(1) 恒为 60 分钟但墙钟时刻漂移(巴黎 03-31 从 12:00 漂到 13:00)。清理任务这种"间隔敏感、时刻不敏感"的场景选 Duration;门店开门这类"时刻敏感"的场景选 ZonedDateTime 日历语义并显式处理 gap。失败输入(gap 时刻、yyyy STRICT 解析)在入口拒绝并告警,不在批内静默顺移——顺移本身就是一次数据错位。以上是算例推导,没有实测集群数据。
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
| 换 JDK/发行版 | 内置 tzdb 版本;count/格式化实现行为以对应版本为准 |
| JVM 默认 Locale/时区变化 | 所有未显式指定 Locale/ZoneId 的解析与 now();容器化部署常为 UTC |
| 历史日期/未来日期 | tzdb 规则可能随版本修订;跨版本对账固定 tzdb 或显式声明规则版本 |
与旧 Date/Calendar 互操作 | 毫秒精度边界;Calendar 的月份从 0 起 |
| 数据库列类型 | TIMESTAMP 与 TIMESTAMP WITH TIME ZONE 的存取语义独立于本文 API,见存储课程 |
实验:输入、步骤、预期与边界
实验入口:labs/java-senior/run.sh library(StdLibraryProbe.java 的时间/时区/格式化场景)。
cd <仓库根>
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home \
bash labs/java-senior/run.sh libraryU05 关键实测(Oracle JDK 17.0.12,x86_64;完整命令、stdout/stderr、退出状态见本批证据):
PASS instant epoch second=1704067200/milli=1704067200000, fixed Clock/Date/explicit zone, month rejected, day=86400s Duration
PASS month-end/YearMonth/Adjusters, ISO 2021-01-01=2020-W53, Thai2567<->ISO2024, overflow rejected
PASS Paris gap rejected/recovered 03:30; overlap +02/+01 delta=3600s; Period=23h/Duration=24h; tzdb=[2024a]
PASS optional lazy fallback calls=0
PASS strict invalid leap day rejected, corrected leap date=2024-02-29
PASS formatter SMART normalizes, STRICT/uuuu and explicit Locale recover场景与预期:秒/毫秒单位混用得到不同 Instant(不抛异常);Clock.fixed 注入可复现时间;Instant.plus(Period.ofMonths(1)) 抛 UnsupportedTemporalTypeException,plus(Period.ofDays(1)) 成功且等于 plus(Duration.ofDays(1))(固定 86400 秒——此断言经一次实测修正,见证据 TDD 记录);月末截断与 YearMonth.atEndOfMonth;ISO 周年 2021-01-01=2020-W53;ThaiBuddhist 2567 与 ISO 2024 往返;LocalDate.MAX.plusDays(1) 拒绝;巴黎 gap 02:30 的 atZone 顺移 03:30、ofStrict 拒绝、修正后恢复;overlap 两个偏移差 3600 秒、earlier/later 可选;Period=23h 与 Duration=24h 分岔;SMART 截断 vs STRICT+uuuu 恢复;Locale.FRANCE 拒绝英文月名。
边界:tzdb 输出为本轮运行时内置版本(2024a),规则文本以 tzdb 为准;count/格式化的实现行为按版本记录;实验不覆盖全部时区(只测 Europe/Paris 与 Asia/Shanghai 两个代表)与全部日历系统(只测 ISO 与 ThaiBuddhist);性能与集群场景不在本实验范围。
追问链
第一层:为什么 Instant 不支持"加一个月"?
一个月的秒数随月份/年份/时区变化,Instant 是与时区无关的时间轴点,没有"日历"可解释"一个月"。这不是缺陷而是模型边界:要日历语义就在 ZonedDateTime/LocalDate 上操作,再把结果转回 Instant。混用两层模型是这类 bug 的根源。
第二层:atZone 对 gap 时刻顺移而不是抛异常,合理吗?
它是默认便利策略:02:30 不存在,按规则跳到 03:30 至少给出确定结果。但对账、计费这类不能接受"时刻被悄悄改写"的场景要先用 getValidOffsets(...).isEmpty() 检测或用 ofStrict 显式拒绝。API 提供"宽松"与"严格"两档,选哪档是业务决定。
第三层:为什么解析要用 uuuu 而不是 yyyy?
yyyy 是 YEAR_OF_ERA:STRICT 风格下没有 era 字段就不发明时代(源码分支见上文),单独使用无法解析;SMART 下它退化为普通年份,掩盖问题。ISO 日期(无时代概念)配 STRICT 的标准写法是 uuuu-MM-dd。SimpleDateFormat 时代的 yyyy-MM-dd 直觉搬到 DateTimeFormatter + STRICT 就会踩这个分支。
第四层:评审一个跨时区模块时按什么顺序提问?
- 时间轴点存在哪(Instant/UTC 列)还是墙钟存在哪?2. "业务日"按哪个 ZoneId 定义,DST 切换日的分组规则?3. 解析外部时间用了什么 pattern/resolver/Locale,是否显式?4. 定时/超时用 Duration 还是日历语义,DST 日的间隔下界?5. tzdb 版本变更与历史规则修订谁负责复核?6. 失败输入(gap、STRICT 拒绝)是拒绝告警还是静默顺移?
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Java SE 17 java.time 包描述 | Java SE 17 | 时间模型与不可变性总纲 |
| Java SE 17 Instant | Java SE 17 | epoch 单位与支持单位 |
| Java SE 17 ZonedDateTime | Java SE 17 | gap/overlap 解析策略、earlier/later |
| Java SE 17 ZoneRules | Java SE 17 | 三种映射情况与 getValidOffsets |
| Java SE 17 DateTimeFormatter | Java SE 17 | ResolverStyle 与 pattern 字母语义 |
| OpenJDK 17u Instant 源码 | jdk-17.0.12+7 | plus 单位分支(L850) |
| OpenJDK 17u LocalDate 源码 | jdk-17.0.12+7 | plusMonths(L1324)、resolvePreviousValid(L476) |
| OpenJDK 17u ZoneRules 源码 | jdk-17.0.12+7 | getValidOffsets 分支(L611) |
| OpenJDK 17u IsoChronology 源码 | jdk-17.0.12+7 | STRICT era 分支(L602) |
本地 StdLibraryProbe 实验 | Oracle JDK 17.0.12(tzdb 2024a) | 本轮时间/时区/格式化实测 |
访问日期:2026-10-24。源码行号以 jdk-17.0.12+7 标签为准;时区规则文本属于 tzdb(本轮 2024a),历史年份规则可能随 tzdb 版本修订。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。