Java 日期时间 API 实战:从 Date 到 java.time

Java 日期时间 API(java.time)自 Java 8 引入以来,彻底改变了我们处理时间的方式。比起老旧的 java.util.Date 和 Calendar,新的 Java 日期时间 API 用不可变对象、清晰的类型分工和线程安全的格式化器,把”时间差 8 小时””格式化串了线程”这类祖传坑几乎一网打尽。本文用可运行的代码示例,带你从 Date 平滑迁移到 java.time。

一、为什么该放弃 Date 和 Calendar

老 API 最大的问题集中在三点:可变性、年份偏移、时区缺失。Date 的月份从 0 开始、年份要减 1900;Calendar 同样月份从 0 计数,稍不留神就写出差一个月的 bug。更糟的是二者都暴露 setTime、set 等可变方法,在缓存、线程池或新版Java 21 虚拟线程并发场景下,共享的日期对象会被悄悄改坏。

此外,老 API 把”日期””时间””时区””时间戳”揉在一个 Date 类里,语义模糊:它本质是个带时区的毫秒戳,却没有提供时区查询能力。java.time 把职责拆开,学习成本更低,出错概率也更低。

二、java.time 的核心设计:不可变与值语义

java.time 几乎所有类型都是不可变的(immutable):每次”修改”都返回一个新对象,原对象不变。这与Java 函数式编程里强调的”纯函数 + 不可变值”如出一辙,天然线程安全。同时这些类被设计为”值类型”语义——两个表示同一时刻的对象 equals 即相等,没有身份陷阱。

类型职责是否含时区
LocalDate仅日期(年/月/日)否
LocalTime仅时间(时/分/秒/纳秒)否
LocalDateTime日期 + 时间否
Instant时间线上的一个时间点(UTC)隐含 UTC
ZonedDateTime带时区的完整时间是
OffsetDateTime带固定偏移的完整时间是(偏移)

三、创建时间: LocalDate / LocalTime / LocalDateTime

最常用的是三个”本地”类型。它们不绑定时区,适合表示”生日””会议开始时间”这类业务语义。

// 明确构造,避免魔法数字
LocalDate date = LocalDate.of(2026, Month.OCTOBER, 21);
LocalDate today = LocalDate.now();

System.out.println(date.getYear());       // 2026
System.out.println(date.getMonth());      // OCTOBER(枚举,不是 0/9)
System.out.println(date.getDayOfMonth()); // 21

LocalTime time = LocalTime.of(19, 41, 12);
LocalDateTime ldt = LocalDateTime.of(date, time);
LocalDateTime parsed = LocalDateTime.parse("2026-10-21T19:41:12");

注意 getMonth() 返回的是枚举 Month,彻底消灭了”月份 +1/-1″的偏移错误。需要数字时用 getValue()。

四、时区与时间戳:Instant / ZonedDateTime / OffsetDateTime

一旦涉及”跨时区””存数据库””发接口”,就必须显式处理时区,否则就会遇到经典的”差 8 小时”。Instant 是 UTC 时间线上的绝对点,最适合做存储与传输;ZonedDateTime 附带完整时区规则(含夏令时)。

// 获取当前 UTC 瞬间
Instant now = Instant.now();

// 转换为北京时间(带时区规则,自动处理夏令时)
ZonedDateTime bj = now.atZone(ZoneId.of("Asia/Shanghai"));
System.out.println(bj);
// 2026-10-21T19:41:12.123+08:00[Asia/Shanghai]

// 固定偏移(适合与旧系统/协议对齐)
OffsetDateTime fixed = bj.toOffsetDateTime();

// 反向:字符串带时区解析
ZonedDateTime z = ZonedDateTime.parse("2026-10-21T11:41:12Z[UTC]");

强烈建议:服务端统一用 Instant 或 ZonedDateTime 存储,前端/接口再按用户时区展示。不要用”本地时间 + 注释说它是东八区”这种约定。

五、格式化与解析:DateTimeFormatter 线程安全

5.1 为什么告别 SimpleDateFormat

SimpleDateFormat 是非线程安全的,多个线程共用一个实例会产出乱码甚至抛异常——这是多线程服务里的经典事故。DateTimeFormatter 则完全不可变、线程安全,可以放心定义为静态常量。

// 推荐:定义为静态常量,全局复用,线程安全
private static final DateTimeFormatter FMT =
    DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

String s = bj.toLocalDateTime().format(FMT);
LocalDateTime back = LocalDateTime.parse("2026-10-21 19:41:00", FMT);

// 内置标准格式,零样板
String iso = ldt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);

5.2 预定义格式化器

java.time 内置了大量 ISO_* 与本地化格式化器(如 DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)),能按 Locale 输出”2026年10月21日”这类本地化文本,省去手写模式串。

六、日期计算与调整:Period / Duration / TemporalAdjuster

