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 会少一大半。




