Skip to content

日期时间 API ​

S1-013必学入门约 60 分钟JDK 21编程语言基础 · 常用类

1. 本节定位 ​

Java 8 把日期时间 API 彻底重写了。老 API(Date、Calendar、SimpleDateFormat)有三个硬伤:可变、月份从 0 开始、格式化器线程不安全。

本节讲清新的 java.time 包:它为什么更安全、核心类怎么选、日期计算怎么做对。这些内容在写业务代码时天天用到——订单超时、会员到期、报表周期、日志时间戳。

2. 学习目标 ​

完成本节后,你应该能够:

  • 按用途选择 LocalDate / LocalTime / LocalDateTime / Instant / ZonedDateTime;
  • 用 plus / minus / with 做日期计算,并说清为什么原对象不变;
  • 区分 Duration(时间量)与 Period(日期量),用 ChronoUnit 算间隔;
  • 用 DateTimeFormatter 格式化与解析,并说清它为什么线程安全;
  • 在日期时间与 long 时间戳之间正确转换;
  • 说出老 SimpleDateFormat 的具体风险。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

看老 API 的几个真实痛点:

java
// 痛点一:月份从 0 开始
Calendar calendar = Calendar.getInstance();
calendar.set(2026, 9, 21);        // 9 表示十月!不是九月

// 痛点二:Date 是可变的,传出去就可能被改
Date createTime = order.getCreateTime();
createTime.setTime(0);            // 订单创建时间被改了,而且是"合法"调用

// 痛点三:SimpleDateFormat 不是线程安全的
private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd");
FORMAT.format(date);              // 多线程并发调用会得到错乱结果

第三个问题最危险:它在单线程测试里永远正常,一上生产、一并发就出错。新 API 从设计上消除了这三类问题。

5. 核心概念 ​

5.1 核心类怎么选 ​

类表示典型场景
LocalDate日期(年月日)生日、账单日、会员到期日
LocalTime时间(时分秒)营业时间、打卡时间
LocalDateTime日期 + 时间(无时区)业务时间(推荐默认选择)
Instant时间戳(UTC)存储、传输、跨时区计算
ZonedDateTime带时区的日期时间跨时区展示、定时任务
Duration时间量(时、分、秒)计算"耗时多久"
Period日期量(年、月、日)计算"相差几天/几个月"

为什么业务代码常用 LocalDateTime 而不是 Instant

LocalDateTime 没有时区概念,表示"墙上时钟的时间"——"每天早上 9 点开会"就该用它。

Instant 是"绝对时间点",适合存储与传输。两者之间转换需要一个时区(如 Asia/Shanghai)。

5.2 不可变性:所有修改都返回新对象 ​

java
LocalDate today = LocalDate.of(2026, 9, 21);
LocalDate later = today.plusDays(10);
System.out.println(today);     // 2026-09-21 —— 没变
System.out.println(later);     // 2026-10-01 —— 新对象

这与 String 完全一致(《String 与不可变性》):LocalDate 也是不可变类。所以它可以安全地共享、可以放心地做 Map 的 key。

5.3 日期计算:plus / minus / with ​

方法作用例子
plusDays / minusMonths加减today.plusDays(10)
withDayOfMonth替换某个字段today.withDayOfMonth(1)(本月第一天)
with(TemporalAdjusters...)复杂调整本月最后一天、下个周一
lengthOfMonth()本月天数配合 withDayOfMonth 求月末

月末计算不需要手写"31 还是 30":

java
LocalDate lastDay = today.withDayOfMonth(today.lengthOfMonth());

5.4 间隔计算:Duration vs Period ​

java
LocalTime start = LocalTime.of(9, 0);
LocalTime end = LocalTime.of(17, 30);
Duration duration = Duration.between(start, end);     // 基于时间轴:8 小时 30 分
duration.toMinutes();                                  // 510

Period period = Period.between(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 9, 21));
period.toTotalMonths();                                // 8(按日历算)

选择标准:

  • 算"过了多少秒/分钟/小时" → Duration 或 ChronoUnit.SECONDS.between(...);
  • 算"相差几天/几个月/几年" → Period 或 ChronoUnit.DAYS.between(...)。

ChronoUnit 更适合直接要一个数字:

java
long days = ChronoUnit.DAYS.between(today, later);      // 10

5.5 格式化与解析 ​

java
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");

String text = LocalDateTime.now().format(formatter);           // 格式化
LocalDateTime parsed = LocalDateTime.parse("2026-09-21 14:30", formatter);   // 解析

DateTimeFormatter 是不可变的,所以线程安全——可以放心声明为 static final 常量:

java
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

格式符大小写敏感

yyyy 是年,MM 是月,dd 是日,HH 是 24 小时制,mm 是分钟,ss 是秒。

写成 yyyy-mm-dd(小写 mm 表示分钟)会得到荒谬的结果——这是非常常见的笔误。

