《Java 编程入门》15.1 java.time:日期、时刻与时区

把 java.time 拆成日期、时刻、时区三层,用 library 的借期 14 天、续借、逾期罚金串起 LocalDate、Instant、ZonedDateTime、Duration、Period 与 DateTimeFormatter,并解释夏令时缺口、重叠时刻和 YYYY 与 yyyy 的真实差别。

本节目标:能根据业务语义选择 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 与文本块 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发