跳到主要内容
约 12 分钟3 张卡

java.time

java.time 用不同类型表示时间线上的瞬间、本地日期时间、固定偏移量和地区时区。选择类型时,需要先分清数据表示的是全球同一时刻,还是某地日历上的时间。

本文先从类型选择入手,再解释夏令时转换、日期运算和格式解析。最后说明怎样存储时间、设计跨时区接口,以及用固定时钟测试时间边界。

1. 面试核心​

先记住这几条,再读正文
  • 瞬间与本地时间。 Instant 表示时间线上的瞬间,LocalDateTime 保存本地日期时间,确定瞬间还需偏移量或时区。
  • 偏移与地区规则。 ZoneOffset 表示固定偏移量,地区 ZoneId 按日期和时区规则确定偏移量。
  • 重复任务。 按当地日历重复执行的任务需保存本地规则与 ZoneId,并据此计算每次执行的瞬间。卡 · OffsetDateTime 与 ZonedDateTime
  • 格式化线程安全。 SimpleDateFormat 共享可变解析状态会产生数据竞争,DateTimeFormatter 不可变且线程安全。卡 · DateTimeFormatter
  • 时长与日历间隔。 Duration 用秒与纳秒表示经过的时长,Period 用年、月、日表示日历上的间隔。

2. Instant​

Instant 表示 UTC 时间线上的一个点,适合记录已经发生的事件:

Instant createdAt = Instant.now();

这里的 createdAt 记录调用时的瞬间。订单创建时间、消息发送时间和审计日志时间都适合这样保存。两个系统使用同一时间尺度后,就能比较事件先后,并计算经过的时长。

要展示“当地是星期几、几点”,还需要用户所在的时区。Instant 只记录瞬间,所以展示时要将它转换为当地日期时间:

ZoneId shanghai = ZoneId.of("Asia/Shanghai");
ZonedDateTime localView = createdAt.atZone(shanghai);

同一个 Instant 在上海和纽约会显示不同的本地日期时间,但仍是同一个瞬间。

3. LocalDateTime​

生日、开门时间和用户输入的日期,关心的是日历或钟表上的数值。LocalDate 保存年月日,LocalTime 保存时分秒,LocalDateTime 则包含两部分:

LocalDate date = LocalDate.of(2026, 8, 30);
LocalTime time = LocalTime.of(9, 30);
LocalDateTime meeting = LocalDateTime.of(date, time);

上面的 meeting 表示 2026-08-30 09:30,使用 ISO 日历,但没有时区。上海和纽约都可以出现这个本地时间,却对应不同瞬间。这三种类型分别适合以下本地日期或时间需求:

  • 生日等只关心日历日期的数据,可以用 LocalDate 保存。
  • 每天 09:30 开门等本地规则,可以用 LocalTime 表示钟表时间。
  • 用户刚输入、尚未选择时区的日期时间,可以暂存为 LocalDateTime。

把输入的 LocalDateTime 转成瞬间时,需要明确地区或偏移量。擅自把它当作 UTC,会误解用户输入。若隐式使用服务器时区,部署地区变化还会改变转换结果。

4. OffsetDateTime 与 ZonedDateTime​

本地日期时间加上 UTC 偏移量,就能确定一个瞬间。偏移量表示当地时间比 UTC 快或慢多少,OffsetDateTime 把两者保存在一起:

OffsetDateTime value = OffsetDateTime.parse("2026-08-30T09:30:00+08:00");

这里的 +08:00 表示比 UTC 快 8 小时,所以这条记录对应 UTC 的 01:30。偏移量说明了当时的时差,却没有记录它来自哪个地区。

ZonedDateTime 包含 ZoneId,例如 Asia/Shanghai 或 Europe/Paris:

ZonedDateTime parisMeeting = ZonedDateTime.of(
LocalDateTime.of(2026, 10, 25, 2, 30),
ZoneId.of("Europe/Paris")
);

parisMeeting 除了保存日期时间,还保留了巴黎的地区时区。地区时区的规则包含历史偏移和已知的未来变化,可以据此处理夏令时。

比如,每月按纽约当地时间执行的任务,应保存日历规则和 America/New_York。夏令时切换后,当地时间与 UTC 的时差会变化。保留地区规则,才能重新计算下一次执行时刻。

4.1 ZoneOffset 与 ZoneId​

