第056篇 新时间 API:LocalDateTime 取代 Date 的理由

简介: 移动端时间处理易出线上事故:`SimpleDateFormat` 静态共享致线程不安全;跨时区“昨天”判断偏差;字符串截取本地化日期失效。Java 8 `java.time` 核心在于厘清**三类时间语义**:`Instant`(UTC瞬时点)、`LocalDateTime`(无时区墙上时间)、`ZonedDateTime`(含时区规则,支持夏令时)。存储用 `Instant`,展示才绑定时区;格式化必传 `Locale`;`YYYY`≠`yyyy`;`Period`(日历量)与 `Duration`(物理量)不可混用。

时间处理是移动端最容易出线上事故的一块:一个 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-落地

有任何问题欢迎在评论区留言交流。

相关文章
|
15小时前
|
缓存 监控 Java
第021篇 异常体系 Throwable:Checked 与 Unchecked 的边界
Android面试高频题:Throwable异常体系,需透彻理解Error/Exception区别、受检/非受检划分逻辑,以及finally执行机制。重点在于“为什么这样设计”和“用错的后果”——如吞异常致线上脏数据、滥用异常控流程拖垮性能、finally抛异常掩盖根因等。真懂者必踩过坑、用过、复盘过。
17 0
|
13小时前
|
Java 定位技术 开发工具
第059篇 建造者模式:链式调用为何无处不用在哪些地方
建造者模式核心是**将复杂对象的创建过程外置、分步可控、集中校验**。它解决参数过多、互斥依赖、分步初始化问题,关键在于:私有构造、链式setter、build()统一校验、产物不可变(final+防御拷贝)、默认值内聚于Builder——非仅为语法糖,而是工程化构造控制。
23 1
|
15小时前
|
缓存 安全 Java
第019篇 Object 通用方法:equals、hashCode 与 clone 契约
Android面试高频题:Object通用方法(equals/hashCode/clone等)是判断候选人“用过”还是“懂原理”的试金石。核心在于——equals相等则hashCode必相等,否则HashMap/HashSet将失效;clone默认浅拷贝,可变字段需手动深拷贝;toString虽小,却是日志排查关键。重写必讲场景、取舍与代价。
16 0
|
15小时前
|
算法 Java 编译器
第015篇 抽象类与接口:到底该怎么选才不丢分
本文深入剖析抽象类与接口的本质差异:抽象类聚焦“is-a”复用(带状态、构造器、模板方法),接口强调“can-do”契约(多实现、default/private方法演进)。直击常考误区——常量接口滥用、default方法状态依赖、抽象类this逃逸,并结合Android实战(BaseActivity、OnClickListener)与Kotlin新特性,讲清原理、场景与避坑方案。
22 0
|
13小时前
|
JSON 数据库 Android开发
第051篇 Serializable 与 Parcelable:Android 为什么偏爱后者
Serializable 是 Java 原生序列化机制,依赖反射与字段名对齐,兼容性好但性能差、体积大;Parcelable 是 Android 专用接口,手写定序读写,无反射、零元数据,高效紧凑,但顺序敏感、跨版本易错。Binder 传输必须用 Parcelable——因其面向共享内存、低开销、可控字节布局,而 JSON/Serializable 均无法满足跨进程实时性与缓冲区限制(如 TransactionTooLargeException)。
21 0
|
15小时前
|
缓存 安全 Java
第012篇 static 关键字全景:静态变量、方法与内部类
本文深入解析 Android 面试高频考点 `static` 关键字:从类加载、内存布局到生命周期;详解静态成员共享性、线程安全边界、方法隐藏机制;剖析内存泄漏、OOM、初始化顺序等典型坑及规避方案;结合单例、弱引用、静态内部类等工程实践,助你结构化作答,展现系统性认知。
23 0
|
13小时前
|
缓存 前端开发 安全
第046篇 类加载机制与双亲委派:热修复的伏笔
类加载机制核心是“双亲委派”——加载请求逐级**上抛**至Bootstrap加载器,确保核心类可信、避免重复加载、保障类唯一性(全限定名+加载器)。它解决安全与一致性问题,而非单纯流程;破坏委派(如SPI、热修复)是设计特性,非错误。面试重在理解动机与权衡。
22 0
|
16小时前
|
SQL 安全 Java
第007篇 String、StringBuilder 与 StringBuffer:拼接性能三选一
Android面试中,String、StringBuilder与StringBuffer的选型本质是权衡:String不可变、线程安全但拼接低效;StringBuilder单线程高性能,扩容可控;StringBuffer加锁保障多线程安全,但有同步开销。真功夫在量化场景、预估容量、规避内存抖动。
19 0
|
13小时前
|
监控 Java 测试技术
第041篇 线程池七参数:ThreadPoolExecutor 从配置到调优
Android面试高频题“线程池七参数”,实为三层能力筛选:背参数(入门)、讲流程(进阶)、析设计(高手)。核心在于理解`corePoolSize→workQueue→maximumPoolSize→RejectedExecutionHandler`的执行链与制约关系,避开无界队列、线程命名缺失、拒绝策略误用等典型坑。真懂者必知:参数非独立旋钮,而是协同约束。
18 0
|
13小时前
|
缓存 安全 Java
第076篇 集合三大类与只读可变两套体系
Kotlin集合分List/Set/Map三组,各含只读与可变类型(共6种)。关键在于:“只读”是类型约束(禁止调用修改方法),≠“不可变”(底层数据仍可能被改)。`listOf()`返回真不可变实现,安全共享;而`asList()`是活视图,需警惕副作用。工程中应坚持“内部可变、对外只读+`toList()`拷贝”。
23 0