S03-08 核心类-日期时间
[TOC]
概述
UTC vs GMT
在日常生活中,当我们提到“世界时间”时,通常是指协调世界时(UTC,Coordinated Universal Time)。它是目前全球统一使用的时间标准,世界上所有国家和地区的本地时间都是以它为基准推算出来的。
UTC(协调世界时,Coordinated Universal Time):这是现代科学领域的时间基准。它主要依靠分布在全球的几百台极度精确的“原子钟”来计时,同时还会根据地球自转速度的微小变化(通过引入“闰秒”)进行微调,以确保时间既精确又符合地球的昼夜规律。GMT(格林尼治标准时间,Greenwich Mean Time):这是历史上的世界时间基准。它是通过观测太阳穿过英国伦敦格林尼治天文台本初子午线(经度为 0 度)的时刻计算出来的。虽然在日常交流或计算机系统中,GMT 常被当做 UTC 的同义词,但在严谨的科学和航空领域,UTC 已经完全取代了 GMT。GMT 是前世界标准时,而 UTC 是现世界标准时。 UTC 比 GMT 更加精准。UTC 已经完全取代了 GMT。
TAI vs UT1
我们现在定义时间的标准,其实有两套体系在“打架”:
原子时(TAI):这是极其精准的物理时间,基于原子内部的震荡频率计算。它极其稳定,几千万年都不会误差一秒。世界时(UT1):这是基于地球自转的“天文时间”,也就是我们常说的“日出而作,日落而息”。
闰秒
闰秒 就是为了让“人类的手表”和“地球的自转”保持同步,而人为增加(或减少)的那一秒钟。
为什么需要闰秒:
由于地球自转是不均匀的,且总体趋势是在慢慢变慢。月球引发的潮汐摩擦力、内部岩浆流动、大地震、甚至极地冰川融化,都会导致地球自转一圈的时间稍微变长。
如果人类完全按照绝对精准的“原子时”生活,几千年后,中午 12 点的时候,太阳可能才刚刚从地平线升起。为了让精准的“钟表时间”(也就是目前全球通用的协调世界时 UTC)去迁就稍微有些不准的“地球自转”,国际地球自转服务组织(IERS)就发明了“闰秒”。
当这两个时间的误差累积快达到 0.9 秒时,专家们就会宣布:我们要强行加(或减)一秒了!
闰秒是怎么加的:
通常,闰秒会安排在 6 月 30 日或 12 月 31 日的最后一秒。
正常的时钟跳动是这样的:
23:59:58->23:59:59->00:00:00加上闰秒的那天,时钟会变成这样:
23:59:58->23:59:59->23:59:60->00:00:00
也就是说,那一天的最后一分钟有 61 秒。从 1972 年首次引入到今天,全球一共增加了 27 次正闰秒(由于地球偶尔也会转得快一点,理论上也有“负闰秒”,但历史上还从未实施过)。
夏令时
夏令时(DST,Daylight Saving Time,日光节约时制):在天亮得早的夏季,人为地把时钟“拨快”,让人早起早睡;等到了秋冬季节,再把时钟“拨回”正常时间。
它是一种为了节约能源、充分利用自然光照而人为调整地方时间的制度。
夏令时是怎么运行的:
国际上通用的夏令时调整口诀是 “Spring Forward, Fall Back”(春前秋后):
- 春季(拨快 1 小时):当春天到来,白天变长时,在规定的某一天凌晨(通常是周末),把时钟从 01:59 直接拨到 03:00。这会让你那天少睡一个小时,但随后的每一天,你都会比平时“早”一个小时起床,从而在傍晚享受到更多的日光。
- 秋季(拨回 1 小时):当秋天到来,白天变短时,在规定的某一天凌晨,把时钟从 01:59 拨回 01:00。这天你会多出一个小时的睡眠,时间重新恢复为标准时间(冬令时)。
时区
时区(Time Zone):是地球上使用同一个标准时间的区域。
为什么要发明时区:
在古代,人们靠看太阳来定时间。太阳升到最高点就是正午 12 点(这叫地方太阳时)。
但地球是圆的,并且一直在自转。这就导致当北京是正午时,往西边走的乌鲁木齐太阳才刚刚升起,而往东边的日本太阳已经偏西了。
在马车时代,这种误差无伤大雅。但到了 19 世纪,铁路和电报普及了。如果每个城市都用自己的太阳时,火车的时刻表会彻底变成一场灾难(比如从 A 城早上 8 点出发,坐了 1 小时车到 B 城,结果 B 城的时间才 7 点半)。
为了统一标准,让跨地区协作成为可能,人类在 1884 年的国际会议上正式确立了“时区”的概念。
时区是如何划分的:
理论上的时区划分非常像切西瓜:
基本数学: 地球自转一圈是 360 度,耗时 24 小时。所以 360 ÷ 24 = 15 度。这意味着,经度每相差 15 度,时间就相差 1 个小时。
零点基准: 科学家们把经过英国伦敦格林尼治天文台的那条经线定为“本初子午线”(0 度经线)。以此为中心,划出了中时区(也就是零时区,对应 UTC/GMT 时间)。
东西推算:
本地时间 = 世界时间(UTC) + 时区偏移量- 从零时区向东,每跨越 15 度就是一个新时区,时间加 1 小时(东一区 UTC+1,东二区 UTC+2...直到东十二区)。
- 从零时区向西,时间减 1 小时(西一区 UTC-1,西二区 UTC-2...直到西十二区)。
- 东西十二区在太平洋中部重合,这里有一条人为划分的国际日期变更线。
现实中的“妥协”与“奇葩”:
虽然理论上的时区是一条条笔直的经线,但现实中,为了方便管理,时区的划分充满了政治和行政的妥协:
弯弯曲曲的边界线: 时区线通常会避开把一个城市或一个小国劈成两半,而是顺着国界或州界弯曲。
大国的不同选择:
- 美国、俄罗斯、澳大利亚等大国,因为东西跨度太大,国内实行多时区制。比如美国本土就有东部、中部、山地、太平洋四个主时区。
- 中国的地理位置横跨了从东五区到东九区五个时区,但为了国家行政和调度的统一,全国统一采用东八区的北京时间(UTC+8)作为标准时间。
奇葩的零头时区: 并不是所有时区都是整点相差。有些国家为了让本国时间更贴近太阳的真实起落,采用了半小时甚至 15 分钟的偏移量。比如印度统一使用 UTC+5:30,而尼泊尔使用的是极其罕见的 UTC+5:45。