5.6 时区与时间戳 ​

java
ZoneId shanghai = ZoneId.of("Asia/Shanghai");

// 日期 → 时间戳
long epochSecond = LocalDate.of(2026, 9, 21).atStartOfDay(shanghai).toInstant().getEpochSecond();

// 时间戳 → 日期
LocalDate date = LocalDate.ofInstant(Instant.ofEpochSecond(epochSecond), shanghai);

时区是转换的必要参数:同一个 long 时间戳,在东八区是"9 月 21 日 00:00",在 UTC 是"9 月 20 日 16:00"。

5.7 与老 API 互转 ​

维护老代码时不可避免要转换:

java
Date legacy = Date.from(instant);                          // 新 → 老
Instant back = legacy.toInstant();                         // 老 → 新
LocalDateTime dateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());

原则:新代码一律用 java.time;遇到老接口时只在边界转换,内部不混用。

6. 递进示例 ​

6.1 最小示例:不可变与计算 ​

java
LocalDate today = LocalDate.of(2026, 9, 21);
System.out.println(today.plusDays(10));     // 2026-10-01
System.out.println(today);                  // 2026-09-21

6.2 业务示例:账单周期 ​

java
LocalDate billDay = LocalDate.of(2026, 9, 21);
LocalDate periodStart = billDay.withDayOfMonth(1);                    // 本月第一天
LocalDate periodEnd = billDay.withDayOfMonth(billDay.lengthOfMonth()); // 本月最后一天
System.out.println(periodStart + " ~ " + periodEnd);                  // 2026-09-01 ~ 2026-09-30

不需要判断"这个月是 30 还是 31 天"——lengthOfMonth() 会算好,包括闰年二月。

6.3 反例:三个真实事故 ​

java
// 事故一:共享 SimpleDateFormat
private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd");
// 并发调用会解析出错误日期,且不抛异常
// 修复:改用 DateTimeFormatter

// 事故二:用 == 比较日期
if (date1 == date2) { }             // 比较的是地址
// 修复:用 equals(或 isEqual 比较"同一天")

// 事故三:用 Period 算精确时长
Period.between(LocalDate.now(), deadline).getDays()   // 只返回"天数部分",不含月
// 修复:要总天数用 ChronoUnit.DAYS.between(...)

第三个坑最隐蔽:Period.between(1 月 1 日, 3 月 5 日).getDays() 返回的是 5(不是 64),因为它把差拆成了"2 个月 + 5 天"。

7. 完整可运行代码 ​

文件:labs/common-classes/src/main/java/cn/kb/learn/common/DateTimeLesson.java

java
package cn.kb.learn.common;

import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Period;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;

public class DateTimeLesson {
    public static void main(String[] args) {
        // 1. 构造日期
        LocalDate today = LocalDate.of(2026, 9, 21);
        System.out.println("日期: " + today);
        System.out.println("星期: " + today.getDayOfWeek());
        System.out.println("年份: " + today.getYear() + ",是否闰年: " + today.isLeapYear());

        // 2. 不可变:所有"修改"都返回新对象
        LocalDate tenDaysLater = today.plusDays(10);
        System.out.println("10 天后: " + tenDaysLater);
        System.out.println("原对象未变: " + today);

        // 3. 计算间隔
        System.out.println("相差天数: " + ChronoUnit.DAYS.between(today, tenDaysLater));
        System.out.println("相差月数: " + Period.between(today, today.plusMonths(3)).toTotalMonths());

        // 4. 日期时间与格式化
        LocalDateTime dateTime = today.atTime(14, 30);
        System.out.println("日期时间: " + dateTime);

        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
        System.out.println("格式化: " + dateTime.format(formatter));
        System.out.println("解析: " + LocalDateTime.parse("2026-09-21 14:30", formatter));

        // 5. Duration 计算时间差
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of(17, 30);
        Duration duration = Duration.between(start, end);
        System.out.println("工作时长(分钟): " + duration.toMinutes());
        System.out.println("工作时长: " + duration.toHours() + " 小时 " + (duration.toMinutes() % 60) + " 分钟");

        // 6. 与时间戳互转
        ZoneId shanghai = ZoneId.of("Asia/Shanghai");
        long epochSecond = today.atStartOfDay(shanghai).toInstant().getEpochSecond();
        System.out.println("2026-09-21 零点的 Unix 时间戳(秒): " + epochSecond);
        System.out.println("从时间戳还原日期: " + LocalDate.ofInstant(java.time.Instant.ofEpochSecond(epochSecond), shanghai));

        // 7. 常用的边界计算
        System.out.println("本月第一天: " + today.withDayOfMonth(1));
        System.out.println("本月最后一天: " + today.withDayOfMonth(today.lengthOfMonth()));
        System.out.println("下个月今天: " + today.plusMonths(1));
        System.out.println("是否早于 10 天后: " + today.isBefore(tenDaysLater));
    }
}

