在 Java 8 之前,我们使用 Date、Calender 和 SimpleDateFormat 来声明时间戳、使用日历处理日期和格式化解析日期时间。但这些类的 API 可读性差、易用性差、使用起来冗余繁琐,还有线程安全问题。因此 Java 8 推出了新的日期时间类,每一个类功能明确清晰、类之间协作简单、API 定义清晰不踩坑,API 功能强大无需借助外部工具类即可完成操作,并且线程安全。下图中展示了 Java 8 前后的日期时间类型,图中箭头代表的是新老类型在概念上等价的类型:
时间戳
1. Instant
在 Java 中,Instant 表示时间线上的某个点,通过调用 Instant.now() 会得到当前的时刻。可以把 Instant 对象用作时间戳,通过 equals 和 compareTo 方法来比较两个 Instant 对象。为了得到两个时刻之间的时间差,还可以使用静态方法 Duration.between()。
Instant start = Instant.now();
// logic
Instant end = Instant.now();
Duration duration = Duration.between(start, end);
long cost = duration.toMillis();
Instant 常用方法如下:
// 从最佳的可用系统时钟中获取当前时刻
public static Instant now()
// 产生一个时刻,该时刻与当前时刻距离给定的时间量。Duration和Period实现了TemporalAmount接口
public Instant plus(TemporalAmount amountToAdd)
public Instant minus(TemporalAmount amountToSubtract)
public Instant plus(long amountToAdd, TemporalUnit unit)
public Instant minus(long amountToSubtract, TemporalUnit unit)
Instant 可以对比之前的 Date 类,Date 也不包含时区信息,世界上任何一台计算机使用 new Date() 初始化得到的时间都一样。因为 Date 中保存的是一个时间戳,表示从 1970 年 1 月 1 日 0 点(Epoch 时间)到现在的毫秒数。因此不同时区的人转换 Date 会得到不同的时间戳结果:
String stringDate = "2020-01-02 22:00:00";
SimpleDateFormat inputFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 默认时区解析时间表示
Date date1 = inputFormat.parse(stringDate);
System.out.println(date1 + ":" + date1.getTime()); // Thu Jan 02 22:00:00 CST 2020:1577973600000
// 纽约时区解析时间表示
inputFormat.setTimeZone(TimeZone.getTimeZone("America/New_York"));
Date date2 = inputFormat.parse(stringDate);
System.out.println(date2 + ":" + date2.getTime()); // Fri Jan 03 11:00:00 CST 2020:1578020400000
虽然我们调用 Date 对象的 toString 方法会打印出时区信息,实际上,显示的时区仅仅用于呈现,并不代表 Date 类内置了时区信息。
2. Duration
Duration 是两个时刻之间的时间量,可以通过调用 toNanos、toMillis、getSeconds、toMinutes、toHours 和 toDays 来获得 Duration 按照传统单位度量的时间长度。主要方法如下:
// 产生一个给定数量的指定时间单位的时间间隔
public static Duration of(long amount, TemporalUnit unit)
// 产生一个在给定时间点之间的Duration对象。Instant、LocalDatel、LocalTime、LocalDateTime、ZonedDateTime类实现了Temporal接口
public static Duration between(Temporal startInclusive, Temporal endExclusive)
// 产生一个时刻,该时刻与当前时刻距离给定的时间量。Duration和Period类实现了TemporalAmount接口
public Duration minus(Duration duration)
public Duration plus(Duration duration)
public Duration minus(long amountToSubtract, TemporalUnit unit)
public Duration plus(long amountToAdd, TemporalUnit unit)
注意 Instant 和 Duration 类都是不可修改的类,对其进行加、减操作都会返回一个新的实例。
本地日期
在 Java API 中有两种人类时间,本地日期/时间和时区时间。本地日期/时间包含日期和当天的时间,但是与时区信息没有任何关联。比如 1903 年 6月 14 日就是一个本地日期,但由于这个日期既没有当天的时间,也没有时区信息,因此它并不对应精确的时刻。与之相反的是,1969 年 7 月 16 日 09:32:00 EDT 是一个带时区日期/时间,表示的是时间线上的一个精确的时刻。
1. LocalDate
LocalDate 是带有年、月、日的日期。为了构建 LocalDate 对象,可以使用 now 或 of 静态方法:
LocalDate today = LocalDate.now();
today = LocalDate.of(2021, 10, 5);
today = LocalDate.of(2021, Month.SEPTEMBER, 5);
与 UNIX 和 java.util.Date 中使用的月从 0 开始计算而年从 1900 开始计算的不规则的习惯用法不同,你需要提供通常使用的年、月份的数字,或者使用 Month 枚举。还可以使用各种 minus 和 plus 方法直接对日期进行加减操作,如下代码实现了减一天和加一天,以及减一个月和加一个月:
LocalDate.now()
.minus(Period.ofDays(1))
.plus(1, ChronoUnit.DAYS)
.minusMonths(1)
.plus(Period.ofMonths(1));
除 LocalDate 外,还有 MonthDay、YearMonth 和 Year 类可以描述部分日期。例如 12 月 25 日没有指定年份的话,就可以表示成一个 MonthDay 对象。
2. Period
前面讲到两个 Instant 之间的时长是 Duration,而用于本地日期的等价物是 Period,其用法和 Duration 差不多。Period 定义了日期间隔,通过 Period.between 能够得到两个 LocalDate 的差,返回的是两个日期差几年零几月零几天。
如果希望得知两个日期之间差几天,直接调用 Period 的 getDays() 方法得到的只是最后的“零几天”,而不是算总的间隔天数。比如,计算 2019 年 12 月 12 日和 2019 年 10 月 1 日的日期间隔,很明显日期差是 2 个月零 11 天,但获取 getDays 方法得到的结果只是 11 天,而不是 72 天,可以使用 ChronoUnit.DAYS.between 解决这个问题:
System.out.println("//计算日期差");
LocalDate today = LocalDate.of(2019, 12, 12);
LocalDate specifyDate = LocalDate.of(2019, 10, 1);
System.out.println(Period.between(specifyDate, today).getDays());
System.out.println(ChronoUnit.DAYS.between(specifyDate, today));
// 输出结果
11
72
3. TemporalAdjusters
对于日程安排应用来说,经常需要计算诸如 “每个月的第一个星期二” 这样的日期。TemporalAdjusters 类提供了大量用于常见调整的静态方法。你可以将调整方法的结果传递给 with 方法进行快捷日期调节,示例如下:
//本月的第一天
LocalDate.now().with(TemporalAdjusters.firstDayOfMonth());
//今年的程序员日
LocalDate.now().with(TemporalAdjusters.firstDayOfYear()).plusDays(255);
//今天之前的一个周六
LocalDate.now().with(TemporalAdjusters.previous(DayOfWeek.SATURDAY));
//本月最后一个工作日
LocalDate.now().with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY));
一如既往,with 方法会返回一个新的 LocalDate 对象,而不会修改原来的对象。我们还可通过 ofDateAdjuster 方法以 Lambda 方式实现 TemporalAdjuster 接口来创建自己的调整器,比如,为当前时间增加 100 天内的随机天数:
LocalDate.now().with(temporal -> temporal.plus(ThreadLocalRandom.current().nextInt(100), ChronoUnit.DAYS));
常用方法如下:
// 返回一个调整器,用于将日期调整为给定的星期日期
public static TemporalAdjuster next(DayOfWeek dayOfWeek)
public static TemporalAdjuster nextOrSame(DayOfWeek dayOfWeek)
public static TemporalAdjuster previous(DayOfWeek dayOfWeek)
public static TemporalAdjuster previousOrSame(DayOfWeek dayOfWeek)
// 返回一个调整器,用于将日期调整为月份中第一个、最后一个或第n个给定的星期日期
public static TemporalAdjuster firstInMonth(DayOfWeek dayOfWeek)
public static TemporalAdjuster lastInMonth(DayOfWeek dayOfWeek)
public static TemporalAdjuster dayOfWeekInMonth(int ordinal, DayOfWeek dayOfWeek)
本地时间
1. LocalTime
LocalTime 表示当日时刻,例如 15:30:00。同 LocalDate 一样可以使用 now 或 of 方法创建其实例:
LocalTime now = LocalTime.now();
now = LocalTime.of(20, 30, 48);
2. LocalDateTime
还有一个表示日期和时间的 LocalDateTime 类,包含了 LocalDate 和 LocalTime 的信息。同样也没有时区的概念,只是一个日期时间的表示。
当把 Date 转换为 LocalDateTime 时,需要通过 Date 的 toInstant 方法得到一个 UTC 时间戳进行转换,并需要提供当前的时区,这样才能把 UTC 时间转换为本地日期时间。相反,把 LocalDateTime 转换为 Date 时也需要提供时区,用于指定是哪个时区的时间表示,也就是先通过 atZone 方法把 LocalDateTime 转换为 ZonedDateTime,然后才能获得 UTC 时间戳:
Date in = new Date();
// Date to LocalDateTime
LocalDateTime ldt = LocalDateTime.ofInstant(in.toInstant(), ZoneId.systemDefault());
// LocalDateTime to Date
Date out = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
时区时间
1. Zoneld
每个时区都有一个 ID,例如 America/New_York 和 Europe/Berlino。可通过 ZoneId.getAvailableZoneIds 获取所有可用的时区,当给定一个时区 ID,静态方法 Zoneld.of(id) 可以产生一个 Zoneld 对象,也可以使用 ZoneOffset.ofHours 通过一个 offset 来初始化一个具有指定时间差的自定义时区。
ZoneId timeZoneSH = ZoneId.of("Asia/Shanghai");
ZoneId timeZoneNY = ZoneId.of("America/New_York");
ZoneId timeZoneJST = ZoneOffset.ofHours(9);
2. ZonedDateTime
对于日期时间表示,LocalDateTime 是不带时区属性的,所以命名为本地日期时间;而 ZonedDateTime 相当于是 LocalDateTime 和 ZoneId 的合体,是具有时区属性的日期时间。因此,LocalDateTime 只能认为是一个时间表示,而 ZonedDateTime 才是一个有效的时间。
我们可以通过调用 local.atZone(zoneld) 用这个对象将 LocalDateTime 对象转换为 ZonedDateTime 对象,或者可以通过调用静态方法 ZonedDateTime.of 来构造对象。例如:
ZonedDateTime nowTime = LocalDateTime.now().atZone(ZoneId.of("Asia/Shanghai"));
nowTime = Instant.now().atZone(ZoneId.of("Asia/Shanghai"));
nowTime = ZonedDateTime.of(LocalDateTime.now(), ZoneId.of("Asia/Shanghai"));
格式化
1. DateTimeFormatter
DateTimeFormatter 类提供了三种用于打印日期/时间值的格式器:
- 预定义的标准格式器
- locale 相关的格式器
- 指定模式的格式器
预定义的标准格式器是 DateTimeFormatter 内置的静态属性,要使用标准的格式器,可以直接调用其 format 方法进行解析:
DateTimeFormatter.ISO_DATE_TIME.format(LocalDateTime.now())
标准格式器主要是为了机器可读的时间戳而设计的。为了向人类读者表示日期和时间,可以使用 locale 相关的格式器。对于日期和时间而言,有 4 种与 locale 相关的格式化风格,具体如下表所示:
风格 | 日期 | 时间 |
---|---|---|
SHORT | 7/16/69 | 9:32 AM |
MEDIUM | Jul 16, 1969 | 9:32:00 AM |
LONG | July 16, 1969 | 9:32:00 AM EDT |
FULL | Wednesday, July 16, 1969 | 9:32:00 AM EDT |
静态方法 ofLocalizedDate、ofLocalizedTime 和 ofLocalizedDateTime 可以创建这种格式器,例如:
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).format(LocalDateTime.now());
最后,可以通过 ofPattern 方法指定模式来定制自己的日期格式。具体格式如下表所示,其中,每个字母都表示一个不同的时间域,而字母重复的次数对应于所选择的特定格式。
时间域或目的 | 示例 |
---|---|
YEAR_OF_ERA | yy:69、yyyy:1969 |
MONTH_OF_YEAR | M:7、MM:07、MMM:Jul、MMMM:July |
DAY_OF_MONTH | d:6、dd:06 |
DAY_OF_WEEK | e:3、E:Wed、EEEE:Wednesday |
HOUR_OF_DAY | H:9、HH:09 |
CLOCK_HOUR_OF_AM_PM | K:9、KK:09 |
AMPM_OF_DAY | a:AM |
MINUTE_OF_HOUR | mm:02 |
SECOND_OF_MINUTE | ss:00 |
NANO_OF_SECOND | nnnnnn:000000 |
时区ID | W:America/New_York |
时区名 | z:EDT、zzzz:Eastern Daylight Time |
使用示例如下:
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
System.out.println(formatter.format(LocalDateTime.now()));
2. SimpleDateFormat 的坑
当需要解析的字符串和格式不匹配的时候,SimpleDateFormat 表现得很宽容,还是能得到结果。比如,我们期望使用 yyyyMM 来解析 20160901 字符串:
String dateString = "20160901";
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMM");
System.out.println("result:" + dateFormat.parse(dateString));
居然输出了 2091 年 1 月 1 日,原因是把 0901 当成了月份,相当于 75 年:
result:Mon Jan 01 00:00:00 CST 2091
而我们使用 Java 8 中的 DateTimeFormatter 就可以避免。首先,使用 DateTimeFormatterBuilder 来定义格式化字符串,不用去记忆使用大写的 Y 还是小写的 Y,大写的 M 还是小写的 m:
private static DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder()
.appendValue(ChronoField.YEAR) //年
.appendLiteral("/")
.appendValue(ChronoField.MONTH_OF_YEAR) //月
.appendLiteral("/")
.appendValue(ChronoField.DAY_OF_MONTH) //日
.appendLiteral(" ")
.appendValue(ChronoField.HOUR_OF_DAY) //时
.appendLiteral(":")
.appendValue(ChronoField.MINUTE_OF_HOUR) //分
.appendLiteral(":")
.appendValue(ChronoField.SECOND_OF_MINUTE) //秒
.appendLiteral(".")
.appendValue(ChronoField.MILLI_OF_SECOND) //毫秒
.toFormatter();
其次,DateTimeFormatter 是线程安全的,可以定义为 static 使用;最后 DateTimeFormatter 的解析比较严格,需要解析的字符串和格式不匹配时,会直接报错,而不会把 0901 解析为月份。