Skip to content

加一天,为什么只过了 23 小时 ​

一个「24 小时后过期」的优惠券,用 plusDays(1) 计算过期时间。大多数日子没有问题,每年有一天它会提前一小时过期,另有一天会多活一小时。把代码改成 plusHours(24) 之后,另一个「每天上午 9 点推送」的功能在同样的两天里变成了 8 点和 10 点。

java.time 把「时间」分成了几种不同的东西:时间线上的一个点、日历上的一个日期、墙上时钟的读数、一段经过的时长、一段日历上的间隔。它们各有各的运算规则,混用时编译器不会报错,大多数输入下结果也碰巧正确。

本文在 JDK 25.0.4.1 上用固定的日期把这些差别逐个排出来。全部结果是确定的,实验见文末。

一、时间点与本地时间不是一回事 ​

Instant 是时间线上的一个点,全世界同时发生。LocalDateTime 是墙上时钟的读数,不带时区,它本身不对应任何确定的时间点。

text
时间点 2026-10-09T02:00:00Z
  在上海是     2026-10-09 10:00
  在洛杉矶是   2026-10-08 19:00      日期都不一样

本地时间 2026-10-09T10:00
  按上海解释是 2026-10-09T02:00:00Z
  按纽约解释是 2026-10-09T14:00:00Z   相差 12 小时

由此得到两条存储与传输的规则:

  • 「什么时候发生的」存时间点。 订单创建时间、日志时间、过期时间,用 Instant(或带偏移的 OffsetDateTime)表示,数据库里用带时区语义的类型或统一存 UTC。LocalDateTime.now() 取到的是运行这段代码的机器的墙上时间,换一台时区不同的机器、换一个容器镜像,同样的值就指向另一个时刻。
  • 「日历上的哪一天、几点」存本地时间加时区。 生日、账单日、营业时间、「每天 9 点」,这些本来就不是时间点。只存 LocalDateTime 而不存它属于哪个时区,等于丢了一半信息。

「今天」也依赖时区:同一个时间点,上海已经是 9 日,洛杉矶还是 8 日。按天统计、按天限额的逻辑要先确定用哪个时区的「天」。数据库一侧的同类问题见 统计到月底那天,数据少了一天。

二、一天不总是 24 小时 ​

实行夏令时的时区,每年有一天 23 小时、一天 25 小时。以纽约为例,2026 年 3 月 8 日凌晨 2 点直接跳到 3 点。

本地时钟(America/New_York)3/7 12:003/8 00:0002:00—03:00 不存在plusDays(1):本地时间不变,经过 23 小时到达 3/8 12:00plusHours(24):经过 24 小时,本地时间晚 1 小时到达 3/8 13:00秋天相反:11 月 1 日 01:30 出现两次,默认取先到的那一次,两次相差 60 分钟账单日、有效期、「每天 9 点」用日历运算(LocalDate、plusDays);超时、限流窗口、耗时用时间点与时长(Instant、Duration)把「一天」写成 86400 秒,在有夏令时的时区每年会错两次图中横轴按经过的小时数等距排列,消失的那一小时用红色标出
图 1 · 纽约 2026 年 3 月 8 日凌晨 2 点直接跳到 3 点,这一天只有 23 小时。从 3 月 7 日中午出发,plusDays(1) 保持本地时间不变,到达 3 月 8 日中午,实际只过了 23 小时;plusHours(24) 走满 24 小时,到达的是下午 1 点。两种写法都对,表达的是两件不同的事:「明天同一时刻」与「24 小时之后」。
操作结果
2026-03-07T12:00(纽约).plusDays(1)03-08 12:00,实际经过 23 小时
同一时刻 .plusHours(24)03-08 13:00
把不存在的本地时间 03-08 02:30 转成纽约时间得到 03:30,没有异常
把出现两次的本地时间 11-01 01:30 转成纽约时间默认取先出现的那一次(偏移 -04:00),另一次在 60 分钟之后
纽约 03-08 这一天的长度23 小时

plusDays 是日历运算:日期加一,墙上时间不变。plusHours 和 Duration 是时间线上的运算:经过固定的秒数。选哪一个取决于业务含义:

  • 「明天同一时间」「每月 1 号」「每天 9 点」:日历运算。在 ZonedDateTime 或 LocalDate 上加天、加月。
  • 「24 小时后过期」「30 分钟内有效」「超时 5 秒」:时长运算。在 Instant 上加 Duration。
  • 「这一天」的起止:用 date.atStartOfDay(zone) 到 date.plusDays(1).atStartOfDay(zone),不要用「零点加 86400 秒」。

国内的服务也会遇到这个问题:只要有海外用户、海外节点,或者处理的时间数据来自实行夏令时的地区。把时区写死成 UTC 或 Asia/Shanghai 可以避开本机的夏令时,但避不开业务数据里的。

本地时间不存在或出现两次时,java.time 默认按规则挑一个而不报错。需要明确处理时,用 ZoneRules.getValidOffsets 检查这个本地时间对应几个偏移。

三、月末与间隔 ​

