Java 里处理日期时间,最容易出错的地方不是 API 记不住,而是把“时间点”“本地日期时间”和“显示时区”混在一起。Date 打印出来有年月日时分秒,很多人就以为它内部保存了时区;LocalDateTime 看起来更现代,又容易被当成可以跨系统传递的绝对时间。
我更习惯先把问题拆成三件事:这个值到底表示一个全球唯一的时间点,还是某个地区墙上钟表显示的时间;字符串解析时按哪个时区解释;落库、接口和日志里要不要保留时区语义。
Date 保存的是时间点
java.util.Date 表示的是一个时间点,本质上保存的是一个 long 毫秒数:从 UTC 1970-01-01 00:00:00 到当前时间点经过的毫秒数。
Date date = new Date();
System.out.println(date + ", " + date.getTime());
输出里看到的年月日时分秒,不是 Date 对象内部单独保存的字段,而是打印时根据系统默认时区把毫秒数格式化出来的结果。
所以同一个 Date 在北京、东京、伦敦展示出来可能不同,但它内部的毫秒数是同一个时间点。真正发生变化的是“展示方式”,不是这个时间点本身。
Date 与时区的关系
可以用同一个毫秒值分别按北京、东京、伦敦展示:
Date date = new Date(1503544630000L);
SimpleDateFormat bjSdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
bjSdf.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
SimpleDateFormat tokyoSdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
tokyoSdf.setTimeZone(TimeZone.getTimeZone("Asia/Tokyo"));
SimpleDateFormat londonSdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
londonSdf.setTimeZone(TimeZone.getTimeZone("Europe/London"));
System.out.println("毫秒数:" + date.getTime() + ", 北京时间:" + bjSdf.format(date));
System.out.println("毫秒数:" + date.getTime() + ", 东京时间:" + tokyoSdf.format(date));
System.out.println("毫秒数:" + date.getTime() + ", 伦敦时间:" + londonSdf.format(date));
这段代码的重点不是输出哪个城市的时间,而是提醒自己:Date 保存的是时间点,时区只在解析和格式化时参与。
因此工程里不要依赖 System.out.println(date) 这种默认展示结果来判断时间是否正确。线上机器默认时区、容器镜像时区、本地开发机时区都可能不同。
字符串解析必须明确时区
"2017-08-24 11:17:10" 这种字符串只有字面年月日时分秒,本身没有说明它属于哪个时区。按北京时间解释和按东京时间解释,会得到两个不同的时间点。
String timeStr = "2017-08-24 11:17:10";
SimpleDateFormat bjSdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
bjSdf.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
Date bjDate = bjSdf.parse(timeStr);
SimpleDateFormat tokyoSdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
tokyoSdf.setTimeZone(TimeZone.getTimeZone("Asia/Tokyo"));
Date tokyoDate = tokyoSdf.parse(timeStr);
System.out.println("北京时间解释:" + bjDate.getTime());
System.out.println("东京时间解释:" + tokyoDate.getTime());
如果业务说“这个字符串是北京时间”,解析器就必须明确设置 Asia/Shanghai。如果业务说“这个字符串来自用户所在地”,就要先拿到用户的 ZoneId,再去解释这个本地时间。
最危险的是接口里只写一个 yyyy-MM-dd HH:mm:ss,但文档不说明时区。调用方和服务端各按自己的默认时区理解,最后问题会变成“为什么差了 8 小时”。
时区转换分两步
时区转换不要理解成直接修改字符串。更稳的流程是:
- 按源时区解析字符串,得到时间点。
- 按目标时区格式化同一个时间点。
比如把北京时间转换成东京时间:
String timeStr = "2017-08-24 11:17:10";
SimpleDateFormat bjSdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
bjSdf.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
Date date = bjSdf.parse(timeStr);
SimpleDateFormat tokyoSdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
tokyoSdf.setTimeZone(TimeZone.getTimeZone("Asia/Tokyo"));
System.out.println(tokyoSdf.format(date));
这里 date 代表同一个瞬间,变化的只是输出视角。先拿到时间点,再换时区展示,代码会清楚很多。
java.time 里几个类型怎么选
新代码里更推荐使用 Java 8 之后的 java.time。但这些类型也不能混用:
| 类型 | 适合表达 |
| — | — |
| Instant | 全球唯一的时间点,适合日志、事件时间、数据库绝对时间。 |
| LocalDate | 只有日期,例如生日、账单日、自然日。 |
| LocalTime | 只有时间,例如每天 09:30 开始。 |
| LocalDateTime | 没有时区的本地日期时间,例如“上海时间 2026-06-10 09:00”里的字面部分。 |
| ZonedDateTime | 带地区时区规则的日期时间,例如 Asia/Shanghai。 |
| OffsetDateTime | 带固定 offset 的日期时间,例如 +08:00。 |
LocalDateTime 不是时间点。它只是一个本地日历上的年月日时分秒。如果要把它变成全局唯一时间点,必须配合 ZoneId:
LocalDateTime localDateTime = LocalDateTime.of(2026, 6, 10, 9, 0);
ZoneId zoneId = ZoneId.of("Asia/Shanghai");
Instant instant = localDateTime.atZone(zoneId).toInstant();
这一步就是把“本地时间”解释成“某个时区下的时间点”。
SimpleDateFormat 不要多线程共享
老代码里经常会看到静态的 SimpleDateFormat:
private static final SimpleDateFormat FORMAT =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
这在多线程环境里有风险,因为 SimpleDateFormat 不是线程安全的。正式项目里有三个更稳的选择:
- 方法内创建,用完即丢。
- 用
ThreadLocal<SimpleDateFormat>包一层,兼容老接口。 - 新代码优先使用线程安全的
DateTimeFormatter。
如果已经迁到 java.time,格式化可以这样写:
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime value = LocalDateTime.parse("2026-06-10 09:00:00", formatter);
不过还是那句话,只有 LocalDateTime 还不够表达时间点。是否需要 ZoneId,取决于业务语义。
接口和落库要写清楚语义
工程里最应该统一的是边界约定:
| 场景 | 建议 |
| — | — |
| 数据库保存事件时间 | 优先保存 UTC 语义的时间点,字段注释写清单位和时区。 |
| 前后端接口传时间点 | 优先 ISO-8601 字符串或毫秒时间戳,文档明确时区和单位。 |
| 用户输入预约时间 | 保存本地日期时间时同时保存用户或业务时区。 |
| 日志时间 | 统一格式和时区,避免多机器排障时对不上。 |
| 老接口使用 Date | 在入口和出口转换,业务内部尽量使用 java.time。 |
我自己写接口时,会尽量避免只传 "yyyy-MM-dd HH:mm:ss"。如果必须这么传,也要在接口文档里写清楚它到底是北京时间、用户本地时间,还是服务端默认时区。
小结
Java 日期时间处理可以先记住这几条:
Date保存的是时间点,不保存业务时区。- 字符串解析时必须明确源时区,否则同一串字符可能变成不同时间点。
- 时区转换要先得到时间点,再按目标时区格式化。
- 新代码优先用
java.time,但不要把LocalDateTime当成全球唯一时间点。 - 多线程场景不要共享
SimpleDateFormat,优先使用DateTimeFormatter。 - 接口、数据库和日志里要写清楚时间单位、时区和业务语义。
大多数“时间差 8 小时”的问题,本质上都不是 Java API 不会用,而是边界语义没有写清楚。