固定偏移量适合协议中的明确时差,地区时区适合遵循当地日历的安排。ZoneOffset.ofHours(8) 始终是 +08:00。而 ZoneId.of("Europe/Paris") 根据日期和时区数据库规则确定偏移量,所以冬夏可以不同。

5. 本地时间解析​

把本地时间转换为瞬间,要先按地区规则确定偏移量。夏令时会让时钟跳过或重复一段时间,因此输入并非总能唯一对应一个瞬间:

LocalDateTime local = LocalDateTime.parse(
"2026-03-29 02:30",
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm")
);

ZonedDateTime zoned = local.atZone(ZoneId.of("Europe/Paris"));
Instant instant = zoned.toInstant();

夏令时切换会产生间隙与重叠:

  • 间隙:某段本地时间不存在,时钟直接向前跳。
  • 重叠:某段本地时间出现两次,对应两个不同瞬间。

atZone 遇到间隙时,会把本地时间向后推移一个间隙长度。上面的巴黎时间 02:30 就会调整为 03:30。遇到重叠时,它默认选择时钟回拨前的偏移,对应两个候选瞬间中较早的一个。

支付截止、航班和定时任务需要明确自己的解析策略。间隙时间可以向后推移,也可以拒绝输入,让用户改选有效时间。若业务只关心固定执行时刻,可以直接约定一个 Instant。重叠时间则需要明确选择较早还是较晚的偏移。

校验时可调用 ZoneRules.getValidOffsets(localDateTime),根据返回的有效偏移数量处理:

  • 返回 0 个偏移,说明输入落在间隙中。
  • 返回 1 个偏移,说明输入可以唯一确定瞬间。
  • 返回 2 个偏移,说明输入落在重叠区间,需要选择其中一个。

6. 时区转换​

给不同地区的用户展示同一事件时,应保留瞬间,只改变显示时区。withZoneSameInstant 就用于这种转换:

ZonedDateTime shanghai = instant.atZone(ZoneId.of("Asia/Shanghai"));
ZonedDateTime newYork = shanghai.withZoneSameInstant(
ZoneId.of("America/New_York")
);

上海和纽约显示的本地日期时间不同,但两者的 toInstant() 相同,所以事件发生的时刻保持不变。

withZoneSameLocal 则尽量保留原来的年月日和时分秒,再按目标时区解释。若两个时区的偏移不同,对应瞬间也会改变。目标本地时间落在间隙时,还会按间隙规则调整。

因此,普通展示转换使用 withZoneSameInstant。只有迁移时区标错的数据,或明确要保留当地钟表时间时,才考虑 withZoneSameLocal。

7. Duration 与 Period​

计算两个事件相隔多久,与计算生日相隔几年,需要不同的时间单位。Duration 用秒和纳秒表示时间线上的时长:

Duration elapsed = Duration.between(startInstant, endInstant);

elapsed 表示两个瞬间之间经过的时间。Period 则按年、月、日表示日历差异,例如从生日到今天的间隔:

Period age = Period.between(birthDate, today);

age 保存年月日数量,适合按日历理解年龄。跨一小时的夏令时切换时,日历一天会变成 23 或 25 小时。因此,“24 小时后”使用 Duration.ofHours(24)。而“当地明天同一时刻”应在带时区的时间上使用 plusDays(1),遇到间隙时再按规则调整。

8. DateTimeFormatter​

时间文本需要同时约定格式和非法日期的处理方式。下面的格式器使用严格解析,拒绝 2 月 30 日这类日期:

private static final DateTimeFormatter ORDER_TIME =
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")
.withResolverStyle(ResolverStyle.STRICT);

DateTimeFormatter 不可变且线程安全,所以可以像上面一样作为静态常量共享。SimpleDateFormat 内部会修改解析状态,多线程共享同一实例时需要同步。

选择格式时,要区分以下三种年份符号:

  • uuuu 表示连续年份,包含 0 和负数年份,适合直接解析完整日期。
  • yyyy 表示纪元内年份,需要结合公元前或公元后的纪元信息理解。严格解析时,只有 yyyy-MM-dd 会缺少纪元信息。
  • YYYY 表示基于周的年份,年末年初可与日历年不同,所以普通日期格式应使用前两种符号。

协议优先使用 ISO 8601 格式,例如:

