时间处理是移动端最容易出线上事故的一块:一个 SimpleDateFormat 静态字段被多线程共享导致解析出错误时间,一个"昨天"的判断在跨时区时差一天,一个本地化日期直接用字符串截取。这些 bug 隐蔽、低频、难复现,所以面试里问时间 API,考的不是 API 列表,而是候选人知不知道时间的三类语义。
先把结论放在前面:Java 8 的 java.time 把时间分成三类——本地日期时间(LocalDateTime,没有时区概念,只有墙上时间)、带偏移的时间(OffsetDateTime,用 +08:00 这样的固定偏移)、带时区的时间(ZonedDateTime,用 Asia/Shanghai 这样的区域时区规则,能正确处理夏令时)。 业务上真正需要的是第三种,日期展示常用第一种,而老代码的 Date 落在"系统默认时区的瞬时点"这个模糊地带上。
机制拆解
理解新 API 的关键,是把 Instant 当成坐标原点:它是时间轴上的一个点(UTC 时间戳 + 纳秒精度),不带任何时区信息。 OffsetDateTime 与 ZonedDateTime 都是"某个时区下观察到的 Instant",区别在于 ZonedDateTime 额外持有 ZoneId,知道规则(历史上夏令时怎么切换、某时区后来改过偏移),所以它能算"下一个切换点前的最后一个小时"这类问题。
格式化和解析靠 DateTimeFormatter。它是不可变且线程安全的,可以做成静态常量复用。这一点直接对应了老 API 的最大坑:SimpleDateFormat 内部是可变状态(一个 Calendar 字段),共享实例时线程互相改状态,就会出现解析出别人那一天的结果。 SimpleDateFormat 的 format() 与 parse() 都不是线程安全的,Android 早期版本里很多人用 ThreadLocal 规避,新 API 直接从设计上解决了这个问题。
Period 与 Duration 也常被问:Period 是日期量(年/月/日),Duration 是时间量(秒/纳秒),用 between 求差。 前者用于"加一个月是几号"的日历语义,后者用于"耗时多久"的物理量语义——"2026-01-31 加一个月"在 Period 语义下会得到 2 月 28 日,而用 Duration.ofDays(30) 则是 1 月 31 日加 30 天,这是两种截然不同的业务含义。
这些坑的正确绕法
最常见的坑是把 LocalDateTime 当成"当前时间"存库,丢掉了时区信息。 表现是服务部署到另一个时区后,所有时间都整体偏移几个小时;或者用户出国后看到的历史记录时间全乱。根因是 LocalDateTime 只表示"墙上时间这个组合",它与时区的关系在写入时就没有被固定。 对策是:存储用 Instant 或 UTC 毫秒数,展示时才转成 ZoneId 下的 LocalDateTime。这一条是时间处理的第一纪律。
其次是跨时区比较两个 LocalDateTime 的大小,结果取决于解析时的时区假设。 因为它没有时区,语义天然模糊。正确做法是先把两个值各自绑定到明确的 ZoneId 变成 ZonedDateTime(或转 Instant),再比较。
还有一个更隐蔽的坑:SimpleDateFormat 的 YYYY 与 yyyy 混用,年份跨错。 YYYY 是 week-based-year(周所属的年),年末年末那几天会给出下一年的值;yyyy 才是日历年。MM 是月、mm 是分钟,大小写写反是另一个高频事故。 Android 侧还有一个 Locale 陷阱:不传 Locale 时格式化结果随系统语言变化,阿拉伯语系与部分南亚语言的数字与月份名会被本地化,导致服务端校验失败或日志难以比对。规矩是:格式化/解析时显式传 Locale.CHINA 或 Locale.ROOT。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 格式化器不可变且线程安全,可以放心做成静态常量复用
private static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.CHINA);
// 存储:统一用 Instant / UTC 毫秒
long ts = Instant.now().getEpochSecond(); // 落库字段
void save(Instant at) {
db.insert(at.getEpochSecond()); }
// 展示:绑定到目标时区再格式化
String show = FMT.format(LocalDateTime.ofInstant(
Instant.ofEpochSecond(ts), ZoneId.systemDefault()));
// 指定业务时区(比如统一按北京时间展示)
String bj = FMT.format(ZonedDateTime.ofInstant(
Instant.ofEpochSecond(ts), ZoneId.of("Asia/Shanghai")));
// 解析:容错策略显式声明,别让异常飘到崩溃
LocalDate d = LocalDate.parse("2026-02-28",
DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.CHINA));
// 差值:日期量用 Period,耗时量用 Duration
LocalDate from = LocalDate.of(2026, 1, 31);
Log.d("t", "加一个月 = " + from.plusMonths(1)); // 2026-02-28(日历语义)
Log.d("t", "加30天 = " + from.plusDays(30)); // 2026-03-02
// 老代码迁移:Date 与新 API 互转
Instant i = date.toInstant(); // java.util.Date
Date back = Date.from(i);
这段代码值得盯三处:第一处,静态 DateTimeFormatter 替代静态 SimpleDateFormat,并发问题从设计上消失;第二处,存储用 Instant、展示用 ZonedDateTime,时区信息在读出来时才确定;第三处,ofPattern 显式传 Locale,避免随系统语言变化。
这题在面试里怎么问、怎么答
"新时间 API 解决了旧 API 的哪些问题? "给四组:①线程安全(DateTimeFormatter 不可变);②语义清晰(区分本地时间、偏移时间、带时区时间,不再有 Date 那种模糊定位);③运算能力(plusMonths、withDayOfMonth、until 直接给出日历运算,不必自己加毫秒再校正月末);④时区规则完备(ZoneId 能处理夏令时切换,ZoneOffset 不能)。 也要讲迁移成本:老代码里的 Date/Calendar 仍在,Calendar 还是可变的,改造成本要算进排期。
"Instant、LocalDateTime、ZonedDateTime 有什么区别?"答:坐标轴上的点用 Instant;墙上时间用 LocalDateTime;墙上时间 + 规则用 ZonedDateTime;OffsetDateTime 介于 LocalDateTime 与 ZonedDateTime 之间,只记固定偏移,不记规则。 再补一句实战选择:业务要"某天的营业时间"用 LocalDate,要"某次操作的准确时刻"用 Instant。
"夏令时导致的本地时间重复或缺失怎么处理?"给现象:某些时区在切换点附近,本地时间会重复两次或直接跳过一小时。ZonedDateTime 对重复的本地时间按偏移量消歧(ofLocal 遇重复会取较早的,ofStrict 更严格),对跳过的本地时间会自动前移。对策是业务上尽量用 Instant 表达并按用户时区展示,关键流程(对账、结算)明确以 UTC 或固定时区为准。
"使用中遇到过什么问题?"案例一:定时任务在服务器切时区后触发时间漂移;定位到入库用的是本地 LocalDateTime 字符串;修复为改存 UTC 时间戳。案例二:日志里日期显示成 2025-12-31,但业务确认消息是 2026 年的;定位到格式化用了 YYYY 周-based-year;修复为改用 yyyy 并补了跨年边界单测。
再补一个工程上值得讲清的点:Android 里的 java.time 在 API 26 以下需要 desugar 支持,AGP 的 D8 会为 java.time 生成兼容实现,代价是包体与首次调用的类加载时间。 所以在工具类里统一收口时间工具(一个 TimeUtils,内部做版本判断或统一走 desugar),比在业务代码里到处 LocalDateTime.now() 更可控。面试里能主动谈"库兼容成本",说明考虑过真实上线。
给正在准备面试的你
把时间的三类语义画成三条线:Instant 画成一条水平的时间轴,上方标 UTC 时刻;LocalDateTime 画成一排没有时区标签的钟面刻度,标"墙上时间,跨时区比较会错";ZonedDateTime 在刻度上方加一层"时区规则"(含夏令时切换标记)。右侧再画一个竖向的 DateTimeFormatter 模块,标"不可变、线程安全、显式 Locale"。 面试中时间类问题的答案,基本都能在这张图上找到落点。
再补工程案例与踩坑——应用落点是把项目里散落的 Calendar.getInstance() 和 new SimpleDateFormat() 收进统一 TimeUtils,检查所有落库字段是否都带时区语义,为跨年与夏令时各补一条边界单测。
复习时别孤立刷题:Optional 与空值处理——Optional 是库层约定,与语言层的空安全是同一类问题的两种解法。
划两句重点:DateTimeFormatter 不可变线程安全,替代共享的 SimpleDateFormat;存储用 Instant,展示才转 ZoneId;YYYY 与 yyyy 不是一个东西;Period 是日期量、Duration 是时间量。
下一篇聊设计模式入门:单例、工厂、观察者的 Android 落地——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Optional-与空值处理:比判空更优雅的表达
下一篇预告:设计模式入门:单例、工厂、观察者的-Android-落地
有任何问题欢迎在评论区留言交流。