日期时间API 详解
本章定位:全面掌握Java 8日期时间API体系。飞翔科技的黄俪负责考勤系统重构,面对项目中同时存在的java.util.Date旧代码和java.time新需求,她必须精通两套API及其互操作。本章从Date/Calendar基础出发,深入到JDK 8全新的java.time包,覆盖LocalDate、LocalTime、LocalDateTime、Instant、Duration、Period、DateTimeFormatter等核心类,帮助读者在真实项目中游刃有余地处理日期时间。
定义与语法
| 属性 | 说明 |
|---|---|
java.util.Date | JDK 1.0引入的传统日期时间类,表示时间戳(毫秒数),大多数方法已废弃 |
java.util.Calendar | JDK 1.1引入的抽象日历类,用于日期运算,设计有缺陷(月份从0开始) |
java.text.SimpleDateFormat | 传统日期格式化类,线程不安全,多线程环境需注意 |
java.time.LocalDate | JDK 8新增,表示不含时间的日期(年-月-日),不可变、线程安全 |
java.time.LocalTime | JDK 8新增,表示不含日期的时间(时-分-秒-纳秒),不可变 |
java.time.LocalDateTime | JDK 8新增,表示日期+时间(不含时区),最常用的本地日期时间类 |
java.time.Instant | JDK 8新增,表示UTC时间线上的一个瞬时点,精确到纳秒 |
java.time.Duration | JDK 8新增,表示基于时间的量(秒+纳秒),如"36.5秒" |
java.time.Period | JDK 8新增,表示基于日期的量(年+月+日),如"2年3个月4天" |
java.time.format.DateTimeFormatter | JDK 8新增,线程安全的日期时间格式化器 |
java.time.ZoneId | JDK 8新增,时区标识符,如"Asia/Shanghai" |
java.time.temporal.ChronoUnit | JDK 8新增,日期时间单位枚举,如DAYS、HOURS、MINUTES |
| ISO-8601 | 国际日期时间标准格式,java.time遵循此标准 |
设计哲学对比:旧API(Date/Calendar)是可变、非线程安全、设计混乱的;新API(java.time)是不可变、线程安全、职责清晰、符合ISO标准的。JDK 8项目中两者并存,掌握互操作至关重要。
完整代码示例
示例一:黄俪使用Date/Calendar处理飞翔科技传统考勤数据
飞翔科技的老考勤系统仍在使用java.util.Date存储打卡记录。黄俪需要解析这些历史数据,计算员工的工作天数。
/** 飞翔科技 - 使用传统Date/Calendar API处理历史考勤数据 */
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
public class LegacyAttendanceDemo {
public static void main(String[] args) throws Exception {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 模拟从数据库中读取的员工打卡记录
Date clockIn10001 = sdf.parse("2024-03-18 08:55:30"); // 工号10001 赵鸣
Date clockOut10001 = sdf.parse("2024-03-18 18:05:12");
Date clockIn10002 = sdf.parse("2024-03-18 09:12:45"); // 工号10002 孙鹤
Date clockOut10002 = sdf.parse("2024-03-18 17:48:30");
System.out.println("========== 飞翔科技考勤记录(旧版Date) ==========");
System.out.println("工号10001 赵鸣 签到:" + sdf.format(clockIn10001));
System.out.println(" 签退:" + sdf.format(clockOut10001));
System.out.println("工号10002 孙鹤 签到:" + sdf.format(clockIn10002));
System.out.println(" 签退:" + sdf.format(clockOut10002));
// 计算工作时长(毫秒差 → 小时)
long workMs10001 = clockOut10001.getTime() - clockIn10001.getTime();
double workHours10001 = workMs10001 / (1000.0 * 60 * 60);
System.out.printf("\n工号10001 赵鸣 工作时长:%.2f 小时\n", workHours10001);
// 使用Calendar进行日期运算:计算本月剩余工作日
Calendar cal = Calendar.getInstance();
cal.setTime(new Date());
int today = cal.get(Calendar.DAY_OF_MONTH);
int maxDay = cal.getActualMaximum(Calendar.DAY_OF_MONTH);
System.out.println("本月已过天数:" + today + " 天,本月共 " + maxDay + " 天");
System.out.println("本月剩余天数:" + (maxDay - today) + " 天");
// Calendar的陷阱:月份从0开始!
cal.set(2024, 2, 18); // 注意:2代表3月!
System.out.println("\n【注意】Calendar月份从0开始:");
System.out.println(" cal.set(2024, 2, 18) → 实际日期:" + sdf.format(cal.getTime()));
}
}
========== 飞翔科技考勤记录(旧版Date) ==========
工号10001 赵鸣 签到:2024-03-18 08:55:30
签退:2024-03-18 18:05:12
工号10002 孙鹤 签到:2024-03-18 09:12:45
签退:2024-03-18 17:48:30
工号10001 赵鸣 工作时长:9.16 小时
本月已过天数:18 天,本月共 31 天
本月剩余天数:13 天
【注意】Calendar月份从0开始:
cal.set(2024, 2, 18) → 实际日期:2024-03-18 00:00:00
示例二:大翔用java.time实现飞翔科技项目截止日期倒计时
飞翔科技的核心项目"云鹤平台"定于2025年6月30日交付。大翔需要精确计算剩余天数,并为每个里程碑生成倒计时。
/** 飞翔科技 - 使用JDK 8 java.time实现项目截止日期倒计时 */
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Period;
import java.time.Duration;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
public class ProjectDeadlineDemo {
public static void main(String[] args) {
// 项目交付日期
LocalDate deadline = LocalDate.of(2025, 6, 30);
LocalDate today = LocalDate.now();
System.out.println("========== 飞翔科技「云鹤平台」项目倒计时 ==========");
System.out.println("项目交付日期:" + deadline);
System.out.println("当前日期 :" + today);
// 使用Period计算日期差
Period period = Period.between(today, deadline);
System.out.println("\n距交付还剩:"
+ period.getYears() + " 年 "
+ period.getMonths() + " 月 "
+ period.getDays() + " 天");
// 使用ChronoUnit精确计算总天数
long totalDays = ChronoUnit.DAYS.between(today, deadline);
long totalWeeks = ChronoUnit.WEEKS.between(today, deadline);
System.out.println("精确剩余:" + totalDays + " 天(约 " + totalWeeks + " 周)");
// 里程碑时间计算
LocalDate milestoneCodeFreeze = deadline.minusWeeks(4); // 代码冻结
LocalDate milestoneTestStart = deadline.minusMonths(2); // 测试启动
LocalDate milestoneDevStart = LocalDate.of(2024, 3, 1); // 开发启动
System.out.println("\n--- 里程碑节点 ---");
System.out.println("开发启动:" + milestoneDevStart + " ("
+ ChronoUnit.DAYS.between(milestoneDevStart, deadline) + "天总周期)");
System.out.println("测试启动:" + milestoneTestStart);
System.out.println("代码冻结:" + milestoneCodeFreeze);
System.out.println("正式交付:" + deadline);
// 使用DateTimeFormatter格式化输出
DateTimeFormatter cnFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日");
System.out.println("\n--- 格式化的里程碑 ---");
System.out.println("开发启动:" + milestoneDevStart.format(cnFormatter));
System.out.println("测试启动:" + milestoneTestStart.format(cnFormatter));
System.out.println("代码冻结:" + milestoneCodeFreeze.format(cnFormatter));
System.out.println("正式交付:" + deadline.format(cnFormatter));
}
}
========== 飞翔科技「云鹤平台」项目倒计时 ==========
项目交付日期:2025-06-30
当前日期 :2024-12-20
距交付还剩:0 年 6 月 10 天
精确剩余:192 天(约 27 周)
--- 里程碑节点 ---
开发启动:2024-03-01 (486天总周期)
测试启动:2025-04-30
代码冻结:2025-06-02
正式交付:2025-06-30
--- 格式化的里程碑 ---
开发启动:2024年03月01日
测试启动:2025年04月30日
代码冻结:2025年06月02日
正式交付:2025年06月30日
示例三:白歌处理飞翔科技员工入职周年纪念
白歌需要为每位员工计算入职周年纪念日,并判断当天是否需要提醒HR准备庆祝活动。
/** 飞翔科技 - 使用java.time计算员工入职周年纪念 */
import java.time.LocalDate;
import java.time.Month;
import java.time.Period;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
class EmployeeAnniversary {
long employeeId;
String name;
LocalDate hireDate;
EmployeeAnniversary(long employeeId, String name, LocalDate hireDate) {
this.employeeId = employeeId;
this.name = name;
this.hireDate = hireDate;
}
}
public class AnniversaryDemo {
public static void main(String[] args) {
List<EmployeeAnniversary> employees = new ArrayList<>();
employees.add(new EmployeeAnniversary(10001, "赵鸣", LocalDate.of(2015, 4, 15)));
employees.add(new EmployeeAnniversary(10002, "孙鹤", LocalDate.of(2018, 12, 1)));
employees.add(new EmployeeAnniversary(10003, "高英", LocalDate.of(2020, 6, 30)));
employees.add(new EmployeeAnniversary(10004, "杨英", LocalDate.of(2022, 3, 10)));
LocalDate today = LocalDate.of(2024, 12, 20);
System.out.println("========== 飞翔科技员工入职周年纪念 ==========");
System.out.println("当前日期:" + today);
System.out.println();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy年MM月dd日");
for (EmployeeAnniversary emp : employees) {
Period service = Period.between(emp.hireDate, today);
long totalMonths = ChronoUnit.MONTHS.between(emp.hireDate, today);
LocalDate nextAnniversary = emp.hireDate.withYear(today.getYear());
// 如果今年的纪念日已过,计算明年的
if (nextAnniversary.isBefore(today)) {
nextAnniversary = nextAnniversary.plusYears(1);
}
long daysUntil = ChronoUnit.DAYS.between(today, nextAnniversary);
System.out.println("工号 " + emp.employeeId + " " + emp.name);
System.out.println(" 入职日期:" + emp.hireDate.format(fmt));
System.out.println(" 服务年限:" + service.getYears() + "年"
+ service.getMonths() + "个月(共" + totalMonths + "个月)");
System.out.println(" 下次周年纪念:" + nextAnniversary.format(fmt)
+ "(还有 " + daysUntil + " 天)");
// 判断是否需要提前准备
if (daysUntil <= 30 && daysUntil > 0) {
System.out.println(" 【提醒】距离纪念日不足30天,请HR准备庆祝活动!");
}
System.out.println();
}
}
}
========== 飞翔科技员工入职周年纪念 ==========
当前日期:2024-12-20
工号 10001 赵鸣
入职日期:2015年04月15日
服务年限:9年8个月(共116个月)
下次周年纪念:2025年04月15日(还有 116 天)
工号 10002 孙鹤
入职日期:2018年12月01日
服务年限:6年0个月(共72个月)
下次周年纪念:2024年12月01日(还有 -19 天)
工号 10003 高英
入职日期:2020年06月30日
服务年限:4年5个月(共53个月)
下次周年纪念:2025年06月30日(还有 192 天)
工号 10004 杨英
入职日期:2022年03月10日
服务年限:2年9个月(共33个月)
下次周年纪念:2025年03月10日(还有 80 天)
示例四:李眉用Instant和Duration处理飞翔科技API超时监控
李眉负责飞翔科技微服务网关,需要对API调用进行精确的时间度量。Instant和Duration提供了纳秒精度的时间计算能力。
/** 飞翔科技 - 使用Instant和Duration进行API调用时间度量 */
import java.time.Instant;
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ThreadLocalRandom;
public class ApiTimeoutMonitor {
public static void main(String[] args) throws Exception {
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
System.out.println("========== 飞翔科技 API 超时监控 ==========");
// 模拟三次API调用
for (int i = 1; i <= 3; i++) {
Instant start = Instant.now();
// 模拟API处理耗时(100-800毫秒随机)
long simulatedMs = ThreadLocalRandom.current().nextLong(100, 801);
Thread.sleep(simulatedMs);
Instant end = Instant.now();
Duration elapsed = Duration.between(start, end);
// 转换为北京时间显示
ZonedDateTime beijingStart = start.atZone(ZoneId.of("Asia/Shanghai"));
ZonedDateTime beijingEnd = end.atZone(ZoneId.of("Asia/Shanghai"));
System.out.println("\n--- API调用 #" + i + " ---");
System.out.println(" 开始时间(UTC):" + start);
System.out.println(" 开始时间(北京):" + beijingStart.format(fmt));
System.out.println(" 结束时间(北京):" + beijingEnd.format(fmt));
System.out.println(" 耗时:" + elapsed.toMillis() + " 毫秒");
System.out.println(" 耗时(纳秒):" + elapsed.toNanos());
// 超时判定(阈值500毫秒)
if (elapsed.toMillis() > 500) {
System.out.println(" 【警告】调用超时!阈值500ms,实际" + elapsed.toMillis() + "ms");
} else {
System.out.println(" 【正常】耗时在阈值范围内");
}
}
// Duration的解析与运算
Duration timeout = Duration.ofSeconds(3).plusMillis(500);
System.out.println("\n--- 系统超时配置 ---");
System.out.println(" 全局超时时间:" + timeout.toMillis() + " 毫秒");
System.out.println(" 即 " + timeout.getSeconds() + " 秒 + "
+ timeout.getNano() / 1_000_000 + " 毫秒");
}
}
========== 飞翔科技 API 超时监控 ==========
--- API调用 #1 ---
开始时间(UTC):2024-12-20T10:30:15.123456Z
开始时间(北京):2024-12-20 18:30:15.123
结束时间(北京):2024-12-20 18:30:15.389
耗时:266 毫秒
耗时(纳秒):266000000
【正常】耗时在阈值范围内
--- API调用 #2 ---
开始时间(UTC):2024-12-20T10:30:15.390Z
...
耗时:612 毫秒
【警告】调用超时!阈值500ms,实际612ms
--- 系统超时配置 ---
全局超时时间:3500 毫秒
即 3 秒 + 500 毫秒
深度原理分析
一、java.time类体系架构
JDK 8的java.time包设计精良,类职责清晰。以下是核心类的层次关系:
关键设计原则:
- 不可变性:所有核心类都是final且不可变的,线程安全,可放心共享。
- 职责分离:LocalDate只管日期,LocalTime只管时间,LocalDateTime管两者,各司其职。
- 工厂方法:统一使用
now()和of(...)创建实例,不用new(构造器私有)。 - 流畅API:
plusDays().minusMonths().withYear()链式调用,返回新实例。
二、Date/Calendar与java.time互操作
在JDK 8项目中,新旧API互操作是必备技能:
三、线程安全性对比
这是新旧API最关键的差异之一:
// ❌ SimpleDateFormat线程不安全——多线程下可能崩溃或产生错误结果
// 典型错误:java.lang.NumberFormatException: multiple points
// ✅ DateTimeFormatter线程安全——可在多线程中安全共享
private static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 可以放心作为静态常量在所有线程中使用
四、Period vs Duration 的选择
- Period:基于日期(年/月/日),用于LocalDate之间的差值。如"距离入职纪念还有3个月5天"。
- Duration:基于时间(秒/纳秒),用于Instant、LocalTime之间的差值。如"API调用耗时350毫秒"。
两者不可混用:Duration.between(localDate1, localDate2) 会抛异常,因为LocalDate不包含时间信息。
易错场景
反例一:❌ 使用Calendar的月份直接赋值
// ❌ Calendar月份从0开始!2代表3月
Calendar cal = Calendar.getInstance();
cal.set(2024, 2, 15); // 你以为设置的是2月15日,实际是3月15日!
System.out.println(cal.getTime()); // 输出:Sat Mar 15 ...
// ✅ 使用Calendar.MARCH常量,或直接用LocalDate
Calendar cal = Calendar.getInstance();
cal.set(2024, Calendar.MARCH, 15); // 明确使用常量
// ✅ 更好的方式:使用LocalDate
LocalDate date = LocalDate.of(2024, 3, 15); // 月份就是3,直观明了
反例二:❌ SimpleDateFormat多线程共享
// ❌ 致命错误!SimpleDateFormat不是线程安全的
public class DateUtils {
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd");
// 多线程同时调用 → 数据错乱、NumberFormatException异常!
}
// ✅ 方案一:每次new(简单但不高效)
public static String format(Date date) {
return new SimpleDateFormat("yyyy-MM-dd").format(date);
}
// ✅ 方案二:使用ThreadLocal
private static final ThreadLocal<SimpleDateFormat> THREAD_SDF =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// ✅ 方案三(推荐):使用DateTimeFormatter
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
反例三:❌ LocalDateTime与Instant的时区混淆
// ❌ 错误:试图直接比较或混合LocalDateTime和Instant
LocalDateTime local = LocalDateTime.now();
Instant instant = Instant.now();
// local.equals(instant); // 编译错误:不同类型!
// ❌ 常见误解:以为LocalDateTime.now()是UTC时间
LocalDateTime now = LocalDateTime.now(); // 实际是系统默认时区的本地时间
// ✅ 正确处理时区转换
// LocalDateTime → Instant:需要指定时区
ZonedDateTime zoned = LocalDateTime.now().atZone(ZoneId.of("Asia/Shanghai"));
Instant instant = zoned.toInstant();
// Instant → LocalDateTime:需要转换为某个时区
LocalDateTime local = LocalDateTime.ofInstant(instant, ZoneId.of("Asia/Shanghai"));
// 直接获取UTC时间
Instant utcNow = Instant.now(); // 这才是UTC瞬时点
反例四:❌ Period的规范化陷阱
// ❌ Period.between的结果不是规范化的!
LocalDate start = LocalDate.of(2024, 1, 31);
LocalDate end = LocalDate.of(2024, 3, 1);
Period p = Period.between(start, end);
// 结果:P1M-29D(1个月负29天),而非期望的P1M1D
System.out.println(p.getMonths() + "月" + p.getDays() + "天"); // 输出"1月-29天"
// ✅ 使用ChronoUnit精确计算总天数
long days = ChronoUnit.DAYS.between(start, end); // 30天
long months = ChronoUnit.MONTHS.between(start, end); // 1个月(向下取整)
// ✅ 如果需要规范化的Period,先规范化
Period normalized = p.normalized(); // 但normalized()只规范化月份到年,不规范化天
// 最佳实践:使用ChronoUnit进行精确计算,Period主要用于"人类可读"的场景
面试考点
Q1: SimpleDateFormat是线程安全的吗?为什么?如何解决?
答:SimpleDateFormat不是线程安全的。原因是其内部维护了一个Calendar对象作为实例变量,在format/parse过程中会修改这个共享的Calendar状态。多线程并发调用时,A线程的format操作可能被B线程的parse操作打断,导致数据错乱甚至抛出NumberFormatException。
解决方案(三选一):
- 每次new:在方法内创建局部SimpleDateFormat实例(简单但有GC压力)。
- ThreadLocal:为每个线程维护独立的SimpleDateFormat实例。
- 最佳方案:使用JDK 8的DateTimeFormatter,它是不可变且线程安全的。
Q2: LocalDateTime和Instant有什么区别?什么场景用哪个?
答:
- LocalDateTime:表示本地日期时间,不包含时区信息。适用于不需要关心时区的场景,如"系统日志时间"、"本地业务截止日期"。它是"墙上时钟时间"的抽象。
- Instant:表示UTC时间线上的一个瞬时点,精确到纳秒。适用于需要跨时区统一时间戳的场景,如"数据库timestamp"、"API调用时间记录"、"消息队列时间戳"。
转换关系:LocalDateTime + ZoneId = ZonedDateTime → toInstant() → Instant。反过来,Instant + ZoneId → ZonedDateTime → toLocalDateTime()。
Q3: Period和Duration的核心区别是什么?
答:
- Period 是以日期单位(年、月、日)度量的时间量,基于ISO-8601的日期系统。使用
Period.between(LocalDate, LocalDate)计算,适合计算"两个日期之间隔了多少年多少月多少天"。 - Duration 是以时间单位(秒、纳秒)度量的时间量,精确到纳秒。使用
Duration.between(Temporal, Temporal)计算(参数需支持秒精度),适合计算"两个时间点之间精确间隔了多少毫秒/纳秒"。 - 记忆口诀:Period管"年月日"(日历概念),Duration管"时分秒"(时钟概念)。给Period一个LocalDate,给Duration一个Instant。
Q4: JDK 8的java.time相比Date/Calendar有哪些改进?
答:
- 不可变性:所有对象不可变,线程安全,无需防御性拷贝。
- API清晰:Date既表示日期又表示时间,职责混淆;java.time严格分离LocalDate/LocalTime/LocalDateTime。
- 月份从1开始:LocalDate.of(2024, 1, 15)就是1月,告别Calendar的"月份从0开始"的痛苦。
- 时间运算流畅:
date.plusDays(7).minusMonths(1)链式调用,清晰直观。 - 时区处理:ZoneId/ZonedDateTime体系远比TimeZone设计优雅。
- 格式化线程安全:DateTimeFormatter不可变,替代了不安全的SimpleDateFormat。
- ISO-8601标准:全面遵循国际标准,减少歧义。
黄俪总结道:"掌握了java.time,就告别了Date的泥潭。但现实项目中新老API并存,互操作的桥梁方法——Date.toInstant()和Date.from(Instant)——也必须熟记于心。"