在 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();// logicInstant 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));// 输出结果1172
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 LocalDateTimeLocalDateTime ldt = LocalDateTime.ofInstant(in.toInstant(), ZoneId.systemDefault());// LocalDateTime to DateDate 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 解析为月份。
