日期时间 API 演变:从 Date 到 java.time
本教程共 100 篇 · 第 61 篇 · 更新于 2026-08-05 · 约 8 分钟阅读
61. 日期时间 API 演变
本节目标:理解时间、时刻、时区这些基本概念,看清旧 Date/Calendar 的坑,弄懂为什么 Java 8 要重写一套 java.time。
日期时间看起来简单,真写起来全是坑。Java 早期的那套 API 设计得不理想,后来在 Java 8 彻底重写。这一章先讲清楚”为什么要有新 API”,下一章再讲新 API 怎么用。
有人会问:直接学新的不就行了,为什么还要花一章讲旧的?两个原因。一是你接手的老项目里几乎一定有旧 API,看不懂就改不动。二是不知道旧 API 错在哪,就体会不到新 API 每一处设计的用意,学起来只能死记方法名。
先分清三个概念
- 日期:哪年哪月哪日,比如 2026-08-05,不含具体钟点。
- 时间:一天里的时刻,比如 14:30,脱离日期没意义。
- 时刻:一条绝对的时间线上的点,全世界统一,不受时区影响。
举个生活例子:你和外国朋友约”北京时间明早 9 点”视频,他得按自己时区换算。这个”明早 9 点”是带时区的时间;而”此刻距离 1970 年过去了多少秒”,就是一个绝对时刻。
这三层区分不是学究式的抠字眼。新 API 之所以拆出那么多类,正是因为它把这三层分得清清楚楚,每个类只负责一层。旧 API 的 Date 想一个类通吃,结果哪层都没做好。
时间戳:从某个起点数秒
计算机不喜欢”几月几号”,它喜欢数数。业界约定了一个起点:1970 年 1 月 1 日 00:00:00 UTC,叫”纪元”。从这个起点到某一刻过去的毫秒数,就叫时间戳(Epoch Time)。
long now = System.currentTimeMillis(); // 当前时刻的毫秒时间戳
```bash
时间戳的好处是全球统一、与时区无关,存数据库、传网络都很方便。同一瞬间,北京的服务器和纽约的服务器拿到的时间戳完全一致,只是各自换算成钟面时间时不同。Java 的新旧 API 都能和它互转。
> [!NOTE]
> 时间戳通常以毫秒(13 位)或秒(10 位)表示,注意区分。前端 JavaScript 和 Java 习惯用毫秒,很多后端接口和 Unix 工具用秒,两边对接时差 1000 倍的 bug 非常常见。新 API 的 `Instant` 就建立在时间戳之上,精确到纳秒。
## 旧 API 的痛点:Date
早期用 `java.util.Date` 表示时间。它的问题一箩筐:
```java
// 反面教材,不要这样做:这行的本意是 2021 年 1 月 5 日
Date d = new Date(121, 0, 5);
坑在哪?
- 年份要减 1900:想表示 2021 年,参数得写 121。写
2021得到的是公元 3921 年。 - 月份从 0 开始:0 是一月,11 是十二月。想表示一月要写 0,想表示八月要写 7。
- 它既想表示日期又想表示时刻,概念混乱。明明存的是绝对时刻,打印出来却是本地时区的钟面时间。
- 大多数方法(
getYear、getMonth)已被废弃,但没删掉,留着坑人。
更麻烦的是 Date 可变。setTime 能就地改掉它的值,你把一个 Date 传给别人,别人改了,你手上这个也跟着变。为了防止这种事,老代码里到处是防御性拷贝。
旧 API 的痛点:Calendar
为了补救 Date,Java 又加了 Calendar,结果只是换了个坑:
// 反面教材,不要这样做
Calendar cal = Calendar.getInstance();
cal.set(2026, 8, 5); // 本意是 8 月 5 日
System.out.println(cal.getTime()); // 实际打印的是 9 月 5 日!
```bash
看清楚了:`set` 的第二个参数是从 0 起算的月份,写 `8` 得到的是九月。整整差了一个月。这个坑每年都要坑倒一批人,写对的办法是用常量 `Calendar.AUGUST`,但常量本身的值就是 7,取出来还是 0 起算:
```java
// 反面教材,不要这样做
Calendar cal = Calendar.getInstance();
cal.set(2026, Calendar.AUGUST, 5);
int month = cal.get(Calendar.MONTH); // 得到 7,不是 8
System.out.println(month + 1); // 要显示给人看,还得手动加 1
每次取月份都要记得加 1,每次设月份都要记得减 1,忘一次就是线上事故。另外,Calendar 加减日期要调 add,时区处理繁琐,而且它和 Date 一样是可变的,多线程下改同一个对象会出乱子。
更坑的 SimpleDateFormat
格式化旧时间要用 SimpleDateFormat,但它有个致命伤:不是线程安全的。
它内部藏了一个 Calendar 成员变量当中间缓存。单线程用没事,多个线程共用同一个实例去 parse 或 format,就会互相踩踏这块缓存。
// 反面教材,不要这样做:静态共享的 SimpleDateFormat
static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd");
// 10 个线程同时调用 SDF.parse("2026-08-05")
// 可能的结果:
// 多数线程拿到正确的 2026-08-05
// 少数线程拿到 2026-08-05 之外的乱七八糟日期,比如 0026-08-05
// 甚至直接抛 NumberFormatException: multiple points
```bash
这种 bug 最讨厌的地方是:测试环境流量小,跑一万次都不出错;上线遇到并发,隔三差五冒一条脏数据,还很难复现。要绕开只能每次 `new` 一个新实例,或者用 `ThreadLocal` 包一层,两种写法都别扭。
这也正是新 API 里 `DateTimeFormatter` 被设计成不可变、线程安全的原因——一个静态常量全项目共用,不用操心。
## 时区:绕不开的麻烦
旧 API 里时区用 `TimeZone`,但 `Date` 本身不带时区信息,显示时才按 JVM 默认时区格式化。同一个 `Date` 对象,在北京的服务器上打印是 14:00,部署到伦敦的服务器上打印就变成 06:00,代码一行没改。
夏令时更是重灾区。某些国家夏天把钟拨快一小时,导致一天可能只有 23 小时或多出 25 小时。旧 API 做日期加减时不理会这些规则,算出来的结果经常差一小时。
> [!WARNING]
> 主线代码请彻底告别 `Date`、`Calendar`、`SimpleDateFormat`。它们并没有被删除,老代码还能编译运行,但设计缺陷是骨子里的,补不好。新的 `java.time` 包从根上解决了这些问题。
## 新 API:java.time 的设计哲学
Java 8 引入的 `java.time` 包(规范编号 JSR-310),借鉴了当年口碑极好的第三方库 Joda-Time,主要设计者也是同一个人。它有几个鲜明特点:
- 不可变:每个对象改了都返回新对象,原对象纹丝不动,天然线程安全。
- 清晰分工:`LocalDate` 管日期、`LocalTime` 管时间、`LocalDateTime` 管本地日期时间、`ZonedDateTime` 管带时区的时刻、`Instant` 管时间戳。一个类只干一件事。
- 命名统一:`now()` 取当前、`of(...)` 手动构造、`parse(...)` 解析字符串、`plusXxx` 加、`minusXxx` 减,所有类一套命名,学一个会一片。
- 月份从 1 开始:一月就是 1,八月就是 8,符合人的直觉。
- 格式化类 `DateTimeFormatter` 不可变且线程安全。
## 新旧写法对照
同一件事,两套 API 的差别一目了然:
| 要做的事 | 旧写法(历史兼容,不推荐) | 新写法(主线) |
|----------|---------------------------|----------------|
| 取当前日期 | `Calendar.getInstance()` | `LocalDate.now()` |
| 构造 2026-08-05 | `cal.set(2026, 7, 5)`,月份要减 1 | `LocalDate.of(2026, 8, 5)` |
| 取月份 | `cal.get(Calendar.MONTH) + 1` | `date.getMonthValue()` |
| 加 3 天 | `cal.add(Calendar.DATE, 3)`,改原对象 | `date.plusDays(3)`,返回新对象 |
| 格式化 | `SimpleDateFormat`,非线程安全 | `DateTimeFormatter`,线程安全 |
| 时区转换 | `TimeZone` + 手动换算 | `ZonedDateTime.withZoneSameInstant()` |
| 算两天相差几天 | 毫秒相减再除 86400000 | `ChronoUnit.DAYS.between(a, b)` |
| 是否闰年 | 自己写取余判断 | `date.isLeapYear()` |
右边这一列不用记,下一章会逐个讲。这里只需要感受一个事实:同样的需求,新 API 更短、更准、更不容易写错。
## 关于 ISO 8601
新 API 默认认的日期格式叫 ISO 8601,即 `2026-08-05T14:30:00` 这种形式。它是国际标准,字符串直接按字典序排序就等于按时间排序,跨语言跨系统都认。
用标准格式存时间,别自己发明 `2026年08月05日` 这种肉眼友好但机器难解析的写法。要给人看,在展示层再转换。
## 为什么这章要先讲演变
很多人一上来就背新 API 的方法,却不知道旧代码为什么别用。理解了"年份要减 1900、月份从 0 起、Date 可变又不带时区、格式化器还不线程安全",你才会真心觉得新 API 香,也才能在维护老项目时一眼认出那些坑。
> [!TIP]
> 如果你在老项目里看到 `new Date()`、`Calendar.getInstance()`、`SimpleDateFormat`,心里要亮红灯:这些是新 API 能完全替代的"历史遗留"。改造时优先动那些涉及并发和跨时区的地方,收益最大。
## 常见疑问
**问:老项目全是 Date,能一次性全换掉吗?**
不建议一刀切。稳妥的做法是"边界转换":新写的代码统一用 `java.time`,遇到必须调用的老接口,在调用处用 `Date.from(instant)` 和 `date.toInstant()` 转一下。这两个方法就是 Java 8 专门为新旧共存提供的桥梁,改造成本很低。
**问:`Date` 既然这么烂,为什么不删掉?**
Java 极重视向后兼容。删一个用了二十多年的公共类,全世界无数程序会直接编译失败。折中办法就是把问题方法标为废弃、写清警告,然后另起一套新 API,让大家自愿迁移。这也是理解 Java 很多"历史包袱"的钥匙。
**问:数据库里存时间,到底存什么类型?**
优先存绝对时刻,也就是时间戳或带时区的类型。存一个不带时区的"钟面时间",等服务器换个机房、换个时区部署,数据就全乱了。具体怎么做,第 63 章讲时区时会展开。
## 小结
时间分日期、时间、时刻三层;时间戳是从 1970 纪元起算的秒或毫秒,全球统一。
旧 `Date` 年份要减 1900、`Calendar` 月份从 0 起、两者都可变,`SimpleDateFormat` 非线程安全,时区和夏令时处理也不可靠。
Java 8 的 `java.time` 不可变、分工清晰、命名统一、线程安全,是今天唯一推荐的写法。下一章逐个上手它的核心类。