JDK8 之前
在 Java 8 引入全新的 java.time 包之前,Java 主要依靠 java.util.Date、java.util.Calendar 以及 java.text.SimpleDateFormat 来处理日期和时间。
尽管这些旧版 API 存在许多设计缺陷,如今已被标记为“不推荐使用”,但在维护老旧系统、阅读遗留代码或与早期的第三方库(如旧版 JDBC、某些 JSON 序列化库)对接时,深入理解这些类依然是 Java 开发者的必修课。
System.currentTimeMillis()
static longcurrentTimeMillis():(),时间戳,返回从 1970年1月1日 00:00:00 UTC 到当前时刻所经过的毫秒数。
用途:常用于计算代码执行耗时,或者作为生成唯一 ID 的因子。javalong timestamp = System.currentTimeMillis(); System.out.println("当前时间戳: " + timestamp);计算世界时间的主要标准有:
- UTC(Coordinated Universal Time)
- GMT(Greenwich Mean Time)
- CST(Central Standard Time)
在国际无线电通信场合,为了统一起见,使用一个统一的时间,称为通用协调时(UTC, Universal Time Coordinated)。UTC与格林尼治平均时(GMT, Greenwich Mean Time)一样,都与英国伦敦的本地时相同。这里,UTC与GMT含义完全相同。
java.util.Date
在 Java 的历史长河中,java.util.Date 是最古老的处理日期和时间的类(自 JDK 1.0 引入)。尽管在现代 Java 开发中它已经被 java.time 包下的新 API 取代,但由于历史包袱,我们在维护老项目、使用旧版数据库驱动或某些遗留的第三方库时,依然会频繁地和它打交道。
时间戳的包装类
许多初学者会被 Date 的名字欺骗,认为它代表着日历上的某一天(包含时区等信息)。但实际上,Date 内部只维护了一个简单的 long 类型变量。
- 唯一状态变量:这个
long值叫做fastTime,它记录的是从 Unix 纪元(1970 年 1 月 1 日 00:00:00 GMT)到当前对象所表示的时间之间,所经过的毫秒数。 - 没有时区概念:
Date对象本身绝对不包含任何时区信息。无论你在北京、纽约还是伦敦创建了一个表示同一瞬间的Date对象,它们内部的long值都是完全相同的。
那为什么打印时会有时区?
当你调用 System.out.println(new Date()) 时,实际上是调用了 Date.toString() 方法。这个方法会在内部抓取你操作系统的默认时区,然后把这个底层的时间戳翻译成你当地的时间并拼接成字符串(例如 Tue May 12 15:19:34 CST 2026)。时区只是“显示”时的魔法,而不是对象内部的属性。
API:Date
目前,Date 类中绝大多数方法都已经被标记为 @Deprecated(已过时),只有以下几个与时间戳直接交互的方法还在正常使用:
构造方法
目前官方推荐使用的构造方法只有两个:
Date():
(),分配一个Date对象,并用当前时间(精确到毫秒)对其进行初始化。
本质:底层实际上是调用了System.currentTimeMillis()。java// 1. 获取当前时间的 Date Date now = new Date();Date():
(long date),分配一个Date对象,并根据给定的毫秒时间戳进行初始化。java// 2. 根据时间戳创建 Date (例如:2023-01-01 00:00:00 UTC 的时间戳) long timestamp = 1672531200000L; Date specificDate = new Date(timestamp);
获取与设置
Date 对象内部唯一维护的状态就是一个 long 类型的时间戳,以下两个方法直接操作这个底层数据:
longgetTime():(),获取该Date对象所表示的毫秒级时间戳。
场景:将Date存入数据库的长整型字段,或者参与数学计算时非常常用。voidsetTime():(long time),将该Date对象内部的时间戳设置为指定的值。
注意:这个方法使得Date成为一个可变对象 (Mutable)。在多线程或安全敏感的场景下,轻易调用setTime会导致意外的 Bug。
Date date = new Date();
// 获取时间戳
long currentMillis = date.getTime();
// 将时间往后推移 1 小时 (1小时 = 3600000 毫秒)
date.setTime(currentMillis + 3600000);时间比较
在业务逻辑中,经常需要判断两个时间的先后顺序,Date 提供了四个非常便捷的方法:
booleanbefore():(Date when),测试此日期是否在指定日期when之前。booleanafter():(Date when),测试此日期是否在指定日期when之后。intcompareTo():(Date anotherDate),比较两个日期的顺序(实现了Comparable接口)。
如果参数 Date 等于此 Date,则返回值0;
如果此 Date 在参数 Date 之前,则返回< 0的值;
如果此 Date 在参数 Date 之后,则返回> 0的值。booleanequals():(Object obj),比较两个日期的相等性。
只有当两个Date对象的getTime()返回的毫秒数完全相同时,才返回true。
Date past = new Date(System.currentTimeMillis() - 10000); // 10秒前
Date now = new Date();
System.out.println("now 在 past 之后吗? " + now.after(past)); // true
System.out.println("now 在 past 之前吗? " + now.before(past)); // false
System.out.println("比较结果: " + now.compareTo(past)); // 1 (因为 now > past)现代转换
为了让老旧的 Date 能够无缝接入 Java 8 的全新时间类(java.time 包),官方在 Java 8 中为 Date 新增了一个极其重要的方法:
InstanttoInstant():(),JDK8,将此Date对象转换为一个Instant对象(时间戳的现代面向对象表示)。
场景:这是旧代码与新代码之间的黄金桥梁。只要拿到了Date,第一时间用这个方法把它变成Instant,然后就可以配合ZoneId轻松转为LocalDateTime等新版类。javaDate legacyDate = new Date(); // 核心桥接操作:Date -> Instant Instant instant = legacyDate.toInstant(); // 然后就可以使用现代 API 了 (例如转为本地时间) LocalDateTime modernDateTime = instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
输出方法
- String toString():
(),将Date对象转换为形如dow mon dd hh:mm:ss zzz yyyy的字符串(例如Tue May 12 15:33:24 CST 2026)。
注意:这是Date类内部唯一涉及时区计算的地方。它会读取操作系统的默认时区来格式化字符串。不要依赖这个方法来做数据传输,它仅适用于控制台打印调试。如果需要特定格式的字符串,必须配合SimpleDateFormat(注意线程安全)使用。
设计缺陷
Sun 公司在 JDK 1.1 时就意识到了 Date 的问题,并试图用 Calendar 来补救,但直到 Java 8 才真正彻底解决。
Date 的主要缺陷如下:
命名与职责名不副实:
名字叫
Date(日期),但它不仅包含年月日,还包含时分秒和毫秒。这导致在只需要传递“某一天”(如生日、节假日)的业务场景中,不得不想办法抹去时分秒(通常设为 00:00:00),极其麻烦。反人类的偏移量(已过时方法):
如果你尝试使用它那些已过时的构造函数或 Getter 方法,你会发现非常违背直觉的规则:
- 年份:是从 1900 年开始计算的。如果你想表示 2023 年,你传入的参数必须是
123(2023 - 1900)。调用getYear()返回的也是 123。 - 月份:是从 0 开始的。
0代表一月,11代表十二月。这导致了无数的 "差一个月" Bug。 - 天数:更奇葩的是,月份从 0 开始,但一个月中的哪一天(
getDate())却是从 1 开始的。
java// 极其反人类的旧版构造方式 (已被强烈废弃) // 意图创建: 2023年10月25日 Date badDate = new Date(123, 9, 25); // 123代表2023, 9代表10月- 年份:是从 1900 年开始计算的。如果你想表示 2023 年,你传入的参数必须是
可变性 (Mutability):
Date提供了setTime(long time)方法。这意味着当你把一个Date对象作为参数传递给某个方法后,那个方法可以在内部偷偷修改这个时间!在安全要求高的场景(如密码过期时间验证、防重放攻击校验),必须在每次
get或set时进行防御性拷贝(克隆一个新对象),否则会引发严重的安全隐患。缺乏直接的格式化与计算能力:
Date自己不能格式化,必须配合SimpleDateFormat(线程不安全)使用;它自己也不能进行“加一天”、“减三个月”的运算,必须配合Calendar(极其繁琐)使用。
遗留 Date 处理
如何优雅地处理遗留的 Date(与 Java 8+ 桥接):
在现代开发中,如果你从老接口或者旧版数据库驱动中拿到了一个 Date 对象,最佳实践是立即将其转换为 Java 8 的新 API,进行业务逻辑处理,如果必须要返回 Date 给老接口,再转换回去。
桥接的核心是 Instant(时间戳对象):
Instant toInstant():(),JDK8,将此 Date 对象转换为一个 Instant 对象(时间戳的现代面向对象表示)。
场景:这是旧代码与新代码之间的黄金桥梁。只要拿到了 Date,第一时间用这个方法把它变成 Instant,然后就可以配合 ZoneId 轻松转为 LocalDateTime 等新版类。
import java.util.Date;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.Instant;
public class DateConverter {
// 1. 旧版 Date -> 新版 LocalDateTime
public static LocalDateTime dateToLocalDateTime(Date date) {
// 先转成绝对瞬间 Instant,再结合系统默认时区转成 LocalDateTime
return date.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
}
// 2. 新版 LocalDateTime -> 旧版 Date
public static Date localDateTimeToDate(LocalDateTime localDateTime) {
// 先结合系统默认时区转成 ZonedDateTime,再转成 Instant,最后装入 Date
Instant instant = localDateTime.atZone(ZoneId.systemDefault()).toInstant();
return Date.from(instant);
}
}总结:将 java.util.Date 视为一个只用来装载 long 类型时间戳的“老旧快递盒”。业务逻辑运算和格式化,统统交给拆箱后的 java.time 包去处理即可。
练习题
练习:如何将一个 java.util.Date 的实例转换为 java.sql.Date 的实例

练习:将控制台输入的年月日(如 2022-12-13)字符串数据保存到数据库中(转换为 java.sql.Date 对象)

