日期格式化与时区:Formatter 与 ZoneId
本教程共 100 篇 · 第 63 篇 · 更新于 2026-08-05 · 约 8 分钟阅读
63. 日期格式化与时区
本节目标:学会用 DateTimeFormatter 把日期转成想要的字符串,理解时区怎么转换,以及 Instant 如何对接时间戳。
上一章讲了日期对象本身,这一章解决两个现实问题:怎么把日期显示成好看的文字,以及带时区的时间怎么处理、怎么和数据库的时间戳互转。
时区是所有日期问题里最容易翻车的一块。很多线上事故的根源就是”差了八小时”,而写代码的人当时完全没意识到自己踩了时区。这一章会把这类坑挨个点出来。
DateTimeFormatter:线程安全的格式化
旧 API 的 SimpleDateFormat 有个致命伤:不是线程安全的。它内部拿一个 Calendar 当中间缓存,多线程共用同一个实例时会互相踩踏,轻则解析出错误日期,重则抛异常。
新 API 的 DateTimeFormatter 是不可变对象,创建之后内部状态永不改变,可以放心当静态常量给全项目共享。
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
// 可以安全地声明为静态常量,全项目共用一个
static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String s = LocalDateTime.now().format(FMT); // 2026-08-05 14:30:00
```text
反过来解析字符串,调静态的 `parse`,把格式器作为第二个参数传进去:
```java
LocalDateTime dt = LocalDateTime.parse("2026-08-05 14:30:00", FMT);
方向别记混:对象调 format 变成字符串,类调 parse 把字符串变成对象。
模式字母速查表
ofPattern 里的字母是有严格含义的,写错一个大小写结果就完全不同:
| 字母 | 含义 | 示例输出 |
|---|---|---|
y | 年 | 2026、yy 输出 26 |
M | 月 | 8、MM 输出 08、MMMM 输出 八月 |
d | 月中的日 | 5、dd 输出 05 |
E | 星期 | EEEE 输出 星期三 |
H | 小时,24 小时制 | 14 |
h | 小时,12 小时制 | 2,需配 a |
m | 分钟 | 30 |
s | 秒 | 05 |
S | 毫秒 | SSS 输出 123 |
a | 上下午标记 | 下午 |
z | 时区名称 | CST |
Z | 时区偏移 | +0800 |
Warning三对最容易写错的:大写
M是月、小写m是分;大写H是 24 小时制、小写h是 12 小时制;大写D是”一年中的第几天”,不是日期,想要日期得用小写d。把HH:mm写成hh:MM,下午两点会显示成02:08,月份跑到分钟位置去了。
字母重复的次数控制补零和展开程度:M 输出 8,MM 输出 08,MMM 输出简写月名,MMMM 输出全称。
内置的 ISO 格式器
不想自己拼模式,DateTimeFormatter 提供了一组现成常量:
LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
// 2026-08-05T14:30:00
LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
// 2026-08-05
```bash
它们输出的就是标准 ISO 8601,存库、传接口首选。这些常量本身也是不可变的,直接引用即可,不用每次创建。
## 带上语言环境 Locale
格式化月份名、星期名时,最好显式指定 `Locale`,否则同一段代码在不同机器上可能显示成中文,也可能显示成英文,取决于操作系统的区域设置。
```java
import java.util.Locale;
DateTimeFormatter cn = DateTimeFormatter
.ofPattern("yyyy年M月d日 EEEE", Locale.CHINA);
System.out.println(LocalDate.of(2026, 8, 5).format(cn));
// 2026年8月5日 星期三
DateTimeFormatter en = DateTimeFormatter
.ofPattern("MMM d, yyyy", Locale.US);
System.out.println(LocalDate.of(2026, 8, 5).format(en));
// Aug 5, 2026
同样的模式串,换个 Locale 输出就变了。凡是模式里出现 E、MMM、a 这类会输出文字的字母,都应该显式给 Locale,否则换台机器就是一个隐蔽的 bug。
可选片段与解析严格度
有些字符串时分秒可有可无,用方括号把可选部分包起来:
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd[ HH:mm]");
// "2026-08-05" 和 "2026-08-05 14:30" 都能解析
```bash
解析严格度也能调。默认的 `SMART` 模式会把 `2026-02-30` 悄悄纠正成 2 月 28 日,脏数据就这么溜进系统了。改成 `STRICT` 直接拒绝:
```java
import java.time.format.ResolverStyle;
DateTimeFormatter strict = DateTimeFormatter
.ofPattern("uuuu-MM-dd")
.withResolverStyle(ResolverStyle.STRICT);
// 注意严格模式下年份要用 uuuu 而不是 yyyy
严格模式里 yyyy 表示”纪年内的年份”,必须配合纪元字段才完整,所以要换成表示绝对年份的 uuuu。这是个很反直觉的细节,写严格解析时记得改。
时区:ZoneId 与 ZonedDateTime
LocalDateTime 没有时区概念,相当于挂历上写的时间。同一个 LocalDateTime 对象在北京和纽约表示的其实是两个不同的瞬间。一旦涉及时区,要用 ZonedDateTime。
import java.time.ZonedDateTime;
import java.time.ZoneId;
ZonedDateTime bj = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
ZonedDateTime ny = ZonedDateTime.now(ZoneId.of("America/New_York"));
System.out.println(bj); // 2026-08-05T14:30+08:00[Asia/Shanghai]
System.out.println(ny); // 2026-08-05T02:30-04:00[America/New_York]
```bash
`ZoneId.of("Asia/Shanghai")` 用的是 IANA 时区数据库里的地区名。别用 `GMT+8` 这种固定偏移写法,它不认识夏令时,遇到会切换夏令时的地区必然算错。想知道系统支持哪些时区,用 `ZoneId.getAvailableZoneIds()` 列出来看。
`ZoneId.systemDefault()` 取当前 JVM 的默认时区。它依赖运行环境,同一份代码在开发机和服务器上可能不一样,所以关键逻辑里最好显式写死时区,别依赖默认值。
## 时区之间转换
把同一个时刻换算到另一个时区,用 `withZoneSameInstant`:
```java
ZonedDateTime bj = ZonedDateTime.of(
2026, 8, 5, 14, 30, 0, 0, ZoneId.of("Asia/Shanghai"));
ZonedDateTime ny = bj.withZoneSameInstant(ZoneId.of("America/New_York"));
System.out.println(ny); // 2026-08-05T02:30-04:00[America/New_York]
北京下午两点半,同一瞬间纽约是凌晨两点半。注意这是”同一个绝对时刻的不同显示”,时间线上的位置没变。
还有一个长得很像的 withZoneSameLocal,它保持钟面数字不变而换时区,等于换了个瞬间。两者用途完全不同:
| 方法 | 保持什么不变 | 什么时候用 |
|---|---|---|
withZoneSameInstant | 绝对时刻不变,钟面变 | 换算”此刻对方几点” |
withZoneSameLocal | 钟面不变,绝对时刻变 | 换算”当地同样是 9 点” |
夏令时也由新 API 自动处理。某些地区夏天把钟拨快一小时,导致某一天只有 23 小时。旧 API 做加减时完全不管这些规则,ZonedDateTime 会按时区数据库的规则正确处理。
OffsetDateTime 与 ZoneOffset
有时你只需要”时间 + 一个固定偏移量”,不需要完整的时区规则,用 OffsetDateTime:
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
OffsetDateTime odt = OffsetDateTime.of(dt, ZoneOffset.of("+08:00"));
System.out.println(odt); // 2026-08-05T14:30+08:00
```bash
它比 `ZonedDateTime` 轻量,也不涉及夏令时规则。数据库的 `TIMESTAMP WITH TIME ZONE` 类型、很多 HTTP 接口的时间字段,映射过来都是 `OffsetDateTime`。
三者的关系可以这样理解:`LocalDateTime` 是"14:30",`OffsetDateTime` 是"14:30 东八区",`ZonedDateTime` 是"14:30 上海时间,且知道上海的夏令时历史规则"。
## Instant:绝对时刻与时间戳
`Instant` 代表时间线上一个绝对的点,底层就是时间戳,精度到纳秒。它不带任何时区,是全球统一的"此刻"。
```java
import java.time.Instant;
Instant now = Instant.now();
long sec = now.getEpochSecond(); // 秒级时间戳,10 位
long ms = now.toEpochMilli(); // 毫秒级时间戳,13 位
Instant fromSec = Instant.ofEpochSecond(sec); // 由秒时间戳还原
Instant fromMs = Instant.ofEpochMilli(ms); // 由毫秒时间戳还原
和 ZonedDateTime 互转靠 atZone 和 toInstant:
ZonedDateTime zdt = now.atZone(ZoneId.of("Asia/Shanghai")); // 绝对时刻 + 时区 = 钟面时间
Instant back = zdt.toInstant(); // 钟面时间 → 绝对时刻
```bash
记住这个公式:绝对时刻 + 时区 = 钟面时间。三者知道任意两个就能推出第三个,时区问题基本都能靠它理清。
## 与数据库、网络打交道
存时间到数据库或通过网络传输,推荐两条规矩:
- 存绝对时刻:存 `long` 时间戳、`Instant` 或 `OffsetDateTime`,不留时区歧义。
- 显示时再转:在展示层按用户所在 `ZoneId` 转成 `ZonedDateTime` 再格式化输出。
> [!WARNING]
> 别把 `LocalDateTime` 直接当"时刻"存进数据库,再当绝对时间读出来。它不带时区,服务器一旦换机房或改时区配置,所有历史数据的含义就全变了,而且这种错误无法从数据本身发现。需要绝对时刻就用 `Instant` 或 `OffsetDateTime`。
## 新旧 API 互转
老代码返回 `java.util.Date` 时,用这两个方法搭桥接进新 API:
```java
// Date → Instant → ZonedDateTime
Instant ins = oldDate.toInstant();
ZonedDateTime zdt = ins.atZone(ZoneId.systemDefault());
// 反向:ZonedDateTime → Date
java.util.Date d = java.util.Date.from(zdt.toInstant());
Date.from 和 toInstant 就是 Java 8 为新旧共存专门加的桥梁。改造老项目时,让转换只发生在调用老接口的边界上,内部逻辑全用新 API。
常见疑问
问:为什么打印出来的时间总是差八小时?
九成是把 Instant 或 UTC 时间直接当本地时间显示了。Instant 的 toString 输出的是 UTC 时间,末尾带 Z。要显示成本地时间,先 atZone(ZoneId.systemDefault()) 再格式化。
问:DateTimeFormatter 每次都要新建吗?
不用。它不可变、线程安全,创建一次存成 static final 常量反复用就行。这和 SimpleDateFormat 必须每次新建正好相反,也是新 API 的一大优势。
问:数据库存字符串时间行不行?
不推荐。字符串没法用数据库的时间函数做范围查询和排序(除非严格用 ISO 格式),也不带时区信息。老老实实用时间戳或数据库自带的时间类型。
小结
DateTimeFormatter 不可变且线程安全,可作静态常量共享;ofPattern 配 Locale 更稳,内置 ISO_* 常量可直接用。
模式字母注意大小写:M 月、m 分、H 是 24 小时制、h 是 12 小时制。
时区用 IANA 地区名的 ZoneId 配 ZonedDateTime,跨时区换算用 withZoneSameInstant;只需固定偏移用 OffsetDateTime。
绝对时刻用 Instant,它和 long 时间戳互转最方便。存储优先绝对时刻,展示时再按用户时区格式化。