java.time:不可变的时间模型
学完你要能自己回答
- java.time 为什么能取代 Date/Calendar?它修正了哪些设计问题?
- LocalDateTime、Instant、ZonedDateTime 分别表示什么?跨时区存储该用哪个?
- DateTimeFormatter 为什么可以共享,而 SimpleDateFormat 不行?
类型地图:先分清「点」与「读数」01
java.time 的核心区分是:时间线上的瞬时点(Instant)与人类可读的日期时间(Local*)。前者适合存储和比较,后者适合展示和业务规则。时区是把两者连接起来的规则。
| 类型 | 表示 | 典型用途 |
|---|---|---|
Instant | 时间线上的瞬时点(UTC 纪元) | 日志、存储、超时、比较 |
LocalDate | 日期,无时区 | 生日、账单日 |
LocalTime | 时间,无日期 | 营业时间、每日时刻 |
LocalDateTime | 日期 + 时间,无时区 | 本地日程、表单 |
ZonedDateTime | 日期 + 时间 + ZoneId | 跨时区展示、夏令时 |
OffsetDateTime | 日期 + 时间 + 偏移量 | 协议、数据库时间戳 |
Duration | 精确时长(秒/纳秒) | 超时、性能计时 |
Period | 日期跨度(年/月/日) | 年龄、账期 |
LocalDateTime 不是「UTC 时间」,它根本没有时区;同一个 LocalDateTime 在上海和纽约代表两个不同的瞬时点。需要跨时区比较或存储时,使用 Instant 或 ZonedDateTime。
Instant 没有时区,LocalDate 没有时间,ZonedDateTime 才有完整的时区规则。选类型时先问:这是「时间线上的点」还是「墙上的读数」?不可变:plusDays 返回新对象02
java.time 的核心类都是不可变的:plusDays、withYear、minusHours 都返回新对象,原对象不变。因此它们天然线程安全,可以自由共享,也不会出现「谁把日期改了」的问题。
LocalDate release = LocalDate.of(2014, 3, 18);
System.out.println(release.plusDays(30));
System.out.println(release.getDayOfWeek());
System.out.println(ChronoUnit.DAYS.between(release, LocalDate.of(2014, 4, 17)));
Period p = Period.between(release, LocalDate.of(2014, 4, 17));
System.out.println(p.getDays());
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy/MM/dd", Locale.US);
System.out.println(release.format(fmt));
ZonedDateTime shanghai = release.atStartOfDay(ZoneId.of("Asia/Shanghai"));
System.out.println(shanghai.toInstant());
System.out.println(shanghai.plusHours(10).toLocalTime());
2014-04-17 TUESDAY 30 30 2014/03/18 2014-03-17T16:00:00Z 10:00
注意 Period.getDays() 只返回「天数部分」,不是总天数。3 月 18 日到 4 月 17 日是 30 天,所以这里刚好等于 30;跨月跨年时要用 ChronoUnit.DAYS.between 拿总天数。
DateTimeFormatter 也是不可变且线程安全的,可以定义为 static final 常量;SimpleDateFormat 不是,绝不能多线程共享。格式化与解析:pattern 的大小写陷阱03
解析和格式化都通过 DateTimeFormatter。默认使用 ISO-8601;自定义 pattern 时注意大小写:yyyy 年、MM 月、dd 日、HH 24 小时、mm 分、ss 秒。
LocalDate d = LocalDate.parse("2014-03-18");
System.out.println(d.format(DateTimeFormatter.BASIC_ISO_DATE));
try {
LocalDate.parse("2014-02-30");
} catch (DateTimeParseException e) {
System.out.println(e.getClass().getSimpleName());
}
20140318 DateTimeParseException
LocalDate.parse 默认严格校验,2 月 30 日不会自动纠正为 2 月 28 日,而是抛 DateTimeParseException。需要宽松解析时显式使用 ResolverStyle,但业务日期通常不该宽松。
DateTimeFormatter.ofPattern("yyyy-MM-dd") 与 DateTimeFormatter.ISO_LOCAL_DATE 在大多数场景等价;但 YYYY 是 week-based year,跨年周会得到意外结果,不要写错。时区与夏令时:LocalDateTime 不够用04
ZonedDateTime 带 ZoneId,按该地区的规则处理偏移量和夏令时。夏令时跳过的本地时间会被推到合法时间;重叠的本地时间需要显式选择 earlier/later offset。
ZoneId ny = ZoneId.of("America/New_York");
LocalDateTime gap = LocalDateTime.of(2014, 3, 9, 2, 30);
System.out.println(ZonedDateTime.of(gap, ny));
System.out.println(ZonedDateTime.of(gap, ny).plusHours(1));
2014-03-09T03:30-04:00[America/New_York] 2014-03-09T04:30-04:00[America/New_York]
2014-03-09 凌晨 2:30 在纽约不存在(夏令时从 2 点跳到 3 点),ZonedDateTime.of 把它推到了 3:30。plusHours(1) 是时间线上的加一小时,不是墙上时间加一小时。
Instant(或 epoch millis),展示时再套 ZoneId;不要把 LocalDateTime 当 UTC 存库,否则跨时区会出现固定偏移。与 Date / Calendar 互操作05
旧 API 通过 toInstant()、Date.from()、GregorianCalendar.toZonedDateTime() 互转。迁移时不要逐行翻译,直接把业务类型换成 java.time,再在边界处转换。
Date legacy = new Date(0L);
System.out.println(legacy.toInstant());
1970-01-01T00:00:00Z
java.sql.Date 的 toInstant() 在 JDK 8 会直接抛 UnsupportedOperationException,因为它只表示日期;应改用 toLocalDate()。java.sql.Timestamp 则可以转 toInstant()。
Date.getYear() 返回「年份减 1900」,Calendar.MONTH 从 0 开始,Calendar 还默认宽松解析。这些历史包袱正是 java.time 要解决的问题。版本边界BOUNDARY
这张表比 API 本身更值得记。写代码时先确认你的运行时版本。
| 特性 / API | 状态 | 说明 |
|---|---|---|
| java.time | 8 正式 | JEP 150;不可变、线程安全,默认 ISO-8601。 |
| `LocalDate.datesUntil` | 9 起 | JDK 8 用循环 + plusDays 生成日期序列。 |
| `Duration.toSecondsPart` | 9 起 | JDK 8 用 getSeconds() % 60。 |
| `DateTimeFormatter` 线程安全 | 8 起 | 可复用;SimpleDateFormat 仍不是线程安全的。 |
| `java.sql.Date.toInstant` | 8 抛 UOE | 改用 toLocalDate();Timestamp 可转 Instant。 |
| ThreeTen-Backport | Java 7 可用 | 无法升级到 8 时的过渡方案,API 接近但包名不同。 |
费曼自检SELF CHECK
先合上页面,用自己的话回答,再展开对照。能讲出因果和反例,才算真正掌握。