String encoded = DateTimeFormatter.ISO_INSTANT.format(createdAt);
Instant decoded = Instant.parse(encoded);

上面的编码保留了瞬间,解析后得到与 createdAt 相等的值。界面可以使用自定义展示格式,系统内部则保留含义明确的时间类型。这样即使换一种显示格式,也能继续正确比较和计算时间。

9. Clock 与时间测试​

到期判断需要读取当前时间,测试却需要稳定地重现某个时刻。Clock 提供可替换的时钟,让业务代码和测试使用不同的时间来源:

final class ExpirationPolicy {
private final Clock clock;

ExpirationPolicy(Clock clock) {
this.clock = clock;
}

boolean expired(Instant deadline) {
return !Instant.now(clock).isBefore(deadline);
}
}

expired 在当前时间达到或超过截止时刻时返回 true。它通过传入的 Clock 读取时间,所以测试可以使用固定时钟:

Clock clock = Clock.fixed(
Instant.parse("2026-08-30T01:30:00Z"),
ZoneOffset.UTC
);

这个时钟始终返回同一个瞬间。把固定值设在到期、跨日或夏令时切换前后,就能稳定验证边界。测试因此无需等待真实时间,也无需修改系统时间。

10. 存储与接口​

时间存入数据库或传给其他系统后,仍需保留原来的业务含义。已发生事件和未来日程关注的内容不同,存储方式也要分别选择。

10.1 已发生事件​

已发生事件需要记录确定的时刻,适合保存 Instant 或具有明确 UTC 语义的数据库时间戳。API 输出带 Z 或显式偏移量,其中 Z 表示 UTC。展示时再按用户时区转换,各系统就能指向同一事件时刻。

10.2 未来日程​

未来日程需要保留当地日历上的约定。比如,“每周一纽约时间 09:00 执行”需要保存以下日程信息:

  • 保存本地日期或重复规则,明确在哪些日历日期执行。
  • 保存本地时间,明确当天几点执行。
  • 保存地区 ZoneId,用于取得当地时区规则。
  • 保存间隙和重叠的处理策略,明确特殊日期怎样选择瞬间。

每次计算下一执行时间时,都要结合本地规则和地区时区。若只保存一次算出的 UTC 瞬间,就会丢失“每周一 09:00”这个约定。已算好的瞬间仍是固定时点,但时区规则更新后,需要重新检查它是否符合原来的当地日程。

10.3 数据库字段​

TIMESTAMP、DATETIME 与 JDBC 映射的时区语义因数据库产品而异,字段名称不足以确定转换规则。保存和读取时,需要核对以下时间转换环节:

  • JDBC 驱动怎样映射 Java 时间类型。
  • 连接配置采用哪个时区。
  • 数据库会话采用哪个时区。
  • ORM,也就是对象关系映射框架,怎样读写这个字段。

最后用不同时区的连接做往返测试,确认读取结果仍表示原来的瞬间或本地时间。

一个简单判断
  • 已发生事件优先使用 Instant。
  • 只有本地日期或时间含义时,使用 LocalDate、LocalTime。
  • 未来安排需要遵循某地规则时,保留 ZoneId。

11. 夏令时解析与日历运算​

下面用巴黎的夏令时切换验证前面的解析规则。示例采用 Europe/Paris 的 2026 年规则,分别观察间隙与重叠:

ZoneId paris = ZoneId.of("Europe/Paris");
ZonedDateTime gap = LocalDateTime.of(2026, 3, 29, 2, 30).atZone(paris);
System.out.println(gap); // 2026-03-29T03:30+02:00[Europe/Paris]
ZonedDateTime overlap = LocalDateTime.of(2026, 10, 25, 2, 30).atZone(paris);
System.out.println(overlap.getOffset()); // +02:00
System.out.println(overlap.withLaterOffsetAtOverlap().getOffset()); // +01:00

比较输出时,需要注意本地时间和偏移量的变化:

  • 3 月 29 日的 02:30 落在间隙中,所以被推至 03:30。
  • 10 月 25 日的 02:30 出现两次,默认选择回拨前的 +02:00。
  • withLaterOffsetAtOverlap() 改选回拨后的 +01:00,对应晚一小时的瞬间。

需要拒绝或让用户选择的场景,可以先调用 getValidOffsets,再执行前面约定的业务策略。

