主题
界面
主题
Java 8 把日期时间 API 彻底重写了。老 API(Date、Calendar、SimpleDateFormat)有三个硬伤:可变、月份从 0 开始、格式化器线程不安全。
本节讲清新的 java.time 包:它为什么更安全、核心类怎么选、日期计算怎么做对。这些内容在写业务代码时天天用到——订单超时、会员到期、报表周期、日志时间戳。
完成本节后,你应该能够:
LocalDate / LocalTime / LocalDateTime / Instant / ZonedDateTime;plus / minus / with 做日期计算,并说清为什么原对象不变;Duration(时间量)与 Period(日期量),用 ChronoUnit 算间隔;DateTimeFormatter 格式化与解析,并说清它为什么线程安全;long 时间戳之间正确转换;SimpleDateFormat 的具体风险。String.format 做格式化输出吗?看老 API 的几个真实痛点:
// 痛点一:月份从 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 从设计上消除了这三类问题。
| 类 | 表示 | 典型场景 |
|---|---|---|
LocalDate | 日期(年月日) | 生日、账单日、会员到期日 |
LocalTime | 时间(时分秒) | 营业时间、打卡时间 |
LocalDateTime | 日期 + 时间(无时区) | 业务时间(推荐默认选择) |
Instant | 时间戳(UTC) | 存储、传输、跨时区计算 |
ZonedDateTime | 带时区的日期时间 | 跨时区展示、定时任务 |
Duration | 时间量(时、分、秒) | 计算"耗时多久" |
Period | 日期量(年、月、日) | 计算"相差几天/几个月" |
为什么业务代码常用 LocalDateTime 而不是 Instant
LocalDateTime 没有时区概念,表示"墙上时钟的时间"——"每天早上 9 点开会"就该用它。
Instant 是"绝对时间点",适合存储与传输。两者之间转换需要一个时区(如 Asia/Shanghai)。
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。
| 方法 | 作用 | 例子 |
|---|---|---|
plusDays / minusMonths | 加减 | today.plusDays(10) |
withDayOfMonth | 替换某个字段 | today.withDayOfMonth(1)(本月第一天) |
with(TemporalAdjusters...) | 复杂调整 | 本月最后一天、下个周一 |
lengthOfMonth() | 本月天数 | 配合 withDayOfMonth 求月末 |
月末计算不需要手写"31 还是 30":
LocalDate lastDay = today.withDayOfMonth(today.lengthOfMonth());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 更适合直接要一个数字:
long days = ChronoUnit.DAYS.between(today, later); // 10DateTimeFormatter 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 常量:
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");格式符大小写敏感
yyyy 是年,MM 是月,dd 是日,HH 是 24 小时制,mm 是分钟,ss 是秒。
写成 yyyy-mm-dd(小写 mm 表示分钟)会得到荒谬的结果——这是非常常见的笔误。
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"。
维护老代码时不可避免要转换:
Date legacy = Date.from(instant); // 新 → 老
Instant back = legacy.toInstant(); // 老 → 新
LocalDateTime dateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());原则:新代码一律用 java.time;遇到老接口时只在边界转换,内部不混用。
LocalDate today = LocalDate.of(2026, 9, 21);
System.out.println(today.plusDays(10)); // 2026-10-01
System.out.println(today); // 2026-09-21LocalDate 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() 会算好,包括闰年二月。
// 事故一:共享 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 天"。
文件:labs/common-classes/src/main/java/cn/kb/learn/common/DateTimeLesson.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));
}
}运行命令:
cd labs
./mvnw -pl common-classes compile
java -cp common-classes/target/classes cn.kb.learn.common.DateTimeLesson预期输出:
日期: 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两个值得注意的点:
2026-09-21 加 10 天正好跨月,得到 2026-10-01——日期计算自动处理月末;toString()(如 2026-09-21T14:30)使用 ISO 格式,这正好是数据库与接口最常用的格式,所以很多场景不需要自定义格式化。today.plusDays(10) 后同时观察 today 与 tenDaysLater 的字段——两个对象都在,值不同(不可变的直接证据);today.withDayOfMonth(today.lengthOfMonth()) 处把 today 改成 LocalDate.of(2024, 2, 1),观察月末返回 29(2024 是闰年);Duration.between 的开始时间改成 17:00、结束改成 9:00(跨天),观察 toMinutes() 返回负数——Duration 不会自动处理跨天,跨天要用 LocalDateTime;DateTimeFormatter 改成 "yyyy-mm-dd"(小写 mm),观察输出变成"分钟"而不是"月"——格式符大小写陷阱的实证。== 比较日期 if (date1 == date2) { } // 比较地址,几乎总是 false
if (date1.equals(date2)) { } // 比较完整值
if (date1.isEqual(date2)) { } // 比较"时间点"(忽略时区表示差异)
if (date1.isBefore(date2)) { } // 比较先后SimpleDateFormat private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd");症状:并发下解析出错、甚至抛 NumberFormatException,且难以复现。
修复:改用 DateTimeFormatter(不可变、线程安全)。
Period.getDays() 当成总天数 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() 只返回"拆解后的剩余天数"。
Duration.between(LocalTime, LocalTime) Duration.between(LocalTime.of(23, 0), LocalTime.of(1, 0)).toMinutes(); // -1320修复:跨天场景用 LocalDateTime 或 Instant。
目标:写一组日期工具方法。
步骤:
boolean isLeapYear(int year):用 Year.isLeap(year) 实现;long daysUntil(LocalDate target):计算从今天(用固定日期代替 now() 便于测试)到目标日期还有几天,已过期返回负数;String formatDuration(Duration duration):把时长格式化为"1 天 2 小时 3 分钟";LocalDate nextWorkday(LocalDate date):返回下一个工作日(跳过周六周日)。验收方式:能解释第 3 步"为什么要用固定日期而不是 LocalDate.now()"(提示:可测试性)。
扩展任务:用 TemporalAdjusters.next(DayOfWeek.MONDAY) 重写第 5 步的一部分,对比两种写法的可读性。
基础题
LocalDate 是可变的。(对 / 错)SimpleDateFormat 不适合作为静态常量?代码阅读题
LocalDate date = LocalDate.of(2026, 1, 31);
System.out.println(date.plusMonths(1));
System.out.println(date.plusDays(1));2026-02-31?编程题
int workdaysBetween(LocalDate start, LocalDate end):统计两个日期之间的工作日天数(不含周末,含首尾)。综合任务
LocalDate expireDate、当前日期 LocalDate today;ChronoUnit 或 Period,并且不要使用 LocalDate.now()(便于写测试)。提示 1(方向):第 4 题第二问的关键是"Java 会做日期规范化"——超过当月末日的日期会被调整到月末。
提示 2(关键点):第 5 题可以用循环逐天判断 getDayOfWeek(),也可以用数学方式算出周数。
提示 3(接近答案):第 6 题的判断顺序很重要——先判是否过期,再依次判断 7 天、30 天,否则逻辑会重叠。
LocalDate 是不可变类,所有修改方法都返回新对象。ChronoUnit.DAYS.between(...)(或 Period)、DateTimeFormatter。SimpleDateFormat 不是线程安全的:它内部用可变字段保存解析状态,多线程并发调用时状态会被互相覆盖,导致结果错乱或抛异常。而 DateTimeFormatter 是不可变的,可以安全共享。2026-02-28
2026-02-01plusMonths(1):1 月 31 日加一个月,目标月(2 月)没有 31 日,Java 会规范化为该月最后一天,即 2 月 28 日(2026 不是闰年);plusDays(1):1 月 31 日加一天,自然进入 2 月 1 日。这类"是否该取月末"的行为在跨月场景(如每月还款日)要特别注意——如果需要"每月最后一天",应该显式用 withDayOfMonth(lengthOfMonth())。
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 天):
9/21 到 9/30 的工作日: 8package 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 可测试性 会展开讲。
预期输出:
已过期 11 天
今天到期
还有 4 天到期
即将到期(19 天)
nullDuration / Period / ChronoUnit 的用途;DateTimeFormatter 格式化与解析,并知道它线程安全;《浮点精度与 BigDecimal》:金额计算的正确姿势,也是 S1 常用类模块的收尾。
暂无