运行命令:

powershell
cd labs
./mvnw -pl common-classes compile
java -cp common-classes/target/classes cn.kb.learn.common.DateTimeLesson

预期输出:

text
日期: 2026-09-21
星期: MONDAY
年份: 2026,是否闰年: false
10 天后: 2026-10-01
原对象未变: 2026-09-21
相差天数: 10
相差月数: 3
日期时间: 2026-09-21T14:30
格式化: 2026-09-21 14:30
解析: 2026-09-21T14:30
工作时长(分钟): 510
工作时长: 8 小时 30 分钟
2026-09-21 零点的 Unix 时间戳(秒): 1789920000
从时间戳还原日期: 2026-09-21
本月第一天: 2026-09-01
本月最后一天: 2026-09-30
下个月今天: 2026-10-21
是否早于 10 天后: true

两个值得注意的点:

  1. 2026-09-21 加 10 天正好跨月,得到 2026-10-01——日期计算自动处理月末;
  2. 默认的 toString()(如 2026-09-21T14:30)使用 ISO 格式,这正好是数据库与接口最常用的格式,所以很多场景不需要自定义格式化。

8. Debug 观察点 ​

  1. 在 today.plusDays(10) 后同时观察 today 与 tenDaysLater 的字段——两个对象都在,值不同(不可变的直接证据);
  2. 在 today.withDayOfMonth(today.lengthOfMonth()) 处把 today 改成 LocalDate.of(2024, 2, 1),观察月末返回 29(2024 是闰年);
  3. 把 Duration.between 的开始时间改成 17:00、结束改成 9:00(跨天),观察 toMinutes() 返回负数——Duration 不会自动处理跨天,跨天要用 LocalDateTime;
  4. 把 DateTimeFormatter 改成 "yyyy-mm-dd"(小写 mm),观察输出变成"分钟"而不是"月"——格式符大小写陷阱的实证。

9. 常见错误 ​

错误 1:用 == 比较日期 ​

java
if (date1 == date2) { }                  // 比较地址,几乎总是 false
if (date1.equals(date2)) { }             // 比较完整值
if (date1.isEqual(date2)) { }            // 比较"时间点"(忽略时区表示差异)
if (date1.isBefore(date2)) { }           // 比较先后

错误 2:共享 SimpleDateFormat ​

java
private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd");

症状:并发下解析出错、甚至抛 NumberFormatException,且难以复现。

修复:改用 DateTimeFormatter(不可变、线程安全)。

错误 3:把 Period.getDays() 当成总天数 ​

java
Period.between(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 5)).getDays();   // 5,不是 64
ChronoUnit.DAYS.between(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 5));    // 64

记忆:要"总天数/总月数"用 ChronoUnit 或 Period.toTotalMonths();getDays() 只返回"拆解后的剩余天数"。

错误 4:跨天用 Duration.between(LocalTime, LocalTime) ​

java
Duration.between(LocalTime.of(23, 0), LocalTime.of(1, 0)).toMinutes();   // -1320

修复:跨天场景用 LocalDateTime 或 Instant。

10. 动手实验 ​

目标:写一组日期工具方法。

步骤:

  1. 运行第 7 节代码,核对输出;
  2. 写方法 boolean isLeapYear(int year):用 Year.isLeap(year) 实现;
  3. 写方法 long daysUntil(LocalDate target):计算从今天(用固定日期代替 now() 便于测试)到目标日期还有几天,已过期返回负数;
  4. 写方法 String formatDuration(Duration duration):把时长格式化为"1 天 2 小时 3 分钟";
  5. 写方法 LocalDate nextWorkday(LocalDate date):返回下一个工作日(跳过周六周日)。

验收方式:能解释第 3 步"为什么要用固定日期而不是 LocalDate.now()"(提示:可测试性)。

扩展任务:用 TemporalAdjusters.next(DayOfWeek.MONDAY) 重写第 5 步的一部分,对比两种写法的可读性。

11. 练习题 ​

基础题

  1. 判断:LocalDate 是可变的。(对 / 错)
  2. 填空:算"相差多少天"用 ______;格式化日期用 ______。
  3. 简答:为什么 SimpleDateFormat 不适合作为静态常量?

代码阅读题

java
LocalDate date = LocalDate.of(2026, 1, 31);
System.out.println(date.plusMonths(1));
System.out.println(date.plusDays(1));
  1. 两行分别输出什么?第二行为什么不是 2026-02-31?

编程题

  1. 写方法 int workdaysBetween(LocalDate start, LocalDate end):统计两个日期之间的工作日天数(不含周末,含首尾)。