加减、取区间、算”下个周五”都不再需要手动翻日历。可读的 plus/minus、表示”年月日”跨度的 Period、表示”精确时长”的 Duration,以及强大的 TemporalAdjuster,覆盖了绝大多数业务计算。

LocalDate today = LocalDate.of(2026, 10, 21);

// 加 7 天,清晰可读
LocalDate due = today.plusDays(7);              // 2026-10-28

// 下个周五(月末结算、周报截止常用)
LocalDate nextFri = today.with(
    TemporalAdjusters.next(DayOfWeek.FRIDAY));

// 月末最后一天
LocalDate monthEnd = today.with(TemporalAdjusters.lastDayOfMonth());

// 人类可读跨度 vs 机器精确跨度
Period p = Period.between(today, nextFri);      // 按日历
Duration d = Duration.between(start, end);      // 纳秒级精确

七、与旧 Date 互转:迁移实战

真实项目不可能一夜之间删光 Date。迁移的关键是统一走 Instant 作为桥梁:Date 可视为”带时区的毫秒戳”,与 Instant 一一对应。

ZoneId sys = ZoneId.systemDefault();

// 旧 Date -> 新 LocalDateTime(按系统默认时区)
Date legacy = new Date();
LocalDateTime ldt = legacy.toInstant().atZone(sys).toLocalDateTime();

// 新 LocalDateTime -> 旧 Date
Date back = Date.from(ldt.atZone(sys).toInstant());

// 数据库/接口层建议直接存 Instant 或 OffsetDateTime
Instant stored = Instant.now();

迁移策略:新增代码全部用 java.time;旧接口出入参保留 Date 但在边界处用上面的桥接一次性转换,逐步收窄老类型的作用域。

八、在 Spring Boot 与持久化中落地

Spring Boot 与 Jackson 对 java.time 有原生支持:LocalDateTime、Instant、ZonedDateTime 可直接序列化为 JSON,无需额外转换器。JPA/Hibernate 也能把 LocalDateTime 映射到 timestamp 列。

// Jackson 自动序列化 java.time,无需写转换器
record OrderDto(Long id, LocalDateTime createdAt) {}

@RestController
class OrderController {
    @GetMapping("/orders/{id}")
    OrderDto get(@PathVariable Long id) {
        return service.find(id);
    }
}

// 统一后端时区,避免容器时区不一致的"8 小时"问题
// application.properties
// spring.jackson.time-zone=Asia/Shanghai

数据库层面,建议时间列统一用 timestamp with time zone(或存储 UTC 的 bigint 毫秒),由应用层负责时区转换,不要让数据库隐式按会话时区解读。

九、常见坑与排错清单

现象根因对策
时间整体差 8 小时存储用本地时间、展示又叠加时区存储统一 UTC(Instant),展示再转 ZoneId
格式化结果乱码/偶发异常多线程共用 SimpleDateFormat改用线程安全的 DateTimeFormatter
月份对不上混用 Date/Calendar 的 0 基月份全面迁移到 java.time 的枚举 Month
夏令时计算出错用 LocalDateTime 做跨时区运算用 ZonedDateTime,依赖内置时区规则
对象被意外改值Date/Calendar 可变java.time 不可变,天然免疫

十、单元测试中的时间控制

时间相关逻辑最容易被测漏。借助Java 单元测试实战:JUnit 5的断言,配合 java.time 的值语义,可以写出稳定、无睡眠等待的测试:用固定 LocalDate 做输入,断言相对计算的结果。

@Test
void testDueDate() {
    LocalDate base = LocalDate.of(2026, 10, 21);
    LocalDate due = base.plusDays(7);
    assertEquals(LocalDate.of(2026, 10, 28), due);
}

// 需要"当前时间"时,把时钟抽象为可注入的 Supplier<Clock>
Clock fixed = Clock.fixed(Instant.parse("2026-10-21T00:00:00Z"), ZoneId.of("UTC"));
LocalDate today = LocalDate.now(fixed); // 测试可完全确定性

用 Clock 注入替代裸 now(),是让时间相关代码可测的关键一步,也符合依赖倒置的好习惯。

十一、小结与落地清单

java.time 不是”另一个日期库”,而是对过去二十年 Date/Calendar 教训的系统性修正。落地时记住几条铁律:业务展示用 LocalDate/LocalDateTime,存储与传输用 Instant,跨时区展示用 ZonedDateTime,格式化一律用 DateTimeFormatter。新项目直接全量使用,老项目在边界处用 Instant 桥接逐步替换,配合确定性 Clock 让测试可控。掌握这套 Java 日期时间 API,时间相关的 bug 会少一大半。

上一篇 磁盘只读故障排查实战:Linux 文件系统修复
下一篇 MySQL EXPLAIN 执行计划:慢 SQL 优化实战
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]