Java 日期时间三十年战争
一份横跨Java类型演进、数据库映射、前端交互的全景式避坑指南
引言:一个让无数程序员崩溃的“小问题”
如果你是一位Java开发者,你一定见过这样的代码:
// 猜猜这是几月?
Calendar cal = Calendar.getInstance();
cal.set(2024, 2, 1);
System.out.println(cal.get(Calendar.MONTH)); // 猜猜输出什么?2
3
4
答案是 2 ——但如果你以为这是2月,那就掉坑了。Calendar的月份从0开始,2代表的是3月。这个“反人类”的设计,坑了一代又一代Java程序员。
更令人头疼的是,当你小心翼翼地在Java中处理好了时间,把它存进数据库,再展示到前端——时区漂移、格式错乱、2038年危机……每一个环节都可能让你彻夜难眠。
日期时间处理,看似简单,实则是Java生态中最容易被低估的复杂性黑洞。从JDK 1.0的 java.util.Date,到JDK 1.1的Calendar,再到JDK 8的 java.time 包(JSR-310),Java花了二十多年才把“时间”这件事整明白。而数据库映射和前端交互,又为这道难题增加了新的维度。
今天,我们就来一次Java日期类型的全景式深度剖析——从Java类型本身,到数据库映射,再到前端交互,看看到底谁该用、谁该扔、哪里会踩坑、如何优雅地“过河”。
第一章:考古现场——JDK 8之前的“黑暗时代”
java.util.Date —— 一个“名不副实”的类
Date是Java 1.0就存在的元老级类。但它的名字极具欺骗性——它并不代表一个日历上的日期(比如“2026-07-29”),而是代表一个时间戳——从Unix纪元(1970-01-01T00:00:00Z)开始的毫秒数。
// 看似在创建日期,实际上在操作时间戳
Date date = new Date();
System.out.println(date.getTime()); // 1710517044440 —— 一串数字2
3
Date的三大“罪状”:
- 罪状一:可变性(线程不安全) —— Date对象的值可以被随意修改(
setTime()方法)。在多线程环境下,一个线程修改了Date,另一个线程可能读到错误的值。 - 罪状二:设计反直觉 —— 月份从0开始(0=一月),年份从1900开始偏移。比如
new Date(124, 2, 1)代表的是2024年3月1日(124代表2024年,2代表3月)。 - 罪状三:大部分方法已废弃 ——
getYear()、getMonth()、getDay()等方法都被标记为@Deprecated。官方都告诉你“别用了”,但无数遗留代码还在用。
java.util.Calendar —— 一次“失败的救赎”
JDK 1.1引入了Calendar,目的是矫正Date的缺陷。但遗憾的是,Calendar只是把问题换了一种方式呈现。
Calendar calendar = Calendar.getInstance();
int month = calendar.get(Calendar.MONTH); // 依然是0-11
int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK); // 周日=1,周一=2...2
3
Calendar的问题清单同样触目惊心:
- 依然是可变的,线程不安全
- 月份依然从0开始,只是去掉了年份从1900开始的坑
- API极其臃肿,需要通过
Calendar.getInstance()获取实例 - 字段常量混乱——
Calendar.MONTH、Calendar.DAY_OF_MONTH、Calendar.DAY_OF_WEEK……光记住这些常量就够喝一壶的
有人甚至说:“Calendar可以说是JDK中最糟糕的类。”
SimpleDateFormat —— 线程不安全的“定时炸弹”
配套的SimpleDateFormat同样是个大坑——它不是线程安全的。在高并发场景下,多个线程共享同一个SimpleDateFormat实例会导致格式化错乱甚至数据损坏。
// 错误示范:共享SimpleDateFormat
private static final SimpleDateFormat sdf =
new SimpleDateFormat("yyyy-MM-dd"); // ❌ 线程不安全!
// 正确做法:每次new一个,或者用ThreadLocal2
3
4
5
实测数据显示:在10个线程并发处理时间操作的场景下,使用Date+SimpleDateFormat的方案会出现约15%的概率格式错乱,而新API方案零错误。
第二章:革命曙光——JDK 8的java.time包
2014年,Java 8发布了。它借鉴了Joda-Time的成功经验,引入了全新的java.time包(JSR-310)。这次,Java终于把“时间”这件事做对了。新API的核心设计哲学是:不可变、线程安全、语义清晰。
新API家族谱系
| 类 | 含义 | 是否含时区 | 精度 |
|---|---|---|---|
| LocalDate | 仅日期(年-月-日) | ❌ | 天 |
| LocalTime | 仅时间(时:分:秒:纳秒) | ❌ | 纳秒 |
| LocalDateTime | 日期+时间 | ❌ | 纳秒 |
| ZonedDateTime | 日期+时间+时区 | ✅ | 纳秒 |
| OffsetDateTime | 日期+时间+UTC偏移量 | 仅偏移量 | 纳秒 |
| Instant | 时间戳(机器时间) | ✅ UTC | 纳秒 |
| Period | 日期量(年/月/日) | ❌ | — |
| Duration | 时间量(秒/纳秒) | ❌ | — |
| DateTimeFormatter | 格式化/解析 | — | — |
核心类的精确定位
- LocalDate —— “只要日期,不要时间”。适合:生日、纪念日、合同签订日期、财务报表日期等。
LocalDate birthday = LocalDate.of(1990, 5, 20); // 月份从1开始!✅
LocalDate today = LocalDate.now();
int age = Period.between(birthday, today).getYears(); // 计算年龄2
3
- LocalTime —— “只要时间,不要日期”。适合:营业时间、打卡时间、会议开始时间等。
LocalTime openingTime = LocalTime.of(9, 0);
LocalTime closingTime = LocalTime.of(18, 0);2
- LocalDateTime —— “日期+时间,但不管时区”。适合:订单创建时间、本地事件记录、日志时间戳(不跨时区时)。
LocalDateTime orderTime = LocalDateTime.now(); // 2026-07-29T14:30:00
LocalDateTime deadline = orderTime.plusDays(3).withHour(23).withMinute(59);2
- ZonedDateTime —— “带时区的完整时间”。适合:跨时区会议、国际航班时刻、全球化系统的用户时间。
ZonedDateTime beijingTime = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
ZonedDateTime newYorkTime = beijingTime.withZoneSameInstant(ZoneId.of("America/New_York"));2
Instant —— “纯机器时间”。适合:日志时间戳、分布式系统事件排序、缓存过期时间、API时间戳传输。
Instant now = Instant.now(); // 2026-07-29T06:30:00Z (UTC)
// 分布式系统中,所有节点用Instant记录事件,天然可排序2
- OffsetDateTime —— 介于
Instant和ZonedDateTime之间。它只记录UTC偏移量(如+08:00),但不记录完整的时区规则(如夏令时)。适合:网络协议传输、数据库存取。
第三章:性能大对决——数据会说话
光说不练假把式。我们用数据说话:
创建性能
Instant.now()最快new Date()比Calendar.getInstance()快约3倍- Java 8新API整体优于传统API
格式化性能
- DateTimeFormatter 比 SimpleDateFormat 快约2-3倍
- SimpleDateFormat 线程不安全,每次需要new或加锁
实测:将字符串 "2024-03-15T12:00:00" 转换为对象——
- Date方案:平均耗时 1.2ms/百次
- LocalDateTime方案:平均耗时 0.3ms/百次
新版API速度提升4倍。
内存占用
创建百万个时间对象:
- Date堆内存:约 480MB
- LocalDateTime堆内存:约 210MB
- GC次数减少60%
新API更省内存的原因是:去掉了冗余的时区字段,对象结构更紧凑。
第四章:选型决策树——别再选错了
面对这么多类型,该怎么选?这是一棵决策树:
代码开始
│
├─ 只需要日期(年-月-日)? → LocalDate
│
├─ 只需要时间(时:分:秒)? → LocalTime
│
├─ 需要日期+时间?
│ │
│ ├─ 不需要时区? → LocalDateTime
│ │
│ └─ 需要时区?
│ │
│ ├─ 需要完整的时区规则(夏令时等)? → ZonedDateTime
│ │
│ └─ 只需要UTC偏移量(如+08:00)? → OffsetDateTime
│
└─ 只需要时间戳(机器时间,不关心人类可读)? → Instant2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
快速参考卡片
| 你的需求 | 用这个 | 别用这个 |
|---|---|---|
| 生日、日期 | LocalDate | Date、Calendar |
| 打卡、营业时间 | LocalTime | Date、Calendar |
| 订单创建时间 | LocalDateTime | Date、Calendar |
| 跨时区会议 | ZonedDateTime | Date + TimeZone |
| 日志时间戳 | Instant | Date |
| 格式化日期 | DateTimeFormatter | SimpleDateFormat |
| 日期计算(年龄) | Period | 手动 Calendar 计算 |
| 时间计算(耗时) | Duration | 手动毫秒计算 |
第五章:数据库映射——从“鸡同鸭讲”到“精准对齐”
日期类型在Java和数据库之间的映射,是生产环境中最容易出现“幽灵Bug”的地方。你以为存的是“2026-07-29 14:30:00”,查出来却变成了“2026-07-29 06:30:00”——8小时时差,足以让一个跨国电商系统的订单时间全线崩溃。
映射全景图
JDBC 4.2(Java 8引入)规范定义了Java java.time类型与SQL标准类型的映射关系:
| SQL标准类型 | 推荐Java类型 | 语义说明 | ||
|---|---|---|---|---|
| DATE | DATE | DATE | LocalDate | 纯日期,无时间 |
| TIME | TIME | TIME | LocalTime | 纯时间,无日期 |
| TIMESTAMP WITHOUT TIME ZONE | DATETIME | TIMESTAMP | LocalDateTime | 日期+时间,无时区 |
| TIMESTAMP WITH TIME ZONE | TIMESTAMP | TIMESTAMPTZ | OffsetDateTime / Instant | 日期+时间,有时区 |
》⚠️ 关键认知:LocalDateTime在SQL标准中对应的是TIMESTAMP WITHOUT TIME ZONE。它不是一个时间点,而是一组“日历字段”(年、月、日、时、分、秒)的集合。把它存进TIMESTAMP WITH TIME ZONE(MySQL的TIMESTAMP)列,就像把一张写着“下午3点”的纸条交给不同时区的人——每个人理解的“下午3点”都不一样。
MySQL的“DATETIME vs TIMESTAMP”——一字之差,天壤之别
这是最容易踩的坑,没有之一。
DATETIME —— “照镜子”类型
- 存储你给它的字面值,不做任何转换
- 例如:你存
2026-07-29 14:30:00,读出来就是2026-07-29 14:30:00 - 不包含时区信息,适合
LocalDateTime
TIMESTAMP —— “翻译官”类型
- 存储时:从当前时区转换到UTC再存储
- 读取时:从UTC转换回当前时区再返回
- 例如:你在东八区存
2026-07-29 14:30:00,数据库存的是2026-07-29 06:30:00(UTC)。换一个UTC时区的应用来读,拿到的是2026-07-29 06:30:00——同一个时间点,不同时区的“翻译”结果不同 - 存储上限:TIMESTAMP只能存到
2038-01-19(32位时间戳溢出问题)
// ❌ 错误示范:将LocalDateTime存入TIMESTAMP列
LocalDateTime localTime = LocalDateTime.of(2026, 7, 29, 14, 30, 0);
// 数据库是UTC时区,存进去的是 2026-07-29 06:30:00
// 换一个东八区的应用来读,拿到的却是 2026-07-29 14:30:00
// 你以为存的是"东八区下午2点半",实际上存的是"UTC上午6点半"
// ——两个应用拿到的是同一个"字面值"吗?不是!是同一个时刻的不同表示!2
3
4
5
6
MySQL官方文档明确指出:将LocalDateTime存储到TIMESTAMP列时,你可能无法存储正确的基于UTC的值,因为该值的时区实际上是未定义的。
最佳实践:
- 数据库字段用DATETIME → Java用LocalDateTime(语义对齐,无时区转换风险)
- 数据库字段用TIMESTAMP → Java用Instant或OffsetDateTime(语义对齐,明确时区)
JPA/Hibernate映射实战
新项目(推荐) :直接使用LocalDateTime/LocalDate,无需@Temporal注解。
@Entity
public class Order {
@Id
private Long id;
// ✅ 推荐:LocalDateTime + 无@Temporal
@Column(name = "created_at", columnDefinition = "DATETIME")
private LocalDateTime createdAt;
// ✅ 纯日期用LocalDate
@Column(name = "delivery_date", columnDefinition = "DATE")
private LocalDate deliveryDate;
// ✅ 需要时区用Instant
@Column(name = "updated_at", columnDefinition = "TIMESTAMP")
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = Instant.now();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
遗留系统(兼容旧代码) :如果必须用java.util.Date,需要配合@Temporal注解:
@Entity
public class LegacyEntity {
@Column(name = "create_time")
@Temporal(TemporalType.TIMESTAMP) // DATE / TIME / TIMESTAMP
private Date createTime;
}2
3
4
5
6
⚠️ 时区统一配置:在 application.yml 中明确设置Hibernate的时区,避免JVM默认时区差异导致的问题:
spring:
jpa:
properties:
hibernate:
jdbc:
time_zone: UTC # 统一用UTC,避免时区漂移2
3
4
5
6
MyBatis映射
MyBatis 3.4.5+内置了LocalDateTimeTypeHandler,无需额外配置:
<resultMap id="orderResultMap" type="Order">
<result property="createTime" column="create_time"
typeHandler="org.apache.ibatis.type.LocalDateTimeTypeHandler"/>
</resultMap>2
3
4
或者直接在实体类中使用,MyBatis会自动处理:
public class Order {
private LocalDateTime createTime; // 自动映射
}2
3
数据库映射决策矩阵
| 你的业务需求 | 数据库字段类型 | Java类型 | 理由 |
|---|---|---|---|
| 生日、合同日期(无时间) | DATE | LocalDate | 语义完全一致 |
| 订单创建时间(不跨时区) | DATETIME | LocalDateTime | 无时区转换,最安全 |
| 用户操作日志(需全球排序) | TIMESTAMP | Instant | UTC存储,天然可排序 |
| 跨时区会议时间 | TIMESTAMP | ZonedDateTime | 保留完整时区规则 |
| API传输/网络协议 | TIMESTAMP | OffsetDateTime | JDBC标准推荐 |
第六章:特殊专题——当“TIMESTAMP的2038年限制”遇上“Instant的时区需求”
这是一个非常经典且现实的难题:我想存储Instant/OffsetDateTime来明确时区,但又想避开MySQL TIMESTAMP的2038年限制,该怎么办?
问题的根源
- MySQL 的
TIMESTAMP类型使用4字节存储,范围是'1970-01-01 00:00:01'UTC到'2038-01-19 03:14:07'UTC。 Instant和OffsetDateTime可以表示远超过 2038 年的时间点。- 直接映射
Instant到TIMESTAMP,在存2038年之后的日期时会报错。
核心解决方案:用 DATETIME + 约定存储 UTC 时间
你的目标“存储明确时区的时间”,本质上是要记录一个绝对的时间点(Instant)。最稳健的做法是:
- 数据库字段:定义为
DATETIME类型(范围1000-9999年,无2038限制)。 - 存储规则:约定所有时间值均以UTC时区存储。
- Java映射:在Java代码中,将
Instant或OffsetDateTime转换为UTC时间的LocalDateTime再存入数据库;读取时,再逆向转换回来。
代码示例
假设你的数据库字段是 created_at DATETIME。
存储时(Java -> 数据库)
// 1. 你的Java对象,使用Instant表示一个绝对时间点
Instant now = Instant.now();
// 2. 【关键】将精度截断到微秒,匹配MySQL DATETIME精度
Instant truncated = now.truncatedTo(ChronoUnit.MICROS);
// 3. 转换为UTC的LocalDateTime
LocalDateTime utcLdt = LocalDateTime.ofInstant(truncated, ZoneOffset.UTC);
// 4. 使用JDBC的setObject方法存入
preparedStatement.setObject(1, utcLdt);2
3
4
5
6
7
8
9
10
11
读取时(数据库 -> Java)
// 1. 从数据库读取,得到的是无时区信息的LocalDateTime
LocalDateTime utcLdt = resultSet.getObject(1, LocalDateTime.class);
// 2. 将其视为UTC时间,转换回Instant
Instant instant = utcLdt.toInstant(ZoneOffset.UTC);
// 3. 你的Java对象使用Instant
myObject.setCreatedAt(instant);2
3
4
5
6
7
8
JDBC驱动支持说明:JDBC 4.2规范要求驱动支持
OffsetDateTime,但对Instant和ZonedDateTime的支持是可选的。为了更好的兼容性,你可以在与JDBC交互的边界上使用OffsetDateTime(带UTC偏移),内部业务逻辑则继续使用Instant。
⚠️ 必须注意的关键问题与对策
这个方案非常成熟,但在落地时有几个关键点必须处理:
问题1:防止“时区漂移”(最重要!)
现象:如果你的JDBC驱动或应用程序的默认时区不是UTC,LocalDateTime在存入DATETIME字段时,可能会被驱动“自作主张”地转换,导致存进去的时间和你预期的不一致。
解决方案(三选一):
- 方案A:统一JVM时区参数(推荐):在启动JVM时,通过参数强制指定时区为UTC。
java -Duser.timezone=UTC -jar your-app.jar这是最干净、最全局的解决方式。
- 方案B:配置数据源时区:在数据库连接URL中指定时区。
jdbc:mysql://localhost:3306/db?serverTimezone=UTC- 方案C:在代码中显式转换(最保险):在任何数据库读写操作前,都将时间转换为UTC的LocalDateTime,如上面“核心方案”中的代码所示。这种方式不依赖外部环境,代码自包含,最为可靠。
问题2:纳秒精度丢失
现象:Instant的精度是纳秒(ns),而MySQL的DATETIME最高精度是微秒(µs)。直接存储Instant会导致纳秒部分被截断,可能在极端情况下(如单元测试)导致两个逻辑上相同的Instant对象不相等。
解决方案:在转换时,明确地对Instant进行截断(truncatedTo) 操作。
// 存储前,将Instant精度截断到微秒
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
// 然后再进行后续的 LocalDateTime.ofInstant(...) 转换2
3
三种方案的终极对比
| 方案 | 数据库类型 | Java类型 | 优点 | 缺点与风险 |
|---|---|---|---|---|
| ❌ 不推荐 | TIMESTAMP | Instant | 语义最匹配,存储空间小 | 有2038年限制,无法存储未来时间 |
| ✅ 推荐方案 | DATETIME | LocalDateTime (UTC) + 边界转换 | 无2038年限制,范围广,可读性好 | 需严格约定所有时间均为UTC,并注意防止时区漂移 |
| ❌ 备选方案 | BIGINT | Instant | 无2038年限制,无时区问题,排序快 | 可读性差,查询过滤不便(如按日期范围查询计算),需在应用层转换 |
最终建议
最平衡、最推荐的做法是:
- 数据库:使用 DATETIME 类型。
- Java:在业务逻辑层统一使用 Instant。
- 边界转换:在数据库访问层(Repository/DAO),统一将 Instant 转换为 UTC 的 LocalDateTime 进行读写。
- 环境配置:将应用服务器的默认时区设置为 UTC,或确保所有时间操作都显式使用 ZoneOffset.UTC。
这套方案既能彻底规避2038年问题,又能保持代码中时间语义的清晰(Instant代表绝对时刻),同时数据库中的存储也具备良好的可读性。
第七章:前端交互——从“鸡同鸭讲”到“对表成功”
前后端时间交互的混乱程度,不亚于数据库映射。前端可能发来"2026-07-29T14:30:00",也可能发来"2026/07/29 14:30",甚至是一个时间戳数字——而后端必须全部正确解析。
两个核心注解,各司其职
@DateTimeFormat (Spring框架)—— “接收专用”
- 作用于数据绑定阶段:将前端传来的字符串转换为Java时间对象
- 适用场景:表单提交(
@RequestParam、@ModelAttribute) - ⚠️ 对
@RequestBodyJSON请求无效——JSON反序列化由Jackson处理,不经过Spring的数据绑定
@JsonFormat (Jackson)—— “收发一体”
- 控制JSON的序列化(Java→JSON返回前端)和反序列化(JSON→Java接收前端)
- 适用场景:
@RequestBodyJSON请求、@ResponseBodyJSON响应 - 同时控制输入和输出格式,是前后端JSON交互的主力
public class OrderDTO {
// 场景1:表单提交(非JSON)—— 用@DateTimeFormat
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime submitTime;
// 场景2:JSON交互 —— 用@JsonFormat
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createTime;
// 场景3:两者都用到(既接收JSON又返回JSON,格式不同)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime updateTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
💡 一句话总结:
@DateTimeFormat管表单/URL参数(Spring MVC层),@JsonFormat管JSON(Jackson层)。混用不冲突,各管各的。
全局配置——让规范成为默认
方案一:全局application.yml配置(推荐)
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
serialization:
write-dates-as-timestamps: false # 不输出时间戳,输出格式化字符串2
3
4
5
6
方案二:全局Jackson配置(更灵活)
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder -> {
// 统一序列化格式
builder.serializers(new LocalDateTimeSerializer(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
builder.deserializers(new LocalDateTimeDeserializer(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
// 统一时区
builder.timeZone(TimeZone.getTimeZone("Asia/Shanghai"));
};
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
方案三:字段级注解(覆盖全局配置)
public class OrderVO {
// 覆盖全局配置,此字段用不同格式
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")
private Instant createdAt;
}2
3
4
5
时区——最容易“翻车”的一环
典型事故现场:
前端在东八区发送"2026-07-29 14:30:00",后端(默认UTC)收到后解析为2026-07-29 14:30:00 UTC,存入数据库变成14:30 UTC。前端再查出来,看到的是22:30(东八区)——整整差了8小时。
解决方案:
- 原则:服务端统一用UTC,前端展示时再转换
// 后端统一配置
spring.jackson.time-zone: UTC
// 接收前端时间时,明确时区
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
private LocalDateTime createTime;
// 或者使用Instant(天然UTC)
private Instant createdAt; // 前端传时间戳或ISO-86012
3
4
5
6
7
8
9
- 前端最佳实践:使用
new Date().toISOString()发送标准ISO-8601格式:
// 前端发送
const now = new Date().toISOString(); // "2026-07-29T06:30:00.000Z"
// 后端用Instant或配置了UTC时区的LocalDateTime接收2
3
前端交互决策速查表
| 场景 | 推荐方案 | 说明 |
|---|---|---|
| 表单提交(非JSON) | @DateTimeFormat | 只控制接收 |
| JSON请求/响应 | @JsonFormat | 同时控制收发 |
| 全项目统一格式 | application.yml 全局配置 | 省心省力 |
| 特定字段特殊格式 | 字段级 @JsonFormat | 覆盖全局 |
| 跨时区系统 | 服务端 UTC + 前端 toISOString() | 最安全 |
| 微服务间通信 | Instant + ISO-8601 | 无歧义 |
第八章:新旧互操作——如何安全“过河”
如果你的项目还在用Date,但想迁移到新API,这是安全转换的方式:
// Date → Instant → ZonedDateTime → LocalDateTime
Date oldDate = new Date();
LocalDateTime newDateTime = oldDate.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
// LocalDateTime → Instant → Date
LocalDateTime ldt = LocalDateTime.now();
Date date = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());2
3
4
5
6
7
8
9
⚠️ 关键陷阱:Date本身不包含时区信息,转换时必须明确指定时区,否则会依赖系统默认时区,导致不同环境结果不一致。
第九章:避坑指南——那些年我们踩过的“时间坑”
坑1:LocalDateTime存TIMESTAMP,时区漂移
现象:存进去是14:30,读出来变成06:30或22:30。
原因:LocalDateTime没有时区信息,存入TIMESTAMP列时,JDBC驱动用当前JVM时区将其转换为UTC。
解决:DATETIME配LocalDateTime,TIMESTAMP配Instant。
坑2:@DateTimeFormat放在@RequestBody上无效
现象:前端传JSON,
@DateTimeFormat纹丝不动,一直报400。原因:
@DateTimeFormat只对表单/URL参数生效,JSON反序列化由Jackson处理。解决:JSON用
@JsonFormat,表单用@DateTimeFormat。
坑3:前端传时间戳,后端用LocalDateTime接收失败
现象:前端传1710517044440,后端报InvalidFormatException。
原因:LocalDateTime无法直接从时间戳反序列化。
解决:改用Instant接收时间戳,或自定义反序列化器。
坑4:MySQL TIMESTAMP的2038年问题
现象:存2039-01-01时报错。
原因:MySQL的TIMESTAMP用32位存储,上限2038-01-19。
解决:需要用2038年以后的日期,用DATETIME + UTC LocalDateTime方案(详见第六章)。
坑5:SimpleDateFormat线程不安全(经典老坑)
现象:生产环境偶发格式错乱、数字乱跳。
原因:SimpleDateFormat在多线程下共享状态。
解决:用DateTimeFormatter(线程安全),定义为
static final常量。
坑6:数据库驱动版本过低
现象:
java.time类型报UnsupportedConversionException。原因:旧版JDBC驱动不支持JSR-310类型。
解决:MySQL驱动 ≥ 8.0.23,PostgreSQL驱动 ≥ 42.2.0。
坑7:Period/Duration混淆使用
现象:用Period计算时间差,结果不对。
原因:Period用于日期(年/月/日),Duration用于时间(时/分/秒/纳秒)。
解决:日期差用
Period.between(),时间差用Duration.between()。
第十章:最佳实践——写进团队规范
基于以上分析,建议你的团队建立以下规范:
✅ 新项目
- 全面拥抱
java.time,禁止使用Date和Calendar - 纯日期用LocalDate,纯时间用LocalTime,日期时间用LocalDateTime
- 跨时区用ZonedDateTime,时间戳用Instant
- DateTimeFormatter定义为静态常量(线程安全,可复用)
- 实体类用LocalDate/LocalDateTime,不要用Date
- 数据库字段:DATETIME配LocalDateTime,TIMESTAMP配Instant
- 服务端统一时区为UTC,前端展示时再转换
⚠️ 遗留系统维护
- 逐步将SimpleDateFormat替换为DateTimeFormatter
- 用Instant替代Date做时间戳存储
- 新旧API转换层明确指定时区
- 兼容旧接口时,在DTO或转换层用Date,领域层保持干净
📊 性能敏感场景
- 用Instant作为内部时间表示(最快、最轻量)
- 仅在需要展示给用户时,才转换为ZonedDateTime或格式化
- 缓存DateTimeFormatter实例(它是线程安全的)
第十一章:终极总结——一张图看懂全貌
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Java日期时间全景决策图 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ ┌──────────┐ │
│ │ 业务需求 │ → │ Java类型 │ → │ 数据库映射 │ → │ 前端交互 │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ └──────────┘ │
│ │
│ 纯日期(生日) → LocalDate → DATE → LocalDate │
│ 纯时间(打卡) → LocalTime → TIME → LocalTime │
│ 日期+时间(无时区) → LocalDateTime → DATETIME → LocalDateTime│
│ 日期+时间(有时区) → Instant → TIMESTAMP → Instant │
│ 跨时区业务 → ZonedDateTime → TIMESTAMPTZ → ZonedDateTime│
│ 2038年后时间 → Instant + → DATETIME(UTC) → Instant │
│ 截断到微秒 │ + 约定UTC │ │
│ │
├─────────────────────────────────────────────────────────────────────────────────┤
│ 三端交互铁律 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【Java选型三原则】 │
│ 1. 无时区用LocalDateTime,有时区用Instant/ZonedDateTime │
│ 2. 机器时间用Instant,人类时间用ZonedDateTime │
│ 3. 永远不要用Date和Calendar(除非维护遗留代码) │
│ │
│ 【数据库映射三原则】 │
│ 1. DATETIME ↔ LocalDateTime(语义对齐) │
│ 2. TIMESTAMP ↔ Instant/OffsetDateTime(明确时区) │
│ 3. 统一配置hibernate.jdbc.time_zone = UTC │
│ │
│ 【前端交互三原则】 │
│ 1. JSON用@JsonFormat,表单用@DateTimeFormat │
│ 2. 服务端统一UTC,前端展示时转换 │
│ 3. 全局配置 + 字段覆盖 = 最灵活 │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
结语:时间会证明一切
Java用二十多年的时间,走过了从Date到Calendar再到java.time的演进之路。这条路充满了设计失误、历史包袱和痛苦教训。
而数据库的TIMESTAMP与DATETIME之争、前端的时区漂移、2038年的“时间炸弹”……每一个环节都在提醒我们:时间处理没有银弹,但有铁律。
- 语义对齐 —— Java类型、数据库类型、前端格式的语义必须一致(有/无时区)
- 显式时区 —— 永远不要依赖系统默认时区,显式指定
- 统一规范 —— 全局配置 + 团队约定,比单点防御重要一万倍
- 边界转换 —— 在系统边界(数据库层、API层)明确转换,内部保持纯净
今天,我们终于有了一个正确、安全、高效的日期时间方案。2014年Java 8就已经发布了java.time,距今已超过10年。如果你的项目还在用Date和Calendar——是时候升级了。
毕竟,连时间自己都在往前走,你的代码凭什么停在原地?