加月份会把日期截到月末,而且不可逆。

text
2026-01-31 加 1 个月        = 2026-02-28
再加 1 个月                 = 2026-03-28
2026-01-31 直接加 2 个月    = 2026-03-31

「每月扣款日」如果用「上一次的日期加一个月」递推,1 月 31 日开始的订阅从 3 月起会永久变成 28 日。正确的做法是保存最初的日期(或「每月第几天」),每次从它出发加 n 个月。

Period 的 getDays() 不是总天数。 1 月 31 日到 3 月 1 日:

text
Period.between(...)            = P1M1D     getDays() = 1
ChronoUnit.DAYS.between(...)   = 29

Period 是「几年几月几天」三个分量,getDays() 只取最后一个分量。要总天数用 ChronoUnit.DAYS.between。同理 Duration 的 getSeconds() 是总秒数而 toMinutesPart() 是分量,两类方法的命名要看清。

时长用整数算会溢出。 30 * 24 * 60 * 60 * 1000 的每个操作数都是 int,结果是 -1702967296;把它当作过期毫秒数,得到的是一个过去的时间。用 Duration.ofDays(30).toMillis()(2592000000)或者在第一个数上加 L。

四、格式化字母 ​

格式字符串里字母的大小写是不同的字段,写错了大多数日期仍然显示正确:

写法含义出错的例子
YYYY按周计的年份2026-12-27 格式化成 2027-12-27(Locale.US)
hh12 小时制的小时15:05 格式化成 03:05
DD一年中的第几天2026-02-10 格式化成 2026-02-41

YYYY 的问题只在年末年初的几天出现:按美国的周定义,2026 年 12 月 27 日(周日)已经属于 2027 年的第一周。测试通常不会选这几天。正确的写法是 yyyy-MM-dd HH:mm:ss,或者直接用预定义的 DateTimeFormatter.ISO_LOCAL_DATE 等常量,不手写字母。

五、解析的宽与严 ​

DateTimeFormatter 默认的解析方式(ResolverStyle.SMART)会悄悄修正一部分不合法的日期:

输入格式与方式结果
2026-02-30yyyy-MM-dd,默认2026-02-28
2026-02-32yyyy-MM-dd,默认抛 DateTimeParseException
2026-02-10yyyy-MM-dd,STRICT抛 DateTimeParseException
2026-02-10uuuu-MM-dd,STRICT2026-02-10
2026-02-30uuuu-MM-dd,STRICT抛 DateTimeParseException

默认方式下,2 月 30 日被改成了 2 月 28 日,没有任何提示。用户填错的日期、上游传错的数据就这样变成了另一个合法的日期。

要让不存在的日期报错,用 withResolverStyle(ResolverStyle.STRICT)。这时有第二个陷阱:yyyy 表示的是「纪元内的年份」,严格模式下只有年份没有纪元无法确定日期,连合法的日期也解析失败。严格模式要配合 uuuu(年份)使用。

六、相等与旧 API ​

同一个时间点的两个 ZonedDateTime(一个是上海时间,一个是 UTC):equals 是 false,isEqual 是 true。equals 比较的是本地时间、偏移、时区三者;作为 Map 的键或放进 Set 去重时,同一时刻会被当成不同的值。需要按时间点比较或去重时先转成 Instant。相等的三套规则见 同一个金额,HashSet 里是两个,TreeSet 里是一个。

旧的 java.util.Date 构造器里年份从 1900 算起、月份从 0 算起:new Date(2026, 10, 9) 表示的是 3926 年 11 月 9 日。SimpleDateFormat 不是线程安全的。新代码不再使用这两个类;与旧接口交互时在边界上用 Date.toInstant()、Date.from(instant) 转换。

这些是 API 层面的区别。在业务模型里,先要分清一个时间字段是计划、发生还是记录的时刻,见计划、发生与记录三个时间。

七、落到代码里 ​

  1. 表示时刻用 Instant,表示日历概念用 LocalDate、LocalTime 加明确的 ZoneId;LocalDateTime 只在确实不关心时区的地方出现。
  2. 取当前时间通过注入的 Clock(Instant.now(clock)),不直接调用无参的 now()。测试可以把时钟固定在夏令时切换、年末、月末这些日子。
  3. 时长用 Duration,日历间隔用 Period 或 ChronoUnit.X.between,不用整数字面量相乘。
  4. 格式化用预定义常量或 uuuu-MM-dd'T'HH:mm:ss;解析外部输入用 STRICT。
  5. 周期性的日期从锚点出发计算,不从上一次的结果递推。
  6. 时区数据会更新(各地的夏令时规则会被修改)。JDK 自带一份,随 JDK 更新;长期运行的旧版本可以用 TZUpdater 单独更新。

配套实验

  • codesphere-labs/java/java-time-pitfalls:JDK 25.0.4.1(时区数据 2026b)上时间点与本地时间、夏令时前后的日历运算与时长运算、月末运算、Period 的分量、格式化字母、默认解析与严格解析、ZonedDateTime 的相等(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权