综合任务

  1. 设计一个"会员到期提醒"逻辑:
    • 入参:会员到期日 LocalDate expireDate、当前日期 LocalDate today;
    • 返回:提醒文案。规则:已过期 → "已过期 N 天";7 天内到期 → "还有 N 天到期";30 天内 → "即将到期(N 天)";更远 → 不提醒(返回 null);
    • 要求:所有日期计算用 ChronoUnit 或 Period,并且不要使用 LocalDate.now()(便于写测试)。
分级提示

提示 1(方向):第 4 题第二问的关键是"Java 会做日期规范化"——超过当月末日的日期会被调整到月末。

提示 2(关键点):第 5 题可以用循环逐天判断 getDayOfWeek(),也可以用数学方式算出周数。

提示 3(接近答案):第 6 题的判断顺序很重要——先判是否过期,再依次判断 7 天、30 天,否则逻辑会重叠。

12. 参考答案 ​

基础题
  1. 错。LocalDate 是不可变类,所有修改方法都返回新对象。
  2. ChronoUnit.DAYS.between(...)(或 Period)、DateTimeFormatter。
  3. 因为 SimpleDateFormat 不是线程安全的:它内部用可变字段保存解析状态,多线程并发调用时状态会被互相覆盖,导致结果错乱或抛异常。而 DateTimeFormatter 是不可变的,可以安全共享。
代码阅读题(第 4 题)
text
2026-02-28
2026-02-01
  • plusMonths(1):1 月 31 日加一个月,目标月(2 月)没有 31 日,Java 会规范化为该月最后一天,即 2 月 28 日(2026 不是闰年);
  • plusDays(1):1 月 31 日加一天,自然进入 2 月 1 日。

这类"是否该取月末"的行为在跨月场景(如每月还款日)要特别注意——如果需要"每月最后一天",应该显式用 withDayOfMonth(lengthOfMonth())。

编程题(第 5 题)
java
package cn.kb.learn.common;

import java.time.DayOfWeek;
import java.time.LocalDate;

public class WorkdayCounter {
    static int workdaysBetween(LocalDate start, LocalDate end) {
        if (start.isAfter(end)) {
            throw new IllegalArgumentException("开始日期不能晚于结束日期");
        }
        int count = 0;
        for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) {
            DayOfWeek day = date.getDayOfWeek();
            if (day != DayOfWeek.SATURDAY && day != DayOfWeek.SUNDAY) {
                count++;
            }
        }
        return count;
    }

    public static void main(String[] args) {
        LocalDate start = LocalDate.of(2026, 9, 21);
        System.out.println("9/21 到 9/30 的工作日: " + workdaysBetween(start, LocalDate.of(2026, 9, 30)));
    }
}

预期输出(9/21 是周一,到 9/30 共 10 天,其中周末 2 天):

text
9/21 到 9/30 的工作日: 8
综合任务(第 6 题)
java
package cn.kb.learn.common;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

public class ExpiryReminder {
    static String remind(LocalDate expireDate, LocalDate today) {
        long days = ChronoUnit.DAYS.between(today, expireDate);

        if (days < 0) {
            return "已过期 " + (-days) + " 天";
        }
        if (days == 0) {
            return "今天到期";
        }
        if (days <= 7) {
            return "还有 " + days + " 天到期";
        }
        if (days <= 30) {
            return "即将到期(" + days + " 天)";
        }
        return null;
    }

    public static void main(String[] args) {
        LocalDate today = LocalDate.of(2026, 9, 21);
        System.out.println(remind(LocalDate.of(2026, 9, 10), today));
        System.out.println(remind(LocalDate.of(2026, 9, 21), today));
        System.out.println(remind(LocalDate.of(2026, 9, 25), today));
        System.out.println(remind(LocalDate.of(2026, 10, 10), today));
        System.out.println(remind(LocalDate.of(2026, 12, 31), today));
    }
}

为什么要传入 today 而不是用 LocalDate.now():now() 让方法的结果依赖系统时间,无法写出稳定的测试(今天跑通过、明天跑失败)。把"当前时间"作为参数传入,方法就变成了纯函数——这是可测试性设计里最基础的一招,T1-006 可测试性 会展开讲。

预期输出:

text
已过期 11 天
今天到期
还有 4 天到期
即将到期(19 天)
null

13. 掌握标准 ​

  • [ ] 能按场景选对日期时间类型;
  • [ ] 会做日期加减与边界计算(月初、月末);
  • [ ] 能区分 Duration / Period / ChronoUnit 的用途;
  • [ ] 会用 DateTimeFormatter 格式化与解析,并知道它线程安全;
  • [ ] 能在时间戳与日期时间之间正确转换(带时区);
  • [ ] 知道"把当前时间作为参数传入"这一可测试性技巧。

14. 扩展资源 ​

15. 下一步学习 ​

《浮点精度与 BigDecimal》:金额计算的正确姿势,也是 S1 常用类模块的收尾。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引