Java 日期时间 API
日期时间是业务系统高频能力:订单创建时间、支付超时时间、会员到期时间、定时任务、账单周期、跨时区展示都离不开它。日期时间处理错误,轻则展示不对,重则订单过期、计费错误、定时任务重复执行。
为什么推荐 java.time
早期 Java 的 Date 和 Calendar 存在很多问题:
| 老 API 问题 | 后果 |
|---|---|
Date 可变 | 传出去后可能被修改 |
| 月份从 0 开始 | 容易写错 |
| API 设计不直观 | 代码难读 |
| 时区处理混乱 | 跨地区业务容易出错 |
Java 8 引入 java.time,更清晰、更安全。
旧 API 到底有什么坑
java.util.Date 的名字很迷惑,它并不只是“日期”,内部本质上表示一个时间点,也就是从 1970-01-01T00:00:00Z 开始经过的毫秒数。
import java.util.Date;
public class DateMutableDemo {
public static void main(String[] args) {
Date expireTime = new Date();
change(expireTime);
System.out.println(expireTime);
}
private static void change(Date date) {
date.setTime(0L); // 外部传入的 Date 被修改
}
}Date 是可变对象。你把它从实体里返回给外部,外部就可以修改它,导致对象状态被绕过。
Calendar 的月份从 0 开始:
import java.util.Calendar;
Calendar calendar = Calendar.getInstance();
calendar.set(2026, 6, 1); // 这里的 6 表示 7 月,不是 6 月这类 API 设计会让代码很难读,也容易制造线上边界问题。新代码优先使用 Java 8 的 java.time。
SimpleDateFormat 为什么线程不安全
SimpleDateFormat 是旧项目里高频问题。它内部有可变状态,多线程共享一个实例时,解析和格式化可能互相污染。
错误写法:
import java.text.SimpleDateFormat;
import java.util.Date;
public class SimpleDateFormatBad {
private static final SimpleDateFormat FORMAT =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static String format(Date date) {
return FORMAT.format(date); // 多线程共享不安全
}
}正确选择:
- 新代码用
DateTimeFormatter,它不可变且线程安全。 - 老代码如果必须用
SimpleDateFormat,不要静态共享,可以局部创建或用ThreadLocal,但更推荐迁移。
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class DateTimeFormatterDemo {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static String format(LocalDateTime time) {
return time.format(FORMATTER);
}
}这也是 JDK 8 日期时间 API 在服务端项目里特别重要的原因之一。
核心类型
flowchart TD
A["java.time"] --> B["LocalDate 日期"]
A --> C["LocalTime 时间"]
A --> D["LocalDateTime 日期时间"]
A --> E["Instant 时间戳"]
A --> F["ZonedDateTime 带时区时间"]
A --> G["Duration 时间间隔"]
A --> H["Period 日期间隔"]| 类型 | 表达内容 | 示例 |
|---|---|---|
LocalDate | 年月日 | 2026-07-01 |
LocalTime | 时分秒 | 22:30:00 |
LocalDateTime | 年月日时分秒,不含时区 | 2026-07-01T22:30 |
Instant | UTC 时间线上的一个点 | 时间戳 |
ZonedDateTime | 带时区日期时间 | Asia/Shanghai |
Duration | 秒、纳秒级时间差 | 30 分钟 |
Period | 年、月、日级日期差 | 1 个月 |
怎么选:
| 业务语义 | 推荐类型 | 说明 |
|---|---|---|
| 生日、账单日、统计日期 | LocalDate | 只有日期,没有具体时分秒 |
| 每天几点执行 | LocalTime | 只有一天内的时间 |
| 本地创建时间 | LocalDateTime | 不带时区,适合同一时区系统内部展示 |
| 统一时间点、日志、事件时间 | Instant | UTC 时间线上的点,适合存储和比较 |
| 跨时区会议、用户本地展示 | ZonedDateTime | 同时包含本地时间和时区规则 |
| 30 秒、15 分钟、2 小时 | Duration | 精确时间间隔 |
| 1 天、1 月、1 年 | Period | 日历日期间隔 |
一句话:机器比较用 Instant,人类日历用 LocalDate/LocalDateTime,跨时区展示用 ZonedDateTime。
日期时间的工作原理
日期时间要分清两层含义:
- 人看到的日历时间,比如
2026-07-01 22:30:00。 - 机器计算的时间点,比如从 Unix epoch 开始经过了多少毫秒。
flowchart TD
A["人类日历时间"] --> B["结合时区规则"]
B --> C["转换为 Instant"]
C --> D["机器可比较的时间点"]
D --> E["再按目标时区格式化展示"]所以时间比较最好比较时间对象或时间戳,不要直接比较格式化后的字符串。跨时区系统必须明确:存储的是时间点,展示时才转换成人所在时区的本地时间。
LocalDateTime 为什么不能代表全球唯一时间点
LocalDateTime 没有时区。2026-07-01 10:00:00 在上海和纽约不是同一个瞬间。
import java.time.LocalDateTime;
import java.time.ZoneId;
public class LocalDateTimeZoneDemo {
public static void main(String[] args) {
LocalDateTime local = LocalDateTime.of(2026, 7, 1, 10, 0);
long shanghaiMillis = local.atZone(ZoneId.of("Asia/Shanghai"))
.toInstant()
.toEpochMilli();
long newYorkMillis = local.atZone(ZoneId.of("America/New_York"))
.toInstant()
.toEpochMilli();
System.out.println(shanghaiMillis);
System.out.println(newYorkMillis);
System.out.println(shanghaiMillis == newYorkMillis);
}
}所以跨时区系统不能只传 LocalDateTime。至少要同时明确时区,或者统一传 Instant / epoch milliseconds。
基本使用
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
public class DateTimeBasicDemo {
public static void main(String[] args) {
LocalDate today = LocalDate.now();
LocalTime nowTime = LocalTime.now();
LocalDateTime now = LocalDateTime.now();
System.out.println(today);
System.out.println(nowTime);
System.out.println(now);
}
}日期计算
import java.time.LocalDateTime;
public class DateCalculateDemo {
public static void main(String[] args) {
LocalDateTime createTime = LocalDateTime.now();
LocalDateTime expireTime = createTime.plusMinutes(30);
System.out.println("创建时间:" + createTime);
System.out.println("过期时间:" + expireTime);
System.out.println("是否过期:" + LocalDateTime.now().isAfter(expireTime));
}
}订单超时、优惠券有效期、会员到期,都可以用类似思路处理。
plusDays 和 plusHours 不一样
plusDays(1) 是日历意义上的加一天,plusHours(24) 是精确加 24 小时。大多数地区平时看起来一样,但遇到夏令时切换就可能不同。
import java.time.ZoneId;
import java.time.ZonedDateTime;
public class DstDemo {
public static void main(String[] args) {
ZoneId zone = ZoneId.of("America/New_York");
ZonedDateTime time = ZonedDateTime.of(2026, 3, 8, 1, 30, 0, 0, zone);
System.out.println(time.plusDays(1));
System.out.println(time.plusHours(24));
}
}如果业务是“明天同一当地时间”,更接近 plusDays(1);如果业务是“严格 24 小时后过期”,更接近 plusHours(24) 或 Duration.ofHours(24)。
格式化和解析
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class DateFormatDemo {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
LocalDateTime now = LocalDateTime.now();
String text = now.format(FORMATTER);
LocalDateTime parsed = LocalDateTime.parse(text, FORMATTER);
System.out.println(text);
System.out.println(parsed);
}
}DateTimeFormatter 是不可变且线程安全的,可以定义为静态常量。
时区为什么重要
LocalDateTime 不包含时区,它只是一个“本地日期时间”。同样的 2026-07-01 10:00:00,在上海和纽约代表的 UTC 时间点不同。
flowchart TD
A["LocalDateTime"] --> B["没有时区"]
C["ZoneId"] --> D["时区规则"]
A --> E["ZonedDateTime"]
D --> E
E --> F["Instant 时间线上的点"]Demo:
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
public class ZoneDemo {
public static void main(String[] args) {
ZonedDateTime shanghai = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
Instant instant = shanghai.toInstant();
ZonedDateTime newYork = instant.atZone(ZoneId.of("America/New_York"));
System.out.println(shanghai);
System.out.println(newYork);
}
}商业系统建议:
- 数据库存储统一时间,常见做法是 UTC 或服务器统一时区。
- 前端展示时按用户所在时区转换。
- 跨国业务不要只存
LocalDateTime,要明确时区策略。
数据库存时间怎么选
数据库存时间没有唯一答案,但必须统一策略。
常见策略:
| 策略 | 优点 | 风险 |
|---|---|---|
| 存 UTC 时间戳或 UTC 时间 | 跨时区一致,适合事件时间 | 展示前必须转换时区 |
| 存服务器统一时区时间 | 国内单时区系统简单 | 迁移服务器时区或跨国业务会复杂 |
| 存本地时间 + 时区 | 语义完整,适合预约、会议 | 字段更多,查询和转换更复杂 |
国内单体业务常见做法是统一服务器、数据库、JVM 时区为 Asia/Shanghai,数据库保存 datetime。但如果是跨地区、跨国、多租户系统,更推荐保存 UTC 时间点,并在展示层按用户时区转换。
流程:
flowchart TD
A["用户提交本地时间"] --> B["携带用户时区"]
B --> C["转换为 Instant"]
C --> D["数据库保存统一时间点"]
D --> E["查询时取出 Instant"]
E --> F["按用户时区格式化展示"]重点不是“哪种永远正确”,而是系统内不能混用。最怕一部分代码按 UTC,一部分按服务器本地时区,一部分按前端字符串处理。
时间戳转换
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
public class TimestampDemo {
public static void main(String[] args) {
long millis = System.currentTimeMillis();
LocalDateTime time = LocalDateTime.ofInstant(
Instant.ofEpochMilli(millis),
ZoneId.of("Asia/Shanghai")
);
long restored = time.atZone(ZoneId.of("Asia/Shanghai"))
.toInstant()
.toEpochMilli();
System.out.println(time);
System.out.println(restored);
}
}秒和毫秒不要混
时间戳常见两种单位:
| 单位 | 示例长度 | Java API |
|---|---|---|
| 秒 | 10 位左右 | Instant.ofEpochSecond(seconds) |
| 毫秒 | 13 位左右 | Instant.ofEpochMilli(millis) |
错误示例:
long seconds = 1783296000L;
Instant wrong = Instant.ofEpochMilli(seconds); // 把秒当毫秒,时间会跑到 1970 附近正确:
Instant instant = Instant.ofEpochSecond(seconds);线上经常出现“前端传 10 位秒时间戳,后端按 13 位毫秒解析”,导致时间相差几十年。
Duration 和 Period
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Period;
public class DurationPeriodDemo {
public static void main(String[] args) {
Duration duration = Duration.between(
LocalDateTime.now(),
LocalDateTime.now().plusHours(2)
);
Period period = Period.between(
LocalDate.now(),
LocalDate.now().plusMonths(1)
);
System.out.println(duration.toMinutes());
System.out.println(period.getMonths());
}
}Duration 适合精确时间差,Period 适合日期差。
Clock:让时间逻辑可测试
业务代码里到处直接调用 LocalDateTime.now(),测试会很难写,因为当前时间一直变。
更好的方式是注入 Clock:
import java.time.Clock;
import java.time.LocalDateTime;
public class OrderExpireService {
private final Clock clock;
public OrderExpireService(Clock clock) {
this.clock = clock;
}
public boolean isExpired(LocalDateTime createTime) {
LocalDateTime now = LocalDateTime.now(clock);
return now.isAfter(createTime.plusMinutes(30));
}
}测试时可以固定当前时间:
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
Clock fixedClock = Clock.fixed(
Instant.parse("2026-07-01T10:00:00Z"),
ZoneId.of("UTC")
);这在订单超时、优惠券过期、会员有效期、定时任务窗口判断里非常有用。
商业 Demo:订单支付超时判断
import java.time.LocalDateTime;
public class OrderExpireDemo {
public static boolean isPaymentExpired(LocalDateTime createTime, int timeoutMinutes) {
if (createTime == null) {
throw new IllegalArgumentException("订单创建时间不能为空");
}
LocalDateTime expireTime = createTime.plusMinutes(timeoutMinutes);
return LocalDateTime.now().isAfter(expireTime);
}
public static void main(String[] args) {
LocalDateTime createTime = LocalDateTime.now().minusMinutes(40);
System.out.println(isPaymentExpired(createTime, 30));
}
}真实业务中,超时取消订单通常还会结合定时任务、消息队列或延迟队列处理。
商业 Demo:账单周期计算
账单、会员、订阅类业务经常按自然月计算,不能简单用 30 天代替一个月。
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
public class BillingCycleDemo {
public static void main(String[] args) {
LocalDate anyDay = LocalDate.of(2026, 2, 15);
LocalDate start = anyDay.with(TemporalAdjusters.firstDayOfMonth());
LocalDate end = anyDay.with(TemporalAdjusters.lastDayOfMonth());
System.out.println(start); // 2026-02-01
System.out.println(end); // 2026-02-28
}
}为什么不能简单 start.plusDays(30)?因为每个月天数不同,还有闰年。按自然月、自然季度、自然年统计时,应使用日历规则,而不是固定秒数。
商业 Demo:接口时间参数解析
对外接口常收到字符串时间,必须统一格式和时区。
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
public class ApiTimeParseDemo {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static Instant parseShanghaiTime(String text) {
if (text == null || text.trim().isEmpty()) {
throw new IllegalArgumentException("时间不能为空");
}
LocalDateTime localDateTime = LocalDateTime.parse(text, FORMATTER);
return localDateTime.atZone(ZoneId.of("Asia/Shanghai")).toInstant();
}
public static void main(String[] args) {
System.out.println(parseShanghaiTime("2026-07-01 10:00:00"));
}
}接口文档要写清楚:
- 时间格式是什么。
- 时区按谁算。
- 时间戳单位是秒还是毫秒。
- 是否允许为空。
- 解析失败返回什么错误码。
不写清楚,前后端、第三方系统、定时任务就会各按各的理解处理。
线上排查流程
flowchart TD
A["时间相关问题"] --> B{"表现是什么"}
B -- "时间差 8 小时" --> C["检查 UTC 和 Asia/Shanghai 转换"]
B -- "时间差几十年" --> D["检查秒和毫秒是否混用"]
B -- "偶发格式错乱" --> E["检查 SimpleDateFormat 是否静态共享"]
B -- "账期边界错误" --> F["检查自然月和固定天数是否混用"]
B -- "定时任务重复或漏执行" --> G["检查时区、夏令时、幂等和补偿"]
B -- "数据库时间不一致" --> H["检查 JVM、数据库、服务器时区配置"]排查建议:
- 日志同时打印原始入参、解析后的对象、最终 epoch milliseconds。
- 查清楚问题机器的系统时区、JVM 时区、数据库会话时区。
- 检查接口时间戳单位,10 位通常是秒,13 位通常是毫秒。
- 如果只在高并发下格式错乱,优先排查共享
SimpleDateFormat。 - 定时任务必须有幂等,不能只靠“时间刚好触发一次”。
常见风险
| 问题 | 后果 | 建议 |
|---|---|---|
| 混用服务器本地时区 | 多地区时间错乱 | 明确统一时区 |
用 Date 到处传 | 可读性差且可变 | 新代码优先 java.time |
| 格式化模式写错 | 解析失败或展示错误 | 统一封装格式 |
| 时间比较只比较字符串 | 逻辑错误 | 转成日期时间对象比较 |
| 定时任务忽略夏令时 | 某些地区时间跳变 | 使用带时区类型 |
静态共享 SimpleDateFormat | 高并发下解析/格式化错乱 | 使用 DateTimeFormatter |
| 秒和毫秒混用 | 时间跑到 1970 或未来 | 明确 timestamp 单位 |
| 用 30 天代替一个月 | 账单、会员周期错误 | 使用 Period 或日历调整器 |
到处直接 now() | 测试困难,边界不可控 | 注入 Clock |
面试常问
为什么推荐 java.time?
因为旧的 Date 可变、语义不清,Calendar API 繁琐且月份从 0 开始,SimpleDateFormat 线程不安全。Java 8 的 java.time 类型更明确,大多不可变且线程安全,更适合现代业务系统。
LocalDateTime 和 Instant 区别?LocalDateTime 是不带时区的本地日期时间,不能单独代表全球唯一时间点;Instant 是 UTC 时间线上的一个瞬间,适合存储、比较和日志事件时间。跨时区系统应明确时区或使用 Instant。
SimpleDateFormat 为什么线程不安全?
它内部有可变状态,多线程共享同一个实例时,格式化和解析过程会互相污染,可能出现错乱结果。生产新代码应使用线程安全的 DateTimeFormatter。
Duration 和 Period 区别?Duration 表示精确时间间隔,按秒和纳秒计算,适合 30 分钟、24 小时这种超时;Period 表示日历日期间隔,按年、月、日计算,适合一个月、一年这种自然日历周期。
为什么时间会差 8 小时?
通常是 UTC 和 Asia/Shanghai 没转换清楚。UTC 比北京时间少 8 小时,如果数据库、后端、前端有的按 UTC、有的按本地时区展示,就会出现 8 小时偏差。
定时任务遇到时间问题怎么保证可靠?
要明确时区,避免夏令时地区的时间跳变影响;任务本身要幂等,防止重复执行;要有补偿扫描,防止漏执行;日志里记录计划触发时间、实际触发时间和业务时间窗口。
本章小结
日期时间要重点理解本地时间、时间戳、时区、格式化和时间差。java.time 的设计比旧 API 更清晰。业务系统里要提前制定时区和存储策略,否则后期排查时间问题非常痛苦。