日期运算也会遇到边界。plusDays(1) 按本地日期前进,在巴黎跨上述切换时可相差 23 或 25 小时。plus(Duration.ofHours(24)) 则沿时间线增加整整 24 小时,所以当地钟表时间会随偏移变化。

月份运算还需要处理目标日期是否存在。LocalDate.of(2026, 1, 31).plusMonths(1) 得到 2 月 28 日,因为 2 月没有 31 日。API 会把日期调整到目标月最后一个有效日期。

12. 常见问题​

选择时间类型后,还容易在输入、默认时区和精度转换处出错。下面说明这些边界怎样影响结果。

12.1 使用时间戳后还需要处理时区吗?​

需要,解析输入、数据库转换和界面展示都要明确时区。epoch 值记录相对纪元起点经过的时间,常用起点是 1970-01-01T00:00:00Z。它能确定瞬间,但无时区文本仍需先补足地区或偏移量,才能转换为正确的时间戳。

12.2 可以统一使用 LocalDateTime 吗?​

12.3 服务器默认时区可以作为业务时区吗?​

12.4 转成 epoch 毫秒会丢失精度吗?​

13 · 面试题卡片​

13.1 SimpleDateFormat 为什么线程不安全,应该怎样替代​

出现公司:CVTE、三七互娱

考察重点

  • 解释多个线程怎样互相覆盖同一个格式器的可变解析状态。
  • 比较加锁、每次新建和 ThreadLocal 隔离实例的成本。
  • 说明 DateTimeFormatter 为什么能够安全共享。

相关内容:DateTimeFormatter。

参考回答

SimpleDateFormat 会修改内部解析状态,多线程共享时会互相覆盖,因此新代码优先改用 DateTimeFormatter。

旧代码可通过实例隔离或同步处理共享状态:

  • 每次调用都创建实例,让不同调用使用各自的状态,代价是增加对象分配。
  • 通过锁保护共享实例,让调用依次执行,代价是串行等待和锁竞争。
  • 用 ThreadLocal 为每个线程保存实例,同时管理线程复用时的清理和实例生命周期。

DateTimeFormatter 不可变且线程安全,可以直接共享。迁移 Date、Calendar 时,还应按数据含义选择类型。事件时间用 Instant,本地日期用 LocalDate,遵循地区规则的日期时间用 ZonedDateTime。

13.2 跨时区定时任务怎样保证执行时间正确​

出现公司:蚂蚁集团

考察重点

  • 区分固定瞬间执行与按当地日历重复执行。
  • 解释 ZoneId 怎样参与计算,并明确夏令时间隙和重叠的处理策略。
  • 说明时区规则更新或进程停机后,怎样重新计算并避免重复执行业务。

相关内容:OffsetDateTime 与 ZonedDateTime、本地时间解析、存储与接口。

参考回答

固定时点执行的任务保存 Instant,按当地日历重复执行的任务保存本地时间、重复规则和地区 ZoneId。

重复任务每次按所用时区数据库的规则计算下一瞬间。间隙时是否推迟或拒绝,重叠时选哪个偏移,都要由业务约定。时区规则更新后,还要重新核对已经算出的未来计划。

调度系统需持久化任务状态,并记录计划时间与实际时间。执行还需保持幂等,也就是同一任务重复触发时只产生一次预期业务效果。进程停机后,再按业务规则决定跳过还是补跑错过的任务。

13.3 夏令时切换时,atZone 和增加一天分别怎样处理?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释 atZone 怎样处理间隙和重叠,并说明增加一个日历日为何会与增加 24 小时不同。

相关内容:夏令时解析与日历运算。

参考回答

atZone 按间隙长度向后推移本地时间,重叠时默认选择回拨前的偏移。

增加一天使用本地日历规则,增加 24 小时则使用时间线上的固定时长。

以 Europe/Paris 的 2026 年规则为例:3 月 29 日的 02:30 落在间隙中,atZone 将它调整为 03:30;10 月 25 日的 02:30 出现两次,默认选 +02:00,也可用 withLaterOffsetAtOverlap() 选择 +01:00。

plusDays(1) 跨一小时的夏令时切换时,经过时长可为 23 或 25 小时。plus(Duration.ofHours(24)) 始终增加 24 小时,因此当地钟表时间会不同。

14. 参考资料​