java.text.SimpleDateFormat
在 Java 的日期时间处理历史中,java.text.SimpleDateFormat 扮演了极其重要但也最具争议的角色。它是 Java 8 之前用于格式化(将对象转为字符串) 和解析(将字符串转为对象) 日期时间的核心基石。
尽管它存在致命的线程安全问题,但在维护老旧项目时,你依然会随处可见它的身影。
格式化与解析
SimpleDateFormat 是 DateFormat 的一个具体子类。它的主要职责是在 java.util.Date 对象和 String 字符串之间建立桥梁:
格式化 (Formatting):
Date->String。把机器友好的时间戳对象,转换成人类可读的指定格式字符串(例如"2023-10-25")。解析 (Parsing):
String->Date。把人类输入的带有格式的时间字符串,转换为程序可以处理的Date对象。
占位符字母表
使用 SimpleDateFormat 时,你必须定义一个“模式字符串”(Pattern)。这个字符串由特定的英文字母组成,大小写极其严格。以下是最常用的占位符:
| 字母 | 代表含义 | 示例表现 |
|---|---|---|
| y | 年份 (Year) | yyyy -> 2023; yy -> 23 |
| M | 月份 (Month) | MM -> 09 (补零); M -> 9; MMM -> Sep |
| d | 月中的天数 (Day in month) | dd -> 05 (补零); d -> 5 |
| H | 24 小时制 (0-23) | HH -> 14 |
| h | 12 小时制 (1-12) | hh -> 02 |
| m | 分钟 (Minute) | mm -> 30 |
| s | 秒 (Second) | ss -> 59 |
| S | 毫秒 (Millisecond) | SSS -> 892 |
| E | 星期几 (Day of week) | E -> 星期二 / Tue |
| Z | 时区(TimeZone) | Z -> -0800 |
最经典的组合模式:"yyyy-MM-dd HH:mm:ss" (例如:2023-10-25 14:30:00)
API:SimpleDateFormat
构造方法
通常我们在创建实例时,不仅要指定格式,有时候还需要指定语言环境 (Locale),这对于解析包含英文字母的月份或星期极其重要。
SimpleDateFormat():
(String pattern),使用指定的模式字符串和系统默认的语言环境(Locale)构造。javaSimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss") Date date = sdf.parse(dateStr);SimpleDateFormat():
(String pattern, Locale locale),使用指定的模式字符串和指定的语言环境构造。
避坑场景:如果你的服务器操作系统是中文环境,但你需要解析"05/Sep/2023"这样的英文格式字符串,只传 pattern 会报错,必须指定Locale.US或Locale.ENGLISHjava// 如果系统是中文环境,直接解析英文月份会抛出 ParseException String dateStr = "25/Oct/2023"; // 正确做法:显式指定英语 Locale SimpleDateFormat sdf = new SimpleDateFormat("dd/MMM/yyyy", Locale.ENGLISH); Date date = sdf.parse(dateStr);
格式化与解析
format():
(Date date),将给定的Date对象格式化为符合模式的字符串。javaimport java.text.SimpleDateFormat; import java.util.Date; public class FormatExample { public static void main(String[] args) { Date now = new Date(); // 模式 1: 标准完整格式 SimpleDateFormat sdf1 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); System.out.println(sdf1.format(now)); // 2026-05-12 16:15:30 // 模式 2: 中文格式带毫秒 SimpleDateFormat sdf2 = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss.SSS"); System.out.println(sdf2.format(now)); // 2026年05月12日 16:15:30.123 } }parse():
(String source),尝试解析给定的字符串,生成一个Date对象。
异常:如果字符串内容与模式不匹配,会抛出ParseException(这是一个受检异常,必须try-catch或throws)。javaimport java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; public class ParseExample { public static void main(String[] args) { String dateString = "2023-01-01 12:00:00"; SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); try { // 将字符串解析为 Date 对象 Date parsedDate = sdf.parse(dateString); System.out.println("解析成功的时间戳: " + parsedDate.getTime()); } catch (ParseException e) { System.err.println("字符串格式不符合预期的 yyyy-MM-dd HH:mm:ss"); e.printStackTrace(); } } }
线程不安全
这是 Java 面试中几乎必问的经典问题:SimpleDateFormat 是线程不安全的。
为什么不安全:
如果你查看 SimpleDateFormat 的底层源码,会发现它继承自 DateFormat。在 DateFormat 内部,维护了一个实例变量 protected Calendar calendar;。
当调用 format() 或 parse() 时,SimpleDateFormat 会先将时间设置到这个全局的 calendar 中,然后进行下一步操作。
如果多线程共享同一个 SimpleDateFormat 实例(例如把它定义为 public static final),线程 A 刚把时间设进 calendar,还没来得及转成字符串,就被线程 B 把 calendar 的时间给覆盖了。这会导致抛出 NumberFormatException 或者返回极其混乱、错误的时间。
经典的错误示范:
// ⚠️ 1. 危险!绝对不要在多线程环境中这样写全局共享变量
public static final SimpleDateFormat ERROR_SDF = new SimpleDateFormat("yyyy-MM-dd");
public void doTask(Date date) {
// 2. 多线程并发调用时,必然报错或数据错乱
String str = ERROR_SDF.format(date);
}旧代码并发解决方案
如果在维护老项目,必须使用 SimpleDateFormat,有以下三种解决方式:
方案 1:局部变量法(简单,但性能最差)
每次需要格式化时都 new 一个新对象。这会导致频繁创建和销毁对象,增加垃圾回收(GC)的压力。
public String formatDate(Date date) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
return sdf.format(date);
}方案 2:同步锁加锁法(安全,但并发性能差)
对共享的实例加锁,强行让多线程排队执行。
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd");
public static synchronized String formatSafe(Date date) {
return SDF.format(date);
}方案 3:ThreadLocal(旧版代码的最佳实践,推荐 ⭐)
利用 ThreadLocal 为每一个线程绑定一个独立的 SimpleDateFormat 实例,做到“空间换时间”,既保证了线程安全,又避免了频繁创建对象。
public class DateUtils {
// 为每个线程分配独立的 SimpleDateFormat 实例
private static final ThreadLocal<SimpleDateFormat> SDF_THREAD_LOCAL =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public static String formatSafe(Date date) {
return SDF_THREAD_LOCAL.get().format(date);
}
public static Date parseSafe(String dateStr) throws ParseException {
return SDF_THREAD_LOCAL.get().parse(dateStr);
}
}现代平替:DateTimeFormatter
自从 Java 8 推出后,你不再需要(也不应该)在新项目中使用 SimpleDateFormat。
Java 8 引入了 java.time.format.DateTimeFormatter。它被设计为绝对不可变且线程安全的,可以放心地定义为全局静态变量。
// Java 8 的现代做法:线程安全,直接设为全局常量
public static final DateTimeFormatter MODERN_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
LocalDateTime now = LocalDateTime.now();
// 格式化
String str = now.format(MODERN_FORMATTER);
// 解析
LocalDateTime parsed = LocalDateTime.parse("2023-10-25 12:00:00", MODERN_FORMATTER);
}java.util.Calendar
在 Java 发展的早期(JDK 1.1),为了解决 java.util.Date 无法处理国际化、缺乏时区支持以及难以进行日期运算(加减操作)等致命缺陷,Sun 公司引入了 java.util.Calendar。
在 Java 8 推出 java.time 包之前,Calendar 一直是 Java 中处理日期运算和日历字段提取的核心主力。尽管现在它已经被标记为遗留 API,但在老项目中它依然无处不在。
核心概念与设计
核心概念与设计:
1. 它是抽象类
Calendar 本身是一个抽象类,无法直接 new。它提供了一个工厂方法 Calendar.getInstance()。在绝大多数情况下,这个方法会根据你操作系统的默认时区和语言环境,返回它的一个标准子类——GregorianCalendar(公历/格里高利历) 的实例。
2. 核心职责:统筹“日历字段”
如果说 Date 只是一个愚蠢的“毫秒时间戳包装盒”,那么 Calendar 就是一个真正的“日历”。它内部维护了一个数组,把时间戳拆解成了年份(YEAR)、月份(MONTH)、日期(DAY)、小时(HOUR)等各个日历字段(Fields),并允许你对这些字段进行独立的操作和数学运算。
设计缺陷
在使用 Calendar 时,有几个违背直觉的设定,是引发无数 Bug 的罪魁祸首:
- 月份从 0 开始:
Calendar.JANUARY的值是0,DECEMBER的值是11。如果你想设置 10 月,你必须传入9,或者使用常量Calendar.OCTOBER。 - 星期从星期日开始,且值为 1:在
Calendar中,一周的第一天是星期日(值为 1),星期一是 2,以此类推,星期六是 7。 - 它是可变的 (Mutable):和
Date一样,调用set、add等方法会直接修改原对象,这意味着它在多线程环境下是绝对不安全的。
日历字段常量
在调用 API 之前,必须熟悉 Calendar 规定的核心字段常量,它们将作为参数高频出现:
Calendar.YEAR:年份Calendar.MONTH:月份 (极其重要:0 表示 1 月,11 表示 12 月)Calendar.DATE或Calendar.DAY_OF_MONTH:一个月中的第几天Calendar.HOUR_OF_DAY:一天中的小时(24 小时制,0-23)Calendar.HOUR:一天中的小时(12 小时制,0-11)Calendar.MINUTE:分钟Calendar.SECOND:秒Calendar.MILLISECOND:毫秒Calendar.DAY_OF_WEEK:星期几 (周日为 1,周一为 2,...,周六为 7)
API:Calendar
实例化对象
Calendar 是抽象类,不能直接 new,需要通过静态工厂方法获取其实例(通常返回的是 GregorianCalendar)。
static CalendargetInstance():(),使用默认时区和语言环境获得一个日历,时间初始化为当前系统时间。static CalendargetInstance():(TimeZone zone),使用指定的时区获取当前时间的日历(常用于处理跨国时间)。static CalendargetInstance():(Locale aLocale),使用指定的语言环境获取日历(影响星期的起始日等本地化习惯)。
// 1. 获取当前时间的日历对象
Calendar calendar = Calendar.getInstance();Date/时间戳 转换
在老项目中,Calendar 往往负责计算,Date 或 long 负责数据传递或入库。
final DategetTime():(),将Calendar对象当前的日历时间转换为一个java.util.Date对象。final voidsetTime():(Date date),使用给定的Date对象来设置此Calendar的当前时间。longgetTimeInMillis():(),返回此Calendar的时间戳(自 1970-01-01 00:00:00 GMT 以来的毫秒数)。voidsetTimeInMillis():(long millis),使用给定的毫秒时间戳来设置此Calendar的当前时间。
// 1. 获取当前时间的日历对象
Calendar calendar = Calendar.getInstance();
// 2. Date -> Calendar
Date now = new Date();
calendar.setTime(now); // 把 Date 塞进 Calendar 中
// 3. Calendar -> Date
Date dateFromCal = calendar.getTime(); // 把 Calendar 算好的结果提取为 Date提取设置日历字段
通过传入 Calendar 类中定义的常量,可以获取各个时间维度的值。
intget():(int field),获取指定日历字段的值。voidset():(int field, int value),将给定的日历字段设置为给定值。voidset():(int year, int month, int date),便捷方法,同时设置年、月、日。voidset():(int year, int month, int date, int hourOfDay, int minute, int second),便捷方法,同时设置完整的年月日时分秒。final voidclear():(),清除所有日历字段的值(将其重置为 1970-01-01 00:00:00.000)。final voidclear():(int field),只清除指定的日历字段。
避坑场景:有时你只需要按天计算,不关心时分秒,需要用clear()清除时分秒和毫秒的影响,否则后续的日期比较(如equals)会失败。
Calendar cal = Calendar.getInstance();
int year = cal.get(Calendar.YEAR);
// 必须 +1 才是符合人类直觉的真实月份
int month = cal.get(Calendar.MONTH) + 1;
int day = cal.get(Calendar.DAY_OF_MONTH); // 等同于 Calendar.DATE
int hour12 = cal.get(Calendar.HOUR); // 12小时制 (0-11)
int hour24 = cal.get(Calendar.HOUR_OF_DAY); // 24小时制 (0-23)
int minute = cal.get(Calendar.MINUTE);
int second = cal.get(Calendar.SECOND);
// 获取今天是星期几 (1=周日, 2=周一 ... 7=周六)
int dayOfWeek = cal.get(Calendar.DAY_OF_WEEK);你可以单独修改某个字段,或者一次性设置年月日。
Calendar cal = Calendar.getInstance();
// 单独修改年份为 2025
cal.set(Calendar.YEAR, 2025);
// 极其注意:设置 10 月 25 日,月份必须传 9 (或者用常量)
cal.set(Calendar.MONTH, Calendar.OCTOBER);
cal.set(Calendar.DAY_OF_MONTH, 25);
// 连写格式 (年, 月, 日, 时, 分, 秒)
cal.set(2023, Calendar.OCTOBER, 25, 14, 30, 0);
// 清空所有字段 (重置为 1970-01-01 00:00:00)
cal.clear();日期计算
这是 Calendar 最核心的功能,用于时间的加减。
voidadd():(int field, int amount),常用,根据日历的规则,将指定的时间量添加或减去指定的日历字段。
特性:会自动进位/借位。例如 1 月 31 日加 1 个月,会变成 2 月 28/29 日;12 月加 1 个月,年份会加 1。java// --- 演示 add() 的进位特性 --- Calendar cal1 = Calendar.getInstance(); cal1.set(2023, Calendar.DECEMBER, 31); // 2023-12-31 cal1.add(Calendar.MONTH, 1); // 加 1 个月 // 结果:2024-01-31 (年份自动进位了,变成了 2024 年!)voidroll():(int field, int amount),危险,向指定的日历字段添加或减去指定的时间量,但不更改更大的字段。
特性:不进位,只在当前字段内循环。java// --- 演示 roll() 的不进位特性 --- Calendar cal2 = Calendar.getInstance(); cal2.set(2023, Calendar.DECEMBER, 31); // 2023-12-31 cal2.roll(Calendar.MONTH, 1); // 滚动 1 个月 // 结果:2023-01-31 (2023 年 12 月 `roll` 加上 1 个月,会变成 2023 年 1 月,年份不变。)
边界查询
在业务中,经常需要知道“本月有多少天”、“今年有多少天”等边界问题。
intgetActualMaximum():(int field),常用,返回指定日历字段可能拥有的最大值(考虑当前时间的上下文)。java// 经典用法(获取当月最后一天) Calendar cal = Calendar.getInstance(); // 设置为某个特定的年月 cal.set(Calendar.YEAR, 2024); cal.set(Calendar.MONTH, Calendar.FEBRUARY); // 自动计算闰年的 2 月有多少天 (返回 29) int lastDay = cal.getActualMaximum(Calendar.DAY_OF_MONTH);intgetActualMinimum():(int field),返回指定日历字段可能拥有的最小值。对于大多数字段(如天数),最小值通常固定为 1。
时间比较
虽然 Calendar 可以转成时间戳通过 >= 或 <= 比较,但它自身也提供了便捷的方法:
booleanbefore():(Object when),判断此Calendar表示的时间是否在指定的Object(必须也是一个Calendar)表示的时间之前。booleanafter():(Object when),判断此Calendar表示的时间是否在指定的Object表示的时间之后。intcompareTo():(Calendar anotherCalendar),比较两个日历对象的时间先后顺序。
返回 0 表示相等;
返回负数表示当前日历更早;
返回正数表示当前日历更晚。
Calendar cal = Calendar.getInstance();
// 比较先后顺序
Calendar future = Calendar.getInstance();
future.add(Calendar.DAY_OF_MONTH, 5);
boolean isAfter = future.after(cal); // true现代平替:迁移到 Java 8+
现代平替:迁移到 Java 8+:
由于 Calendar 存在“可变对象(线程不安全)”和“魔数常量(0 代表 1 月)”的糟糕设计,如果你在维护老项目时拿到了一个 Calendar 对象,建议立刻将其转换为 Java 8 的新 API 以进行后续的业务逻辑处理。
桥接方法:转为 ZonedDateTime
Calendar 相比 Date 的进步在于它包含了时区信息。因此,它最好的转换目标是现代的带时区时间类 ZonedDateTime。
import java.util.Calendar;
import java.time.ZonedDateTime;
import java.time.LocalDateTime;
public class CalendarMigration {
public static void main(String[] args) {
Calendar legacyCal = Calendar.getInstance();
// 1. Calendar 转 ZonedDateTime (Java 8 官方推荐的桥接方式)
ZonedDateTime zdt = legacyCal.toInstant().atZone(legacyCal.getTimeZone().toZoneId());
// 2. 如果你不需要时区,可以进一步转为 LocalDateTime
LocalDateTime ldt = zdt.toLocalDateTime();
System.out.println("现代 API 时间: " + ldt);
}
}练习题
练习:输入年份和月份,输出该月日历
闰年计算公式:年份可以被4整除但不能被100整除,或者可以被400整除。

JDK8 及之后
旧 API 的设计缺陷
如果我们可以跟别人说:“我们在1502643933071见面,别晚了!”那么就再简单不过了。但是我们希望时间与昼夜和四季有关,于是事情就变复杂了。JDK 1.0中包含了一个java.util.Date类,但是它的大多数方法已经在JDK 1.1引入Calendar类之后被弃用了。而Calendar并不比Date好多少。它们面临的问题是:
可变性:像日期和时间这样的类应该是不可变的。
偏移性:Date中的年份是从1900开始的,而月份都从0开始。
格式化:格式化只对Date有用,Calendar则不行。
此外,它们也不是线程安全的;不能处理闰秒等。
闰秒,是指为保持协调世界时接近于世界时时刻,由国际计量局统一规定在年底或年中(也可能在季末)对协调世界时增加或减少1秒的调整。由于地球自转的不均匀性和长期变慢性(主要由潮汐摩擦引起的),会使世界时(民用时)和原子时之间相差超过到±0.9秒时,就把协调世界时向前拨1秒(负闰秒,最后一分钟为59秒)或向后拨1秒(正闰秒,最后一分钟为61秒); 闰秒一般加在公历年末或公历六月末。
目前,全球已经进行了27次闰秒,均为正闰秒。
总结:对日期和时间的操作一直是Java程序员最痛苦的地方之一。
java.time
经过了前面关于 java.util.Date 和 Calendar 等旧版 API 的“痛苦折磨”后,我们终于迎来了 Java 开发者的福音 Java 8 引入的 java.time 包。
java.time 包是由 Joda-Time 的作者 Stephen Colebourne 领导设计的全新 API(JSR 310)。它彻底抛弃了历史包袱,基于领域驱动设计 (DDD,Domain-Driven Design),拥有绝对的线程安全性(不可变性)和极其优雅的流式调用(Fluent API)。
核心设计理念
不可变性 (Immutability):所有的日期时间类(如
LocalDate)一旦创建就不能被修改。任何诸如“加一天”、“改年份”的操作,都会返回一个全新的对象。这彻底消灭了多线程并发修改导致的 Bug。职责单一 (Separation of Concerns):旧版
Date揉合了日期、时间、时区。新版将其严格拆分为:机器时间、不带时区的人类时间、带时区的人类时间。符合人类直觉:月份终于从
1开始算起了(1 月就是 1),星期一到星期日对应的枚举值是1到7。
time 包组成
java.time 并不是孤立的一个包,它包含五个核心包,各自承担不同的架构职责:
java.time(核心基石包):这是开发者日常 90% 工作都在打交道的包。它包含了所有代表时间点和时间段的值类型对象。
- 人类视角(无时区):
LocalDate,LocalTime,LocalDateTime - 机器视角(绝对时间):
Instant - 全球视角(带时区/偏移):
ZonedDateTime,OffsetDateTime,OffsetTime - 时间碎片(残缺时间):
Year,YearMonth,MonthDay(非常适合处理诸如信用卡有效期、每年固定生日等业务场景)。 - 时间跨度:
Duration:物理时间跨度(基于时分秒纳秒,如“耗时 5 分钟”)。Period:日历时间跨度(基于年月日,如“相差 2 年 3 个月”)。
- 人类视角(无时区):
java.time.format(格式化与解析包):这个包彻底埋葬了那个臭名昭著的、线程不安全的
SimpleDateFormat。- 核心类:
DateTimeFormatter- 核心优势:绝对线程安全!你可以(且应该)将它声明为
public static final全局共享。 - 它内置了大量标准格式(如
DateTimeFormatter.ISO_LOCAL_DATE),也支持通过ofPattern("yyyy-MM-dd")自定义。
- 核心优势:绝对线程安全!你可以(且应该)将它声明为
- 高级类:
DateTimeFormatterBuilder- 用于构建极其复杂的自定义解析逻辑(例如解析带有各种奇葩后缀、可选字段的时间字符串)。
- 核心类:
java.time.temporal(底层时间算法与调节器包):这是高级开发者的“魔法武器库”。它提供了底层的加减算法和极度灵活的日历推演。
ChronoUnit(时间单位枚举):定义了从纳秒(NANOS)到纪元(ERAS)的各种加减单位。ChronoField(时间字段枚举):定义了极其精细的时间维度(例如DAY_OF_YEAR,ALIGNED_WEEK_OF_MONTH)。TemporalAdjusters(时间调节器):神仙工具类。专治各种变态的业务日历逻辑:- “本月的最后一个周五” ->
TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY) - “下一个工作日” -> 可以自定义一个
TemporalAdjuster来排除周末和法定节假日。
- “本月的最后一个周五” ->
java.time.zone(时区规则解析包):底层的时区数据库支持。普通的 CRUD 程序员很少直接碰它,但如果你在做跨国金融系统,必须了解它。
ZoneRules:维护了全球各个地区从古至今的时区演变规则(例如历史上某个国家哪一年废除了夏令时)。- 夏令时(DST)重叠与跳跃处理:当夏令时开始或结束时,时间会凭空消失一小时或重复一小时,这个包提供了处理这些时间断层的底层规则。
java.time.chrono(非公历系统包):Java 不仅支持公历(ISO-8601),还内置了对其他历法的支持。
HijrahDate(伊斯兰教历)JapaneseDate(日本和历,支持年号如“令和”、“平成”)MinguoDate(中国台湾的民国纪年)ThaiBuddhistDate(泰国佛教历)
API:日期时间类
FIELD@
| 方法名 | 适用类 | 返回值与说明 |
|---|---|---|
XxxYear() | Date / DateTime | 返回/设置/加减:年份 (如 2023) |
XxxMonthValue() | Date / DateTime | 返回/设置/加减:真实的月份数字 (1 - 12) ⭐ |
XxxDayOfMonth() | Date / DateTime | 返回/设置/加减:当月的第几天 (1 - 31) |
XxxHour() | Time / DateTime | 返回/设置/加减:小时 (0 - 23) |
XxxMinute() | Time / DateTime | 返回/设置/加减:分钟 (0 - 59) |
XxxSecond() | Time / DateTime | 返回/设置/加减:秒数 (0 - 59) |
XxxNano() | Time / DateTime | 返回/设置/加减:纳秒数 (0 - 999,999,999) |
XxxMonth() | Date / DateTime | 返回/设置/加减: Month 枚举 (如 OCTOBER) |
XxxDayOfWeek() | Date / DateTime | 返回/设置/加减: DayOfWeek 枚举通过 .getValue() 可获取 1-7 的数字 |
通用方法@
不论你是操作 LocalDate(日期)、LocalTime(时间)、LocalDateTime(日期时间),还是带有绝对时区的 ZonedDateTime,或者是机器时间 Instant,它们的 API 骨架几乎是完全一样的。
对象创建与解析
由于它们是不可变类且没有 public 构造函数,创建对象必须依赖静态工厂方法。
static <日期时间类>now()(),获取系统默认时区下的当前日期/时间/日期时间/瞬时点。javastatic LocalDate now() // 当前日期 static LocalTime now() // 当前时间 static LocalDateTime now() // 当前日期时间 static ZonedDateTime now() // 当前日期时间(带时区) static Instant now() // 当前瞬时点java// 示例 LocalDate today = LocalDate.now(); LocalTime nowTime = LocalTime.now(); LocalDateTime nowDt = LocalDateTime.now();static <日期时间类>of()(...),通过指定具体的年月日/时分秒/时区创建日期/时间类的实例。javastatic LocalDate of(时,分,秒?,纳秒?) // 年月日 static LocalTime of(时,分,秒?,纳秒?) // 时分秒 static LocalDateTime of(年,月,日,时,分,秒?,纳秒?) // 年月日时分秒纳秒 static ZonedDateTime of(年,月,日,时,分,秒?,纳秒?, ZoneId zone) // 年月日时分秒时区 // 没有 Instantjava// 示例 LocalDate myDate = LocalDate.of(2023, 10, 25); LocalTime myTime = LocalTime.of(14, 30, 0); // 14点30分0秒 LocalDateTime myDt = LocalDateTime.of(2023, 10, 25, 14, 30, 0);static <日期时间类>from()(TemporalAccessor temporal),从给定的时态对象temporal获取指定日期/时间/Instant类的实例。javastatic LocalDate from(TemporalAccessor temporal) // 日期 static LocalTime from(TemporalAccessor temporal) // 时间 static LocalDateTime from(TemporalAccessor temporal) // 日期时间 static ZonedDateTime from(TemporalAccessor temporal) // 日期时间(带时区) static Instant from(TemporalAccessor temporal) // 瞬时点java// 示例【static <日期时间类>parse():(CharSequence text),将符合 ISO-8601 标准的字符串解析为日期/时间/Instant类的实例。javastatic LocalDate parse(CharSequence text, DateTimeFormatter formatter?) // (使用特定格式)解析为 日期 static LocalTime parse(CharSequence text) // 解析为 时间 static LocalDateTime parse(CharSequence text) // 解析为 日期时间 static ZonedDateTime parse(CharSequence text) // 解析为 日期时间(带时区) static Instant parse(CharSequence text) // 解析为 瞬时点javaLocalDate parsedDate = LocalDate.parse("2023-10-25"); LocalTime parsedTime = LocalTime.parse("14:30:00"); LocalDateTime parsedDt = LocalDateTime.parse("2023-10-25T14:30:00"); // 解析完整字符串,注意中间的 'T' 是国际标准规定的分隔符
提取/修改/加减时间
intget():(TemporalField field),通用的底层获取方式,配合ChronoField枚举使用。intgetFIELD():(),返回年月日时分秒等字段。javaLocalDateTime now = LocalDateTime.now(); // 假设现在是 2026-05-14 14:30:00 int year = now.getYear(); // 2026 int month = now.getMonthValue(); // 5 int day = now.getDayOfMonth(); // 14withFIELD():
(int FIELD),返回设置指定的年月日时分秒等字段后的日期时间副本。javaLocalDateTime time = LocalDateTime.now(); // 把年份强制修改为 2025,把小时修改为 8 点 (其他字段不变) LocalDateTime modifiedTime = time.withYear(2025).withHour(8);plusFIELDS():
(long FIELDS),minusFIELDS():
(long FIELDS),返回增加/减少指定的年月日时分秒等字段后的日期时间副本。
注意:它们会自动处理复杂的进位/借位以及闰年逻辑(智能进位)。javaLocalDateTime dt = LocalDateTime.of(2023, 1, 31, 10, 0); // --- 增加时间 (plus) --- LocalDateTime tomorrow = dt.plusDays(1); // 加 1 天 LocalDateTime nextMonth = dt.plusMonths(1); // 加 1 个月 (智能进位:1月31日变成2月28日) LocalDateTime nextYear = dt.plusYears(1); // 加 1 年 LocalDateTime later = dt.plusHours(2).plusMinutes(30); // 链式调用:加 2小时30分 // --- 减少时间 (minus) --- LocalDateTime yesterday = dt.minusDays(1); // 减 1 天 LocalDateTime lastWeek = dt.minusWeeks(1); // 减 1 周 LocalDateTime earlier = dt.minusSeconds(60); // 减 60 秒
对象拼装与拆解
这三大类经常需要相互转换,API 设计得像乐高积木一样:
1. 拼装 (at 系列):将小的粒度拼成大的粒度
LocalDate date = LocalDate.of(2023, 10, 25);
LocalTime time = LocalTime.of(14, 30);
// LocalDate 补充时间 -> LocalDateTime
LocalDateTime dt1 = date.atTime(time); // 2023-10-25T14:30
LocalDateTime dt2 = date.atTime(0, 0, 0); // 补充特定时间:午夜零点
LocalDateTime dt3 = date.atStartOfDay(); // 等同于上一句,更优雅的快捷方法
// LocalTime 补充日期 -> LocalDateTime
LocalDateTime dt4 = time.atDate(date);2. 拆解 (to 系列):将大的粒度拆分为小的粒度
LocalDateTime dt = LocalDateTime.now();
// LocalDateTime 降维提取
LocalDate justDate = dt.toLocalDate();
LocalTime justTime = dt.toLocalTime();时间比较
极其直观的比较方法。
booleanisBefore():(ChronoLocalDateTime other),判断当前对象是否在目标时间之前。booleanisAfter():(ChronoLocalDateTime other),判断当前对象是否在目标时间之后。booleanisEqual():(ChronoLocalDateTime other),判断当前对象是否等于指定日期时间。
LocalDate date1 = LocalDate.of(2023, 10, 25);
LocalDate date2 = LocalDate.of(2023, 11, 1);
boolean b1 = date1.isBefore(date2); // true (date1 在 date2 之前吗?)
boolean b2 = date1.isAfter(date2); // false (date1 在 date2 之后吗?)
boolean b3 = date1.isEqual(date2); // false (date1 和 date2 相等吗?)
// 实用技巧:检查一个日期是否在某两个日期之间
boolean isBetween = date1.isAfter(startDate) && date1.isBefore(endDate);这三大类的 API 虽然繁多,但因为其链式调用和极具语义化的命名,写起来非常丝滑。
在实际业务中,由于我们常常要处理每个月的最后一天、下个星期的某一天等复杂的日历推算,Java 8 特别提供了一个专门做复杂计算的工具类 TemporalAdjusters。你需要我为你单独展开讲讲这个被称为“时间魔法棒”的高级工具吗?
LocalDate
LocalDate:专注于日历计算,不含时分秒。因此,所有关于“天数”、“闰年”的特有逻辑都在这里。
booleanisLeapYear():(),判断是否是闰年。
再也不用手写(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)这种恶心的代码了。intlengthOfMonth():(),intlengthOfYear():(),获取当前月份/年份的总天数(自动处理闰年 2 月 29 天)。Stream<LocalDate>datesUntil():(LocalDate endExclusive),JDK9。极其适合做日历打卡、统计图表横坐标的生成!
LocalDate today = LocalDate.now();
// 1. 极其优雅的闰年及天数判断
boolean isLeap = today.isLeapYear();
int daysThisMonth = today.lengthOfMonth(); // 比如返回 30, 31, 28, 29
// 2. 生成从今天起,未来 7 天的日期流 (极其适合按天生成报表横坐标)
List<LocalDate> next7Days = today.datesUntil(today.plusDays(7))
.collect(Collectors.toList());
System.out.println("未来7天: " + next7Days);LocalTime
LocalTime:专注于一天之内的时分秒,没有跨天的概念。
inttoSecondOfDay():(),longtoNanoOfDay():(),计算当前时间距离今天午夜00:00:00经过了多少秒/纳秒。LocalTimetruncatedTo():(TemporalUnit unit),截断时间精度。
在业务中,如果前端只需要精确到分钟,或者存入数据库时想抹去毫秒,这个方法是神器。(注:LocalDateTime等也有此方法,但在这里最常用)
极值常量:
LocalTime.MIN:00:00LocalTime.MAX:23:59:59.999999999LocalTime.MIDNIGHTLocalTime.NOON
LocalTime time = LocalTime.now(); // 14:35:12.876
// 1. 抹去零头:截断到分钟 (秒和纳秒全部归零)
LocalTime truncatedTime = time.truncatedTo(ChronoUnit.MINUTES);
System.out.println("截断后: " + truncatedTime); // 14:35:00
// 2. 获取当天已经过去的秒数 (例如做每日任务重置计算)
int passedSeconds = time.toSecondOfDay();
// 3. 构建今天的最后期限 (常用于拼装 SQL 的 end_time)
LocalDateTime endOfToday = LocalDate.now().atTime(LocalTime.MAX);LocalDateTime
LocalDateTime:日历与时间的完美结合体。它没有太多独特的算术方法,它最大的特长是“作为桥梁连接一切”。
它主要负责和时区打交道,将自己“升维”。
ZonedDateTimeatZone():(ZoneId zone),将本地时间赋予时区灵魂,升级为ZonedDateTime。OffsetDateTimeatOffset():(ZoneOffset offset),赋予时间偏移量(如+08:00),升级为OffsetDateTime。
LocalDateTime localDt = LocalDateTime.now(); // 机器只知道 14:30,不知道在哪
// 赋予时区灵魂:告诉系统这是北京的 14:30
ZonedDateTime beijingTime = localDt.atZone(ZoneId.of("Asia/Shanghai"));ZonedDateTime
ZonedDateTime:绝对时间点 + 地理位置(时区规则)。它是处理跨国业务的绝对主力。
ZonedDateTimewithZoneSameInstant():(ZoneId zone),(极其常用,核心桥梁) 改变时区,但保持绝对时间瞬间不变。ZonedDateTimewithZoneSameLocal():(ZoneId zone),(极少使用,非常危险) 改变时区,但强行保持钟表上的时间数字不变。
// 假设当前是北京时间 2023-10-25 20:00
ZonedDateTime beijingTime = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
// 正确的时区转换姿势:北京晚上8点,纽约是几点?(绝对时间不变)
ZonedDateTime nyTime = beijingTime.withZoneSameInstant(ZoneId.of("America/New_York"));
// 打印结果:2023-10-25T08:00[America/New_York] (纽约早上8点)
// 提取当前时区和偏移量
ZoneId zone = beijingTime.getZone(); // Asia/Shanghai
ZoneOffset offset = beijingTime.getOffset(); // +08:00Instant
Instant 在 Java 8 及之后的日期时间 API 中,扮演着取代 java.util.Date 的核心角色。它代表的是时间轴上的一个绝对瞬时点(基于 UTC 零时区),精度高达纳秒,非常适合用于记录系统时间戳、日志输出或跨国业务的绝对时间线。
对象创建
这一组 API 用于从当前系统时间、数字时间戳或字符串中获取 Instant 实例。
static InstantofEpochMilli():(long epochMilli),通过指定的毫秒级时间戳创建对象。static InstantofEpochSecond():(long epochSecond),通过指定的秒级时间戳创建对象。java// 1. 将普通的毫秒时间戳转换为 Instant long currentMillis = System.currentTimeMillis(); Instant fromMilli = Instant.ofEpochMilli(currentMillis); // 2. 将秒级时间戳转换为 Instant (常用于与外部系统对接) Instant fromSecond = Instant.ofEpochSecond(1672531200L);
提取时间戳
当我们需要将 Instant 转回基础的 long 类型数据存入数据库或进行传统的数学计算时,使用这一组 API。
longtoEpochMilli():(),将当前瞬间对象转换并提取为毫秒级时间戳(等同于 Date.getTime())。longgetEpochSecond():(),将当前瞬间对象转换并提取为秒级时间戳(去除毫秒及以下精度)。javaInstant instant = Instant.now(); // 1. 提取毫秒时间戳 (日常开发最常用) long millis = instant.toEpochMilli(); System.out.println("毫秒时间戳: " + millis); // 2. 提取秒级时间戳 long seconds = instant.getEpochSecond(); System.out.println("秒级时间戳: " + seconds);
API:ZoneId
ZoneId 是一个抽象类,它代表了一个时区的身份标识。它内部包含了时区规则(ZoneRules),这些规则决定了在历史上的某一个特定瞬间,该地区的时钟到底指在几点几分。
它主要有两个具体的实现:
基于地理区域的 ID(极度推荐):例如
"Asia/Shanghai"、"Europe/Paris"。它包含了夏令时等复杂的历史规则。固定偏移量(ZoneOffset):例如
"+08:00"。它是ZoneId的子类,代表一个死板的、永远不变的时间差。
注意事项
错把 +08:00 等同于 Asia/Shanghai:
- 很多开发者图省事,直接用固定的
+08:00来代替北京/上海时间。 - 灾难后果:中国在 1986 年到 1991 年间实行过夏令时!如果你用固定的
+08:00去解析 1988 年夏天的某个历史订单时间,算出来的时间是完全错误的。Asia/Shanghai知道这段夏令时历史,而+08:00不知道。
盲目信任 ZoneId.systemDefault():
- 容器化(Docker/K8s)时代,如果你没在 Dockerfile 里配置
TZ环境变量,你的 Java 程序默认拿到的是 UTC 时间!导致写入数据库的时间比北京时间少 8 个小时。
获取创建实例
static ZoneIdsystemDefault():(),获取运行当前 JVM 的操作系统的默认时区。static ZoneIdof():(String zoneId),通过标准的时区字符串 ID(如 "Asia/Tokyo")获取或创建一个时区实例。static ZoneIdofOffset():(String prefix, ZoneOffset offset),根据前缀(如 "UTC" 或 "GMT")和偏移量创建一个ZoneId(极少用,通常直接用ZoneOffset.of)。
// 1. 获取系统默认时区 (🚨 容器化部署时一定要确认容器的时区配置)
ZoneId defaultZone = ZoneId.systemDefault();
System.out.println("系统默认时区: " + defaultZone); // 输出如: Asia/Shanghai
// 2. 根据标准地理 ID 获取时区 (⭐ 全球化业务最常用)
ZoneId tokyoZone = ZoneId.of("Asia/Tokyo");
ZoneId nyZone = ZoneId.of("America/New_York");
// 3. 根据固定偏移量获取 (注意:它返回的其实是子类 ZoneOffset)
ZoneId offsetZone = ZoneId.of("+08:00");获取可用时区集合
探索可用的时区集合 (:getAvailableZoneIds)
如果你要做一个下拉菜单,让用户选择自己所在的时区,这个 API 是为你准备的。
static Set<String>getAvailableZoneIds():(),获取 Java 底层内置支持的所有合法时区 ID 字符串集合(通常有 600 多个)。
// 获取全球所有支持的时区 ID
Set<String> allZones = ZoneId.getAvailableZoneIds();
// 打印总数
System.out.println("支持的时区总数: " + allZones.size());
// 查找所有包含 "Asia" 的时区
allZones.stream()
.filter(z -> z.startsWith("Asia/"))
.forEach(System.out::println);
// 输出示例: Asia/Shanghai, Asia/Tokyo, Asia/Seoul...获取时区底层规则
这是高级架构师排查夏令时 Bug 时的利器。你可以探明某一个时区在某一个具体的时刻,究竟有没有实行夏令时。
ZoneRulesgetRules():(),获取该时区 ID 背后极其复杂的历史时区和夏令时演变规则。
// 探究美国纽约的时区规则
ZoneId nyZone = ZoneId.of("America/New_York");
ZoneRules rules = nyZone.getRules();
// 构建一个纽约的冬天时间 (非夏令时) 和夏天时间 (夏令时)
LocalDateTime winterTime = LocalDateTime.of(2023, 1, 15, 12, 0);
LocalDateTime summerTime = LocalDateTime.of(2023, 7, 15, 12, 0);
// 🚨 魔法发生:同一个城市,不同季节,与 UTC 的时间差居然不一样!
System.out.println("冬季偏移量: " + rules.getOffset(winterTime)); // -05:00
System.out.println("夏季偏移量: " + rules.getOffset(summerTime)); // -04:00 (夏令时拨快了1小时)
// 直接询问:这个瞬间是否处于夏令时?
boolean isDst = rules.isDaylightSavings(summerTime.atZone(nyZone).toInstant());
System.out.println("7月15日是否是夏令时: " + isDst); // true规范化 ID
ZoneIdnormalized():(),将当前时区对象规范化。如果当前的ZoneId实际上是一个固定的偏移量(且不是地理区域),它会将其转换为标准的ZoneOffset实例。
ZoneId z1 = ZoneId.of("UTC+08:00");
System.out.println(z1.getClass().getSimpleName()); // ZoneRegion
ZoneId z2 = z1.normalized();
System.out.println(z2.getClass().getSimpleName()); // ZoneOffset (被优化和规范化了)API:TemporalAdjusters
TemporalAdjusters 里面全是一堆静态工厂方法,它们返回的都是 TemporalAdjuster(时间调节器) 接口的实例。
它的底层设计是经典的策略模式(Strategy Pattern):
LocalDate 等时间对象只负责存储数据,而将“怎么把时间调整到目标状态”的算法逻辑,外包给了 TemporalAdjuster。
用法:用法极其统一:时间对象.with(调节器)
注意事项
陷阱:错用在没有日期的对象上(引发运行时异常):
- 绝大部分调节器(如
lastDayOfMonth)都是基于“日期”维度的。如果你傻乎乎地把它用在LocalTime(只包含时分秒)上,会直接抛出UnsupportedTemporalTypeException。
陷阱:分不清 next() 和 nextOrSame() 的致命差别:
- 假设今天是星期五。
- 你调用
next(DayOfWeek.FRIDAY):它会跳过今天,严格返回下周的星期五。 - 你调用
nextOrSame(DayOfWeek.FRIDAY):它发现今天刚好就是星期五,于是直接返回今天。 - 在做自动扣款或定时任务计算时,选错这两个方法会导致业务差整整一周!
月初/年末
专门用于财务报表、账单周期这种强依赖自然月/自然年的场景。
static TemporalAdjusterfirstDayOfMonth():(),当月第一天。static TemporalAdjusterlastDayOfMonth():(),当月最后一天(极其智能,自动处理 28/29/30/31 天)。static TemporalAdjusterfirstDayOfYear():(),当年的第一天。static TemporalAdjusterlastDayOfYear():(),当年最后一天。static TemporalAdjusterfirstDayOfNextMonth():(),下个月的第一天。
LocalDate today = LocalDate.of(2024, 2, 15); // 2024 是闰年
// 找月末 (不用再手写判断闰年和月份天数的逻辑了!)
LocalDate lastDay = today.with(TemporalAdjusters.lastDayOfMonth());
System.out.println("2月最后一天: " + lastDay); // 2024-02-29
// 下个月的第一天
LocalDate nextMonthFirstDay = today.with(TemporalAdjusters.firstDayOfNextMonth());相对星期
常用于计算“下个工作日”、“上个发版日”。
static TemporalAdjusternext():(DayOfWeek),严格寻找下一个指定的星期几(不含今天)。static TemporalAdjusternextOrSame():(DayOfWeek),寻找下一个指定的星期几(含今天。如果今天刚好是,就返回今天)。static TemporalAdjusterprevious():(DayOfWeek),严格寻找上一个指定的星期几。static TemporalAdjusterpreviousOrSame():(DayOfWeek),寻找上一个指定的星期几(含今天)。
// 假设今天是 2023-10-25 (星期三)
LocalDate today = LocalDate.of(2023, 10, 25);
// 找下一个星期五
LocalDate nextFriday = today.with(TemporalAdjusters.next(DayOfWeek.FRIDAY));
System.out.println("下个周五: " + nextFriday); // 2023-10-27月内星期
专治各种西方节假日(感恩节、母亲节)或特殊的公司发薪日(如“每个月最后一个周五发工资”)。
static TemporalAdjusterfirstInMonth():(DayOfWeek),当月第一个星期几。static TemporalAdjusterlastInMonth():(DayOfWeek),当月最后一个星期几。static TemporalAdjusterdayOfWeekInMonth():(int ordinal, DayOfWeek),当月第 N 个星期几。(ordinal可以是负数,代表倒数,非常强大!)
LocalDate today = LocalDate.of(2023, 5, 1);
// 1. 计算母亲节:5月的第 2 个星期日
LocalDate mothersDay = today.with(TemporalAdjusters.dayOfWeekInMonth(2, DayOfWeek.SUNDAY));
System.out.println("母亲节: " + mothersDay);
// 2. 公司发薪日:每个月最后一个工作日 (周五)
LocalDate payDay = today.with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY));
System.out.println("发薪日: " + payDay);
// 3. 神奇的负数:本月倒数第 2 个星期一
LocalDate magicalDay = today.with(TemporalAdjusters.dayOfWeekInMonth(-2, DayOfWeek.MONDAY));自定义时间调节器
TemporalAdjusters 提供的虽然多,但总有些变态业务满足不了,比如“计算下一个工作日(跳过周六周日)”。
这时候,你可以利用 Lambda 表达式自己写一个调节器!
// 计算下一个工作日
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalAdjuster;
public class CustomAdjusterExample {
// 静态定义一个自定义的调节器
public static final TemporalAdjuster NEXT_WORKING_DAY = temporal -> {
// 先把时间往后推一天
temporal = temporal.plus(1, ChronoUnit.DAYS);
// 获取星期几 (1=周一, ..., 7=周日)
int dayOfWeek = temporal.get(ChronoField.DAY_OF_WEEK);
// 如果变成了周六,再加2天跳过周末
if (dayOfWeek == 6) {
return temporal.plus(2, ChronoUnit.DAYS);
}
// 如果变成了周日,再加1天跳过周日
else if (dayOfWeek == 7) {
return temporal.plus(1, ChronoUnit.DAYS);
}
// 工作日直接返回
return temporal;
};
public static void main(String[] args) {
LocalDate friday = LocalDate.of(2023, 10, 27); // 星期五
// 见证奇迹的时刻:星期五的下一个工作日,自动跳过周末变成了星期一
LocalDate nextWorkingDay = friday.with(NEXT_WORKING_DAY);
System.out.println("下一个工作日: " + nextWorkingDay); // 2023-10-30 (周一)
}
}API:Period
Period:专门用于表示基于“日历(年月日)”的时间跨度。例如:“2 年 3 个月零 4 天”。
它的内部极其简单,只维护了三个 int 类型的变量:years(年)、months(月)、days(天)。
核心前提:因为它是基于日历的,所以它只能用于处理 LocalDate(或者包含日期的 LocalDateTime),绝对不能用于只包含时分秒的 LocalTime。
创建与获取
这是获取 Period 实例最常用的方法。
static Periodbetween():(LocalDate startDateInclusive, LocalDate endDateExclusive),最常用,计算两个日期之间的日历差。遵循“包头不包尾”(左闭右开)的原则。static Periodof():(int years, int months, int days),直接指定年月日跨度。也有ofYears,ofMonths,ofDays等单维度的快捷方法。javastatic Period ofYears(int years) // 指定年跨度 static Period ofMonths(int months) // 指定年跨度 static Period ofDays(int days) // 指定年跨度static Periodparse():(CharSequence text),解析 ISO-8601 标准的周期格式(以P开头,如P2Y3M4D代表 2 年 3 个月 4 天)。
LocalDate birthDate = LocalDate.of(1995, 5, 20);
LocalDate today = LocalDate.now(); // 假设今天是 2023-10-25
// 1. 最经典用法:计算年龄
Period age = Period.between(birthDate, today);
System.out.printf("你的年龄是:%d 岁 %d 个月 %d 天\n",
age.getYears(), age.getMonths(), age.getDays());
// 2. 直接构造一个时间跨度:例如“保质期 1年零6个月”
Period shelfLife = Period.of(1, 6, 0);
// 或者单维度构造
Period trialPeriod = Period.ofDays(15); // 15天试用期
// 3. 从标准字符串解析 (通常用于配置文件)
Period parsedPeriod = Period.parse("P1Y2M3D"); // 1年2个月3天维度提取
用于把时间跨度对象拆解,单独获取某一个维度的值。
intgetYears():(),获取年数部分。intgetMonths():(),获取月数部分。intgetDays():(),获取天数部分(切记:这不是总天数!)。
Period p = Period.of(2, 5, 10);
System.out.println("年: " + p.getYears()); // 2
System.out.println("月: " + p.getMonths()); // 5
System.out.println("天: " + p.getDays()); // 10加减乘计算
Period 作为一个“时间跨度对象”,可以直接参与数学运算。
Periodplus():(TemporalAmount amountToAdd),加上另一个跨度。Periodminus():(TemporalAmount amountToSubtract),减去另一个跨度。PeriodmultipliedBy():(int scalar),将当前跨度的年月日同时乘以一个倍数。
Period p1 = Period.ofYears(1).plusMonths(2); // 1年2个月
Period p2 = Period.ofMonths(3); // 3个月
// 跨度相加
Period sum = p1.plus(p2);
System.out.println("相加后: " + sum.getYears() + "年" + sum.getMonths() + "个月"); // 1年5个月
// 跨度相乘 (例如:分期付款3期,每期2个月,总跨度是多少?)
Period totalInstallment = Period.ofMonths(2).multipliedBy(3);
System.out.println("总跨度: " + totalInstallment.getMonths() + "个月"); // 6个月标准化
这是一个非常有意思的 API。
假设你手动创建了一个 Period.of(1, 15, 0)(1 年 15 个月)。这个表述在人类看来很别扭,正常人会说“2 年 3 个月”。
normalized() 方法就是用来做这件事的:它会将超过 12 的月份自动进位到年份。
避坑注意:normalized() 绝对不会去进位“天数”!
因为程序不知道这个月是 28 天、30 天还是 31 天。所以 Period.of(0, 1, 40) 是无法把 40 天进位成 1 个月零几天的。
// 手动造一个极其别扭的跨度:1年 15个月
Period weirdPeriod = Period.of(1, 15, 0);
// 标准化进位
Period normalPeriod = weirdPeriod.normalized();
System.out.printf("标准化后: %d 年 %d 个月\n", normalPeriod.getYears(), normalPeriod.getMonths());
// 输出结果: 标准化后: 2 年 3 个月状态判定
booleanisZero():(),判断这个时间跨度是否为零(年月日全为 0)。booleanisNegative():(),判断这个时间跨度中,是否有任何一个单位是负数。注意,只要年月日中有一个是负数,它就会返回true。
Period p1 = Period.of(0, 0, 0);
System.out.println("是否为零: " + p1.isZero()); // true
// 比如计算倒推日期时的跨度
Period p2 = Period.of(1, -2, 0); // 1年零负2个月
System.out.println("是否为负: " + p2.isNegative()); // trueAPI:Duration
Duration:专门用于表示基于“物理时间(时、分、秒、纳秒)”的时间跨度。例如:“耗时 2 小时 30 分钟”、“相差 500 毫秒”。
它的内部只存了两个数字:seconds(总秒数,long 类型)和 nanos(纳秒零头,int 类型)。
掐表与构造
static Durationbetween():(Temporal startInclusive, Temporal endExclusive),最常用,计算两个时间点(如LocalTime,LocalDateTime,Instant)的绝对耗时差。static DurationofXxx():(long amount),直接构造时长。
包括ofDays,ofHours,ofMinutes,ofSeconds,ofMillis,ofNanos。static Durationparse():(CharSequence text),解析 ISO-8601 标准字符串。
以PT开头,如PT2H30M代表 2 小时 30 分。
Instant start = Instant.now();
// ... 执行一段耗时的业务逻辑 ...
Instant end = Instant.now();
// 1. 计算代码执行耗时 (极其常用)
Duration elapsed = Duration.between(start, end);
// 2. 构造一个缓存过期时间:2 小时 30 分钟
Duration cacheTtl = Duration.ofHours(2).plusMinutes(30);
// 3. 从配置文件解析:PT15M 代表 15 分钟
Duration timeout = Duration.parse("PT15M");数据提取
这是 Duration 最核心的读取 API,一定要分清 to (转换总计) 和 to...Part (提取零头,Java 9+) 的区别。
toXxx():
(),将整个持续时间折算成指定的单位(向下取整)。Xxx:表示Days、Hours、Minutes、MillistoXxxPart():
(),JDK9,只提取格式化后该单位对应的“零头”部分。Xxx:表示Hours、Minutes、Seconds
// 构造一个 65 分钟的跨度
Duration duration = Duration.ofMinutes(65);
// --- Java 8 的折算方法 ---
System.out.println("总小时数: " + duration.toHours()); // 1
System.out.println("总分钟数: " + duration.toMinutes()); // 65
System.out.println("总毫秒数: " + duration.toMillis()); // 3900000
// --- Java 9+ 的零头提取方法 (极其适合做 UI 倒计时显示) ---
// 65 分钟 = 1 小时 5 分钟
System.out.println("小时部分: " + duration.toHoursPart()); // 1
System.out.println("分钟部分: " + duration.toMinutesPart()); // 5 (看!不再是 65 了)加减乘除
Duration 支持极其丰富的数学运算,甚至可以除以另一个 Duration!
Duration d1 = Duration.ofMinutes(10);
Duration d2 = Duration.ofSeconds(30);
// 1. 加减运算
Duration sum = d1.plus(d2); // 10分30秒
Duration diff = d1.minusMinutes(2); // 8分钟
// 2. 乘除运算
Duration doubled = d1.multipliedBy(2); // 20分钟
// 3. 跨度相除 (极其硬核:计算 d1 是 d2 的多少倍)
long ratio = d1.dividedBy(d2);
System.out.println("10分钟是30秒的多少倍: " + ratio); // 20倍状态与极值
常用于判断超时、或者比较两个时间谁先谁后。
isNegative():是否为负数(意味着 end 时间在 start 之前)。isZero():是否为 0 秒。abs():获取绝对值。如果不确定两个时间的先后顺序,但只关心绝对的时间差,用这个!
LocalTime time1 = LocalTime.of(10, 0);
LocalTime time2 = LocalTime.of(9, 0);
// time1 到 time2,时间是倒流的,所以跨度为负
Duration d = Duration.between(time1, time2);
System.out.println("是否为负: " + d.isNegative()); // true
// 获取绝对的时间差 (1 小时)
Duration absoluteDiff = d.abs();
System.out.println("绝对相差小时: " + absoluteDiff.toHours()); // 1API:DateTimeFormatter
线程安全的格式化:DateTimeFormatter
彻底告别 SimpleDateFormat!DateTimeFormatter 是绝对线程安全的,可以直接定义为 public static final 的常量供全局使用。
内置格式:
ISO_LOCAL_DATE:格式如2023-10-25ISO_LOCAL_TIME:格式如14:30:15.123ISO_LOCAL_DATE_TIME:格式如2023-10-25T14:30:15(带 T 分隔符)BASIC_ISO_DATE:格式如20231025(没有任何分隔符,极其适合做批次号或文件名)
常用方法:
static DateTimeFormatterofPattern():(String pattern),使用系统默认语言环境创建格式器。static DateTimeFormatterofPattern():(String pattern, Locale locale),极其重要!,
如果你要解析含有英文单词(如 "Oct", "Monday")的时间字符串,必须指定Locale.US或Locale.ENGLISH,否则在中文操作系统下会直接报错。
import java.time.format.DateTimeFormatter;
public class TimeUtil {
// 线程安全,随便并发调用!
public static final DateTimeFormatter STANDARD_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
LocalDateTime now = LocalDateTime.now();
// 1. 格式化 (对象 -> 字符串)
String str = now.format(STANDARD_FORMAT);
System.out.println(str);
// 2. 解析 (字符串 -> 对象)
String input = "2023-10-25 14:30:00";
LocalDateTime parsed = LocalDateTime.parse(input, STANDARD_FORMAT);
// 3. 🚨 解析带英文单词的特殊格式 (务必带上 Locale)
String englishDate = "25-Oct-2023 14:30";
DateTimeFormatter engFmt = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm", Locale.ENGLISH);
LocalDateTime dt = LocalDateTime.parse(englishDate, engFmt);
}
}