加一天,为什么只过了 23 小时
一个「24 小时后过期」的优惠券,用
plusDays(1)计算过期时间。大多数日子没有问题,每年有一天它会提前一小时过期,另有一天会多活一小时。把代码改成plusHours(24)之后,另一个「每天上午 9 点推送」的功能在同样的两天里变成了 8 点和 10 点。
java.time 把「时间」分成了几种不同的东西:时间线上的一个点、日历上的一个日期、墙上时钟的读数、一段经过的时长、一段日历上的间隔。它们各有各的运算规则,混用时编译器不会报错,大多数输入下结果也碰巧正确。
本文在 JDK 25.0.4.1 上用固定的日期把这些差别逐个排出来。全部结果是确定的,实验见文末。
一、时间点与本地时间不是一回事
Instant 是时间线上的一个点,全世界同时发生。LocalDateTime 是墙上时钟的读数,不带时区,它本身不对应任何确定的时间点。
时间点 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 点。
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 检查这个本地时间对应几个偏移。
三、月末与间隔
加月份会把日期截到月末,而且不可逆。
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 日:
Period.between(...) = P1M1D getDays() = 1
ChronoUnit.DAYS.between(...) = 29Period 是「几年几月几天」三个分量,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) |
hh | 12 小时制的小时 | 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-30 | yyyy-MM-dd,默认 | 2026-02-28 |
2026-02-32 | yyyy-MM-dd,默认 | 抛 DateTimeParseException |
2026-02-10 | yyyy-MM-dd,STRICT | 抛 DateTimeParseException |
2026-02-10 | uuuu-MM-dd,STRICT | 2026-02-10 |
2026-02-30 | uuuu-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 层面的区别。在业务模型里,先要分清一个时间字段是计划、发生还是记录的时刻,见计划、发生与记录三个时间。
七、落到代码里
- 表示时刻用
Instant,表示日历概念用LocalDate、LocalTime加明确的ZoneId;LocalDateTime只在确实不关心时区的地方出现。 - 取当前时间通过注入的
Clock(Instant.now(clock)),不直接调用无参的now()。测试可以把时钟固定在夏令时切换、年末、月末这些日子。 - 时长用
Duration,日历间隔用Period或ChronoUnit.X.between,不用整数字面量相乘。 - 格式化用预定义常量或
uuuu-MM-dd'T'HH:mm:ss;解析外部输入用 STRICT。 - 周期性的日期从锚点出发计算,不从上一次的结果递推。
- 时区数据会更新(各地的夏令时规则会被修改)。JDK 自带一份,随 JDK 更新;长期运行的旧版本可以用 TZUpdater 单独更新。
配套实验
- codesphere-labs/java/java-time-pitfalls:JDK 25.0.4.1(时区数据 2026b)上时间点与本地时间、夏令时前后的日历运算与时长运算、月末运算、
Period的分量、格式化字母、默认解析与严格解析、ZonedDateTime的相等(验证记录)
参考资料
- JDK 25 API:java.time 包说明
- JDK 25 API:DateTimeFormatter(模式字母
u、y、Y、D、h、H的定义与解析方式) - JDK 25 API:ResolverStyle
- JDK 25 API:ZonedDateTime(间隙与重叠时的处理规则)
- JDK 25 API:Clock
- IANA 时区数据库
- Oracle:Timezone Updater Tool