本节目标:能根据业务语义选择
LocalDate、Instant或ZonedDateTime,算对借期和逾期天数,用DateTimeFormatter稳定地格式化和解析,并知道夏令时会让「一天」不等于 24 小时。
适用版本:JDK 25(对照 JDK 21);java.time从 JDK 8 起提供,API 在这两个版本间没有影响本节的差异。
上一节留下的问题
library 的借阅规则里有一个绕不开的量:时间。借期 14 天,从借出日算起;到期没还开始计罚金;续借一次再延长 14 天。规则本身不复杂,但一旦涉及「今天」「到期」「逾期几天」「服务器在哪个时区」,实现方式就会直接影响正确性。
老 API 是 Date、Calendar、SimpleDateFormat。它们可变、线程不安全、月份从 0 开始、时区处理隐晦。java.time 用一组不可变类型替代了它们。这一节不罗列所有类,而是按「你要表达什么」来选类型。
三类时间,三种语义
| 类型 | 表达什么 | 典型用途 | 是否带时区 |
|---|---|---|---|
LocalDate | 日历上的日期 | 借出日、到期日、生日 | 否 |
LocalTime | 一天中的时间 | 开馆时间、截止时刻 | 否 |
LocalDateTime | 日期 + 时间,但没有时区 | 本地排班、日志中的本地时间 | 否 |
Instant | 时间轴上的一个点 | 创建时间、事件时间、跨系统传输 | 本质是 UTC |
ZonedDateTime | 日期时间 + 时区规则 | 面向用户展示、跨时区换算 | 是 |
OffsetDateTime | 日期时间 + 固定偏移 | 协议、数据库中的带偏移时间 | 是(固定偏移) |
Duration | 两个时刻之间的时长 | 超时、耗时、缓存 TTL | 不涉及日历 |
Period | 两个日期之间的日/月/年 | 借期 14 天、会员有效期 | 不涉及时刻 |
选择的判断很简单:
- 只关心日历日,用
LocalDate。借期是「10 月 14 日借,10 月 28 日到期」,不需要知道几点。 - 要记录事件发生的绝对时刻,用
Instant。日志、消息、数据库的created_at都应该是它。 - 要按某个地区展示,用
ZonedDateTime。同一个Instant在东京和纽约显示的小时不同。 - 不要用
LocalDateTime表示绝对时刻。 它没有时区,无法唯一对应时间轴上的一个点。
library 的借阅记录里,borrowedOn 和 dueOn 是 LocalDate,因为规则按天算;如果以后要记录「借出操作发生在几点」,那应该另加一个 Instant borrowedAt,而不是把 LocalDate 换成 LocalDateTime。
借期计算:LocalDate 与 ChronoUnit
LocalDate borrowed = LocalDate.of(2026, 10, 14);
LocalDate due = borrowed.plusDays(14);
System.out.println("borrowed = " + borrowed);
System.out.println("due = " + due);
System.out.println("days = " + ChronoUnit.DAYS.between(borrowed, due));
System.out.println("period = " + Period.between(borrowed, due));
本机 JDK 25 输出:
borrowed = 2026-10-14
due = 2026-10-28
days = 14
period = P14D
plusDays(14) 会正确处理月末、闰年和月份长度,不需要自己加 86400 秒。ChronoUnit.DAYS.between 返回两个日期之间的天数,方向是「第二个减第一个」,所以要算逾期天数时写成 between(dueOn, returnedOn),结果为正表示逾期。
Period 表达的是「14 天」这个日历量,Duration 表达的是「14 × 24 小时」这个精确时长。在只有日期的场景里两者结果一样,但一旦跨夏令时就不一样:某些时区的一天是 23 或 25 小时。
library 的规则直接写成常量:
public static final int LOAN_DAYS = 14;
public static final int MAX_RENEWALS = 1;
public static final long FINE_PER_DAY_CENTS = 50;
public LocalDate dueDate(LocalDate borrowedOn) {
return borrowedOn.plusDays(LOAN_DAYS);
}
罚金按「逾期天数 × 50 分」计算,不按小时。这样用户理解成本低,也不会因为归还时刻是 23:59 还是 00:01 产生争议。
时刻与时区:Instant 与 ZonedDateTime
把上海时间换算成 Instant:
ZonedDateTime shanghai = LocalDateTime.of(2026, 10, 14, 9, 0)
.atZone(ZoneId.of("Asia/Shanghai"));
Instant instant = shanghai.toInstant();
System.out.println("shanghai = " + shanghai);
System.out.println("instant = " + instant);
System.out.println("utc = " + instant.atZone(ZoneId.of("UTC")));
本机输出:
shanghai = 2026-10-14T09:00+08:00[Asia/Shanghai]
instant = 2026-10-14T01:00:00Z
utc = 2026-10-14T01:00Z[UTC]
同一时刻,上海显示 09:00,UTC 显示 01:00。Instant 本身没有「几点」的概念,它只是时间轴上的一个点;ZonedDateTime 才有「在某个地区看起来是几点」。
夏令时的两个陷阱
不是所有地区都有夏令时,但代码不能假设「一天总是 24 小时」。以美国纽约为例,2026 年 3 月 8 日凌晨 2 点跳到 3 点:
ZoneId newYork = ZoneId.of("America/New_York");
LocalDateTime gap = LocalDateTime.of(2026, 3, 8, 2, 30);
System.out.println("gap local = " + gap);
System.out.println("gap zoned = " + gap.atZone(newYork));
本机输出:
gap local = 2026-03-08T02:30
gap zoned = 2026-03-08T03:30-04:00[America/New_York]
02:30 这个本地时间在当天不存在。atZone 没有抛异常,而是把它向后调整到 03:30。如果你的业务是「每天凌晨 2:30 跑批」,这一天就会在 3:30 跑,或者被跳过——取决于你怎么处理。
秋天反过来,凌晨 1 点到 2 点会出现两次:
overlap早 = 2026-11-01T01:30-04:00[America/New_York]
overlap晚 = 2026-11-01T01:30-05:00[America/New_York]
同一个 LocalDateTime 对应两个 Instant。默认 atZone 选较早的偏移;要选较晚的,用 withLaterOffsetAtOverlap()。这类问题在只处理日期的 LocalDate 里不存在,所以借期规则尽量用日期,不要引入时刻。
存什么,显示什么
一条实用规则:
- 存储和传输用
Instant(或 ISO-8601 带偏移的字符串)。它不受服务器时区影响。 - 展示时再转
ZonedDateTime,用用户所在时区。 - 业务规则按日历日算的,用
LocalDate,并在文档里写清楚「哪个时区的日历日」。
library 现在只在本地 CLI 里跑,借期用 LocalDate 足够了。到了卷二做 HTTP 服务,客户端可能在不同时区,届时要明确「到期日按图书馆所在时区的日历日计算」,而不是按客户端本地时间。
格式化与解析
DateTimeFormatter 是不可变、线程安全的,可以存成 static final 常量复用:
DateTimeFormatter iso = DateTimeFormatter.ISO_LOCAL_DATE;
DateTimeFormatter cn = DateTimeFormatter.ofPattern("yyyy年MM月dd日");
System.out.println("iso = " + due.format(iso));
System.out.println("cn = " + due.format(cn));
System.out.println("parsed = " + LocalDate.parse("2026-10-28", iso));
本机输出:
iso = 2026-10-28
cn = 2026年10月28日
parsed = 2026-10-28
模式字母里最容易错的是 yyyy 和 YYYY:
yyyy是日历年(calendar year),日常说的年份。YYYY是「按周算的年份」(week-based year),12 月最后几天可能属于下一年的第 1 周。
用 YYYY-MM-dd 格式化 2026-12-31,可能得到 2027-12-31。日报、账单、合同日期一律用 yyyy。同理,MM 是月,mm 是分;dd 是日,DD 是一年中的第几天。
解析用户输入时,除了格式,还要处理宽松/严格模式。DateTimeFormatter.ofPattern 默认是 SMART,能容忍一些不合理值;需要严格校验时用 DateTimeFormatterBuilder 的 parseStrict(),或者解析后反格式化一遍比对。
在 library 里落地
LoanPolicy 把「今天」从方法内部挪到 Clock 里。生产代码用系统时钟,测试用固定时钟:
package com.plume.library.service;
import com.plume.library.domain.ErrorCode;
import com.plume.library.domain.LibraryException;
import com.plume.library.domain.Loan;
import com.plume.library.domain.LoanStatus;
import java.time.Clock;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
public final class LoanPolicy {
public static final int LOAN_DAYS = 14;
public static final int MAX_RENEWALS = 1;
public static final long FINE_PER_DAY_CENTS = 50;
private final Clock clock;
public LoanPolicy(Clock clock) {
this.clock = Objects.requireNonNull(clock, "clock");
}
public static LoanPolicy systemDefault() {
return new LoanPolicy(Clock.systemDefaultZone());
}
public LocalDate today() {
return LocalDate.now(clock);
}
public LocalDate dueDate(LocalDate borrowedOn) {
return borrowedOn.plusDays(LOAN_DAYS);
}
public boolean canRenew(Loan loan, LocalDate today) {
return loan.status() == LoanStatus.ACTIVE
&& loan.renewals() < MAX_RENEWALS
&& !today.isAfter(loan.dueOn());
}
public Loan renew(Loan loan, LocalDate today) {
if (loan.status() != LoanStatus.ACTIVE) {
throw new LibraryException(ErrorCode.LOAN_NOT_FOUND,
"借阅已结束,不能续借: " + loan.id());
}
if (loan.renewals() >= MAX_RENEWALS) {
throw new LibraryException(ErrorCode.LOAN_LIMIT_EXCEEDED,
"最多续借 " + MAX_RENEWALS + " 次: " + loan.id());
}
if (today.isAfter(loan.dueOn())) {
throw new LibraryException(ErrorCode.LOAN_LIMIT_EXCEEDED,
"已逾期,不能续借: " + loan.id());
}
return new Loan(
loan.id(), loan.isbn(), loan.memberId(),
loan.borrowedOn(), loan.dueOn().plusDays(LOAN_DAYS),
loan.renewals() + 1, LoanStatus.ACTIVE);
}
public long lateDays(Loan loan, LocalDate returnedOn) {
return Math.max(0, ChronoUnit.DAYS.between(loan.dueOn(), returnedOn));
}
public long fineCents(Loan loan, LocalDate returnedOn) {
return lateDays(loan, returnedOn) * FINE_PER_DAY_CENTS;
}
public String formatFine(long cents) {
return "¥%d.%02d".formatted(cents / 100, cents % 100);
}
}
用固定时钟跑一遍:
Clock fixed = Clock.fixed(Instant.parse("2026-10-20T01:00:00Z"),
ZoneId.of("Asia/Shanghai"));
LoanPolicy policy = new LoanPolicy(fixed);
System.out.println("today = " + policy.today());
System.out.println("dueDate = " + policy.dueDate(policy.today()));
本机输出:
today = 2026-10-20
dueDate = 2026-11-03
续借和罚金的实际结果:
canRenew = true
newDue = 2026-11-11
lateDays = 2
fine = ¥1.00
canRenew 在 2026-10-27 判断为 true,因为还没过 10-28 的到期日;续借后到期日从 10-28 变成 11-11;11-13 归还,逾期 2 天,罚金 1 元。这些数字都可以在测试里用固定日期复现,不依赖运行测试的那一天。
常见错误与边界
用 LocalDateTime 存「事件发生时间」。 它没有时区,跨时区传输后无法还原。存 Instant,展示时再转。
用 Duration.ofDays(1) 做日历加一天。 在夏令时切换日,一天可能是 23 或 25 小时。日历语义用 plusDays(1),精确时长才用 Duration。
YYYY 和 yyyy 混用。 跨年那一周会得到错误年份。日期格式统一用 yyyy。
SimpleDateFormat 当成员变量共享。 它不是线程安全的,多线程下会解析出错误日期甚至抛异常。新代码用 DateTimeFormatter。
依赖服务器默认时区。 LocalDate.now()、ZonedDateTime.now() 都用 ZoneId.systemDefault()。容器里没挂时区时,它可能是 UTC,和用户以为的北京时间差 8 小时。入口处显式指定时区,或者用 Clock 注入。
罚金按小时或按分钟算。 规则说「按天」,就不要引入时刻。按小时算会让 23:59 和 00:01 产生不同结果,用户很难理解。
Period 和 Duration 互换。 Period 的 getDays() 可能不是总天数(跨月时 P1M1D 的 getDays() 是 1)。要总天数用 ChronoUnit.DAYS.between。
小结
- 只关心日历日用
LocalDate;绝对时刻用Instant;面向用户展示用ZonedDateTime。 - 借期用
plusDays(14),逾期用ChronoUnit.DAYS.between(dueOn, returnedOn),方向别写反。 - 夏令时会让本地时间出现缺口和重叠,
atZone会自动调整,业务规则尽量只依赖日期。 - 存储用
Instant或带偏移的 ISO 字符串,展示时再按用户时区转换。 DateTimeFormatter线程安全,日期格式用yyyy而不是YYYY。library的LoanPolicy通过Clock获取「今天」,生产用系统时钟,测试用固定时钟。- 罚金按天计,规则里不引入时刻,避免边界争议。
时间算对了,接下来处理另一类文本问题:借阅凭证和报表怎么拼、什么时候该用 StringBuilder、文本块能省掉哪些转义。下一节讲 String、StringBuilder 与文本块。
阅读导航:上一节:14.3 目录遍历与 WatchService · 下一节:15.2 String、StringBuilder 与文本块 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。