Java 时间转换与时区处理指南
Java 日期/时间类型概览
Java 提供了多种日期时间类来表示和处理时间点,其中常用的包括 Instant、OffsetDateTime 和 LocalDateTime。它们之间的区别在于是否包含时区或偏移信息:
- Instant:表示时间轴上精确的一个时间点(通常以UTC时间1970-01-01T00:00:00Z开始计算的秒/纳秒偏移)。它不包含任何时区或偏移概念,等同于一个 UTC 时间点。例如,Instant.now() 返回当前时刻对应的UTC时间。如果要将 Instant 格式化成人类可读的日期时间,需要提供时区信息,否则formatter无法将其转换成人类日期字段。
- LocalDateTime:表示本地的日期和时间,不含时区或时区偏移(Zone Offset)信息。它好比墙上钟表显示的时间,只说明“当地”的年月日时分秒,但无法确定唯一瞬间。同样的LocalDateTime在不同地区表示的实际瞬间并不相同(例如波士顿和斯洛文尼亚同一时刻墙钟显示相同LocalDateTime,其对应的 Instant 不同)。因此,将LocalDateTime转换为具体时间点,必须提供对应的时区或偏移量。反之,从一个Instant转换为LocalDateTime也需要指定时区。一般来说,LocalDateTime主要用于表示本地事件时间或用于构造其他带时区信息的时间对象,在应用中直接使用它来代表瞬时时间需要谨慎。PostgreSQL中
timestamp without time zone
类型通常会映射为Java的LocalDateTime类型。 - OffsetDateTime:在LocalDateTime的基础上增加了相对于UTC的固定偏移(Offset)信息。例如
2007-12-03T10:15:30+01:00
就是OffsetDateTime,表示此本地时间比UTC快1小时。OffsetDateTime包含了本地日期时间和对应的UTC偏移量,因此表示的是唯一的时间轴上的瞬间。给定OffsetDateTime,可以精确转换为Instant;反过来,将Instant转换为OffsetDateTime需要提供一个ZoneOffset。需要注意Offset仅表示与UTC的差值,不包含夏令时等复杂规则(这些由ZonedDateTime处理)。通常OffsetDateTime适合用于网络通信或数据库交互等技术场景,例如通过JDBC与PostgreSQL的timestamp with time zone
类型交互时,就建议使用OffsetDateTime。它所存储的时间点精度与Instant相同(纳秒级)。
我们可以做个小测试: 当前我们在上海 东八区 (UTC +8:00),打印三种类型的now()
1 |
|
对于这个测试结果我们观测到
方法调用 | 控制台输出 | 含义 |
---|---|---|
Instant.now() |
2025-08-07T06:38:42.219218Z | UTC 时间点 → “06:38” |
OffsetDateTime.now() |
2025-08-07T14:38:42.219474+08:00 | 本地日期时间 + 偏移 +08:00 |
LocalDateTime.now() |
2025-08-07T14:38:42.219551 | 本地日期时间,无任何时区/偏移信息 |
DateTimeFormatter的使用
Java的java.time.format.DateTimeFormatter
用于对日期时间进行格式化和解析。常见使用包括:
- 自定义格式模式:可以使用
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
创建自定义格式。例如模式"yyyy-MM-dd HH:mm:ss"
对应如“2025-08-06 03:24:14”的字符串格式。Formatter是线程安全且可重用的。
转换Instant到String
格式化:调用formatter.format(temporal)
将日期时间对象格式化为字符串。需要注意,如果格式化的对象不包含所需的信息,会报错。例如,用上述不含时区信息的formatter直接格式化Instant会抛出异常,因为Instant没有内置时区,formatter无法确定年月日等。解决办法是:指定时区。
可以通过DateTimeFormatter.withZone(ZoneId)
为formatter附加一个时区。这样,格式化Instant时,formatter会先将Instant转换为该时区的本地时间,再按模式输出。例如:
1 |
|
上述代码会先将Instant按上海时区转换,然后输出yyyy-MM-dd HH:mm:ss
格式的字符串(相当于北京时间)。如果未调用withZone,格式化Instant将抛出UnsupportedTemporalTypeException
异常
1 | UnsupportedTemporalTypeException: Unsupported field: YearOfEra |
转换String到Instant
Instant.parse
只接受 ISO-INSTANT格式
- 规范要求末尾 必须是
Z
(零时区),或者带 两位数的时区偏移(+08:00
而不是+8:00
)。
所以如下所示,这两种字符串都可以直接parse为instant类型,不用再做其他转换。
1 |
|
但是有些api会返回这样的字符串:2025-08-07 14:38:42
并不带有偏移量,这时候就需要DateTimeFormatter
出马,给这个字符串一个时区偏移量
1 |
|
转换LocalDateTime到String
DateTimeFormatter
也可以把LocalDateTime
序列化成字符串。我们经过测试注意到,和序列化Instant不一样,这里加不加withZone,对结果没有影响。这是因为:LocalDateTime 不含任何时区/偏移字段,它只携带 “年月日时分秒” 这 6 个字段,没有时区信息。因此formatter只会取这六个字段进行序列化。
1 |
|
转换String到LocalDateTime
LocalDateTime.parse( String )
默认使用 DateTimeFormatter.ISO_LOCAL_DATE_TIME
,
它严格要求 yyyy-MM-dd'T'HH:mm:ss
(中间必须是 T
)。
因此,如果我们要把 "2025-08-07 14:38:42"
这样的字符串转换成LocalDateTime
,就需要用DateTimeFormatter
的帮助。由于LocalDateTime不包含时区信息,所以DateTimeFormatter
没有必要配置zoneId
1 |
|
转换 OffsetDateTime 到 String
DateTimeFormatter
也可以把OffsetDateTime
序列化成字符串。
1 |
|
测试 | Formatter 设置 | 参与格式化的对象 |
---|---|---|
1 上海时区withZone(Asia/Shanghai) |
OffsetDateTime 内部已经带 +08:00 偏移 |
formatter 先把对象转换成 Instant ,再用 上海时区 还原本地时间——因为上海当前偏移就是 +08:00,恢复后仍是 14:38:42 |
2 不指定时区 | 没有 override zone | OffsetDateTime 自己的 +08:00 就足够生成本地时间 → 14:38:42 |
3 指定东京时区 | withZone()设定为东京时区 | 1. 对象本身:2025-08-07T14:38:42+08:00 对应的 Instant = 2025-08-07T06:38:42Z 2. formatter指定偏移量+9:00, 因此格式化成2025-08-07 15:38:42 |
示例:解析淘宝API的时间实现如下:
1
2
3
4
5
6
7private val TAOBAO_FMT: DateTimeFormatter = DateTimeFormatter
.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.of("Asia/Shanghai")) // 淘宝按北京时间解析
fun parseTaobaoDateTime(dateTimeStr: String): OffsetDateTime =
LocalDateTime.parse(dateTimeStr, TAOBAO_FMT)
.atOffset(ZoneOffset.ofHours(8))这里
dateTimeStr
形如“yyyy-MM-dd HH:mm:ss”
的字符串,没有时区信息,但根据注释知道它代表北京时间。所以:- Formatter使用模式
yyyy-MM-dd HH:mm:ss
并通过withZone(Asia/Shanghai)
设定了解析/格式化的默认时区为上海(东八区)。 LocalDateTime.parse
使用该formatter,将字符串解析成一个LocalDateTime
对象。由于模式中没有偏移符号,解析结果是一个没有偏移的LocalDateTime,相当于“2025-08-06 03:24:14”这一天北京时间的本地时间。- 随后调用
.atOffset(ZoneOffset.ofHours(8))
,将LocalDateTime按+08:00偏移附加,转换为OffsetDateTime。
这一系列操作后得到的OffsetDateTime表示的就是北京时间2025-08-06 03:24:14对应的绝对时间点。在这种用法中,
withZone(ZoneId.of("Asia/Shanghai"))
和随后.atOffset(+8)
在效果上都体现了“将本地时间视为东八区时间”这一意图。不过它们作用的层次不同:withZone是作用在解析/格式化器上的(提供默认时区用于解析或用于将Instant等转换成人类时间),而atOffset
是作用在时间对象上的(为LocalDateTime直接赋予一个偏移得到OffsetDateTime)。在解析这一步中,两者结合确保了最终结果具有正确的偏移。如果没有使用withZone,那么解析字符串时其实不涉及时区,上面的LocalDateTime.parse会得到同样的LocalDateTime结果;关键是通过atOffset(ZoneOffset.ofHours(8))
明确了这段本地时间处于UTC+8偏移。(正如该答案评论所指出,formatter即使有withZone,解析LocalDateTime时也会忽略该Zone设置,仅在需要Instant/ZonedDateTime等绝对时间类型时才用上它)。- Formatter使用模式
atOffset 与 withZone 区别:正如上面分析,
LocalDateTime.atOffset(offset)
是将一个无时区的LocalDateTime转为带固定偏移的OffsetDateTime;而DateTimeFormatter.withZone(zone)
是为格式化/解析提供一个默认Zone上下文。两者可以达到类似目的(将某个本地时间与特定偏移关联),但使用场景不同:- 如果已经有一个LocalDateTime对象,需要赋予其偏移用于存储或传输,使用
atOffset
。例如localDateTime.atOffset(ZoneOffset.UTC)
得到UTC偏移的时间。 - 如果在格式化/解析阶段就想指定时区参考,则使用
withZone
。例如格式化Instant时必须指定withZone,否则无法直接格式化;解析没有时区的信息时也可用withZone提供一个默认偏移。
两者不是冗余的,而是API在不同层面提供的灵活性。对于上面的淘宝时间例子,其实完全可以不在DateTimeFormatter中指定.withZone,而仅在解析后调用
.atOffset(ZoneOffset.ofHours(8))
来附加偏移(正如代码所做)。formatter加与不加withZone对LocalDateTime.parse的结果没有区别,但如果同一个formatter还用于格式化Instant等,那么withZone就有用了。例如可以用TAOBAO_FMT直接格式化Instant得到北京时间字符串,这正是withZone的用武之地。- 如果已经有一个LocalDateTime对象,需要赋予其偏移用于存储或传输,使用
转换 String 到 OffsetDateTime
OffsetDateTime.parse(String)
只接受 完整 ISO-8601 字符串——也就是 日期-时间后面必须紧跟偏移量(Z
或 ±HH:mm
)。否则会解析失败
此时,对于"2025-08-07 14:38:42"
这样格式的字符串,就需要DateFormatter
的帮助,但是仅仅靠formatter也没有用,如下所示:
1 |
|
也就是说,如果我直接用这个formatter去解析string的话,还是会报错,因为解析器能把 年月日时分秒 提取出来,却得不到 ZoneOffset
,OffsetDateTime
又必须要 offset,于是抛出DateTimeParseException
因此,合理的做法是,现将其转换为LocalDateTime
,再通过atOffset
方法转换成OffsetDateTime
1 |
|
小结
使用DateTimeFormatter时,牢记以下要点:
- 对于
Instant
等无时区的对象进行格式化,一定要有时区信息(可在格式化前将Instant转换为ZonedDateTime/OffsetDateTime,或直接在Formatter上withZone一个ZoneId)。 - 解析字符串成Instant/OffsetDateTime时,如果字符串不含时区信息,需要结合约定的时区。可以先parse为LocalDateTime再指定偏移,或用withZone+ZonedDateTime.parse一步到位。
withZone
不会影响解析LocalDateTime的结果,但会在你解析成Instant/ZonedDateTime或者格式化Instant时发挥作用。同一个formatter可以反复使用,因此在formatter上预设withZone是很方便的方式。
不同时间类型的相互转换
在Java应用中,经常需要在 Instant、OffsetDateTime、LocalDateTime 之间转换。下面总结常用转换方式:
Instant → OffsetDateTime
调用 instant.atOffset(zoneOffset)
,将Instant附加一个偏移,得到对应的OffsetDateTime。例如:Instant.now().atOffset(ZoneOffset.ofHours(8))
将当前UTC时间转换为东八区时间的OffsetDateTime(相当于北京时间)。也可以使用静态方法 OffsetDateTime.ofInstant(instant, zoneId)
达到相同效果。
我们看到,经过测试,这两者都可以将Instant
转换为OffsetDateTime
类型
1 |
|
1 |
|
OffsetDateTime → Instant
调用 offsetDateTime.toInstant()
即可取得对应的Instant绝对时间点。这会将OffsetDateTime按其Offset换算到UTC。例如OffsetDateTime.parse("2025-08-06T03:24:14+08:00").toInstant()
得到2025-08-05T19:24:14Z
(UTC前一天19:24:14)。
1 |
|
LocalDateTime → OffsetDateTime
需要提供一个ZoneOffset偏移,用 localDateTime.atOffset(offset)
或静态方法 OffsetDateTime.of(localDateTime, offset)
来创建。例如:OffsetDateTime.of(localDateTime, ZoneOffset.ofHours(8))
。这一步相当于假定该LocalDateTime是在给定偏移时区下发生的。本质上,LocalDateTime + Offset 才能确定唯一时间点。
如下所示,当我在转换时指定不同的offset,出来的时刻也是不一样的
1 |
|
OffsetDateTime → LocalDateTime
可以直接调用 offsetDateTime.toLocalDateTime()
获取其本地日期时间部分(Offset将被丢弃)。一般只有在不关心时区的场景下才这么做(比较危险),例如只想取出本地日历时间部分进行显示。但通常业务逻辑中最好保留偏移,除非有充分理由。
如下所示,当转到localDateTime的时候,丢失了+8:00,这时候如果下一个人拿到,就不知道他是属于哪个时区的了,容易出错
1 |
|
LocalDateTime → Instant
因为LocalDateTime不含时区,需要提供一个ZoneOffset才能确定唯一Instant。Java 8中可以使用 localDateTime.toInstant(offset)
直接得到Instant。
或者等效地,先将LocalDateTime附加偏移/时区再转Instant,如:localDateTime.atZone(zoneId).toInstant()
或 localDateTime.atOffset(offset).toInstant()
。
1 |
|
用了第二种方法,我们发现其实是先将LocalDateTime转换成ZonedDateTime这个中间态,然后再转换成Instant的
1 |
|
Instant → LocalDateTime
需要指定目标时区,将Instant转换为该时区的本地时间。例如使用静态方法:LocalDateTime.ofInstant(instant, zoneId)
可得到指定Zone下对应的LocalDateTime。
如下所示,指定不同的offset,可以把Instant
变成不同的LocalDateTime
1 |
|
感想
尽量不要用LocalDateTime,除非是在把"2025-08-07 14:38:42"
这种类型的字符串转换成OffsetDateTime
或Instant
的时候,需要LocalDateTime
作为中间状态
PostgreSQL 中 TIMESTAMP WITH TIME ZONE 的存储与读取
PostgreSQL 提供了两种时间戳类型:TIMESTAMP WITHOUT TIME ZONE
和 TIMESTAMP WITH TIME ZONE
(简称 timestamptz)。通常数据库使用的是后者。
理解它的行为对我们的Java代码存取时间尤为重要:
- 存储:当我们往
timestamptz
字段存入值时,无论提供什么时区偏移,PostgreSQL都会将时间值转换为 UTC后存储。换句话说,TIMESTAMP WITH TIME ZONE并不真的存储时区信息
,它只是利用插入值的偏移量把时间规范化为UTC,再保存时间点。例如,存入'2025-08-06 11:24:14+08:00'
和'2025-08-06 03:24:14+00:00'
(UTC时间)实际上会存储为相同的UTC时间点,只是前者插入时减去了8小时转换为UTC。正因为如此,列类型名称中的“with time zone”容易让人误解,实际上它只是存了UTC时间戳。 - 不保存原始偏移:由于上述转换,数据库并不记录插入时附带的时区偏移。因此,从数据库读取
timestamptz
时,我们拿到的只是那个绝对时间点,至于它原先属于哪个时区,需要应用层自己知道或另存。例如,上面存入的两个值无论哪种偏移,存储后都是同一个UTC时间点,所以查询出来并不知道最初是哪种偏移。
如下所示,并没有时区信息,都是以UTC时间存储的:
Java映射类型选择:对于PostgreSQL的
timestamptz
列,推荐使用OffsetDateTime
或Instant
类型在Java中接收:- OffsetDateTime直观地包含了日期时间和偏移。JPA/Hibernate 默认会将OffsetDateTime属性映射为数据库TIMESTAMP WITH TIME ZONE列。需要注意如前所述,读取时OffsetDateTime偏移将是UTC(除非你修改了会话时区返回的行为,但一般不建议那样做。如果拿到OffsetDateTime后需要转成本地时区显示,可以再调整偏移。
- Instant也可用于表示绝对时间点,对应数据库的TIMESTAMP WITH TIME ZONE。例如,Spring Data JPA会将Instant自动映射为timestamptz列。Instant始终是UTC,不存在偏移困扰,非常适合处理绝对时间戳。当需要显示给用户时,再根据需要转换为特定Zone的时间。
两者选择上,如果更多地关心人与本地时间语义(例如日志时间、业务发生当地时间等),OffsetDateTime可能方便一些;如果只关心统一的时间线排序或存储,Instant也很好用。在内部计算时,它们都可以互相转换而不丢失精度。
比如说这样一条数据:
1 | seven: |
他在数据库中存储的样子是:2025-05-23 12:59:58.000000 +00:00
这样的UTC时间
但是,我用 jooq 读取出来之后,会根据本地JVM的时区信息,自动将其转换成OffsetDateTime
类型
如下所示:可以看出这里example.tbCreatedAt
的类型是OffsetDateTime
,偏移量已经加上
1 |
|
- TIMESTAMP WITHOUT TIME ZONE:这里提及一下区别。如果数据库列是
timestamp without time zone
,则PostgreSQL不会对插入值做时区转换,一律按照字面值存储。例如插入'2025-08-06 11:24:14+08:00'
在此情况下会忽略+08偏移,实际存储为2025-08-06 11:24:14
(认为这是本地时间)。这种类型在Java中通常映射为LocalDateTime。