Java8 的时间库(1):介绍 Java8 中的时间类及常用 API

该图片由Önder ÖrtelPixabay上发布

你好,我是看山。

年龄大的 Java 程序员都有体会,Java8 之前,Java 提供了一组时间类:java.util.Datejava.util.Calendar及其子类和工具类等。功能比较全面,最大的缺点是难用。所以很多团队直接放弃原生时间类,使用第三方的时间类库。后来,Java8 吸收了 joda-time 的优秀设计,提供了一组新的时间处理 APIjava.time.

本文作为这个系列的第一篇,扒一扒DateCalendar存在的问题,说说 Java8 提供的时间库是怎么解决这些问题的。后续再说一下 Java8 中java.time.包中的一些核心类,例如LocalDateLocalTimeLocalDateTimeZonedDateTimePeriodDuration

老时间 API 存在的问题

  1. 线程安全性:老的时间 API 是非线程安全的,而我们的代码都是运行在并发环境下,这样就不得不处理难以调试的并发问题,而且还需要额外的代码处理线程安全。一不小心,就会碰到一些比较诡异的错误,本地还不容易复现,比如定义了一个公用的SimpleDateFormt去操作时间,偶尔并发时会出错,只能通过迂回的办法实现(比如借助ThreadLocal)。在 Java8 中引入的新时间 API 是不可变对象,天然保证了线程安全。
  2. API 设计和可理解性:老的时间 API 在这设计上没有一致的模型,而且日常操作功能不全。而且有一些比较让人诟病的设计,比如Date类的构造方法public Date(int year, int month, int date),其中month字段取值是0-11,也就是按照计算机的 0 作为第一个数,但是day没有这种情况,取值是1-31,很容易搞错。新 API 以 ISO 为中心,遵循日期、时间、持续时间、时间间隔等一致的域模型,而且为时间 API 增加很多实用的工具方法。
  3. 时区处理:老的时间 API 在处理时区逻辑时比较繁琐,但是在新的 API 中,只需要通过Local*Zoned*等类处理时区即可。

下面就开始实际上手 Java8 的时间 API 了。

LocalDate、LocalTime、LocalDateTime

这三个是最常用的几个时间类了,根据名字可以知道,这三个类是默认使用当前机器上的时区作为参考系的时间对象。也就是说,在不需要显示指定时区时,我们就可以使用这几个类。

LocalDate

LocalDate是 ISO 格式(yyyy-MM-dd)的日期,没有时分秒的时间数据。我们可以用它表示生日、放假等只关心日期的数据。通过now方法创建当前日期实例:

LocalDate localDate = LocalDate.now();

这里是使用机器时间创建的。

我们还可以使用of方法或parse方法获取指定日期的LocalDate实例,比如,我们想要时间为 2021 年 6 月 11 号:

LocalDate.of(2021, 6, 11);
LocalDate.parse("2021-06-11");

LocalDate还提供了各种实用方法来获取时间信息,接下来快速浏览一下这些 API 方法。

  1. 获取明天的日期,即当前日期加一天:
LocalDate tomorrow = LocalDate.now().plusDays(1);
  1. 获取上个月的今天,即当前日期并减去一个月(我们可以使用枚举单位操作数据):
LocalDate previousMonthSameDay = LocalDate.now().minus(1, ChronoUnit.MONTHS);`
  1. 解析日期“2021-06-11”,并获取周几(结果DayOfWeek是一个枚举类,设计很周到):
DayOfWeek friday = LocalDate.parse("2021-06-11").getDayOfWeek();
  1. 解析日期“2021-06-11”,并获取几号:
int eleven = LocalDate.parse("2021-06-11").getDayOfMonth();
  1. 检查一个日期的年份是否是闰年:
boolean leapYear = LocalDate.now().isLeapYear();
  1. 判断一个日期是否在另外一个日期之前:
boolean isBefore = LocalDate.parse("2021-06-11").isBefore(LocalDate.parse("2021-06-12"));
  1. 判断一个日期是否在另外一个日期之后:
boolean isAfter = LocalDate.parse("2021-06-12").isAfter(LocalDate.parse("2021-06-11"));
  1. 获取给定日期的当天开始时间,比如给定“2021-06-11”,想要获取“2021-06-11T00:00”(也就是当天的 0 点时间):
LocalDateTime beginningOfDay = LocalDate.parse("2021-06-11").atStartOfDay();
  1. 获取给定日期所在月份的的第一天,比如给定“2021-06-11”,想要获取“2021-06-01”:
LocalDate firstDayOfMonth = LocalDate.parse("2021-06-11").with(TemporalAdjusters.firstDayOfMonth());

LocalTime

LocalDate是 ISO 格式(yyyy-MM-dd)的日期,没有时分秒的时间数据。我们可以用它表示生日、放假等只关心日期的数据。通过now方法创建当前日期实例:

LocalTime提供的是没有日期数据的时间,只有时分秒数据。这个类很多方法与LocalDate类似,所以我们快速过一下这些 API 方法。

  1. 创建当前时间
LocalTime now = LocalTime.now();
  1. 解析给定的字符串时间,可以使用ofparse方法,比如下午 4:30:
LocalTime sixty30 = LocalTime.parse("16:30");
LocalTime sixty31 = LocalTime.of(16, 31);
  1. 解析给定字符串时间,并获取一小时后的时间实例:
LocalTime seventy30 = LocalTime.parse("16:30").plus(1, ChronoUnit.HOURS);
  1. 获取给定字符串时间的小时数:
int sixty = LocalTime.parse("16:30").getHour();
  1. 判断一个时间是否在另外一个时间之前:
boolean isBeforeTime = LocalTime.parse("16:30").isBefore(LocalTime.parse("17:30"));
  1. 判断一个时间是否在另外一个时间之后:
boolean isAfterTime = LocalTime.parse("17:30").isAfter(LocalTime.parse("16:30"));
  1. 常用的时间常量:
LocalTime.MIN;// 00:00
LocalTime.NOON;// 12:00
LocalTime.MAX;// 23:59:59.999999999
LocalTime.MIDNIGHT;// 00:00

LocalDateTime

顾名思义,LocalDateTime表示日期和时间的组合。有了前两个类做铺垫,这个类也是很类似的操作。

  1. 创建当前时间
LocalDateTime.now();
  1. 解析给定的字符串时间,可以使用ofparse方法,比如 2021 年 6 月 11 日 16 点 30 分:
LocalDateTime.of(2021, Month.JUNE, 11, 16, 30);
LocalDateTime.parse("2021-06-11T16:30:00");// 注意这个时间格式,这种写法属于 UTC 时间格式,后续再开文说一下时间格式的话题。

其他与LocalDateLocalTime类似的 API,比如plusDaysMinsHoursgetMonth等。我们可以把LocalDateTime理解为LocalDateLocalTime的合体。

LocalDateLocalTimeLocalDateTime处理的都是当前系统所在时区的日期时间数据,有时候我们还需要处理特定时区的日期和时间,Java8 提供了ZonedDateTime,接下来我们说说这个类。

ZonedDateTime

ZonedDateTime的使用需要配合ZoneIdZoneId表示不同区域的标识符,在${JAVA_HOME}/lib/tzdb.dat文件中存放了默认的区域标识符,如果没有特别定义,需要是文件中指定的数据才能获取到ZoneId实例。

我们来创建我天朝的区域:ZoneId zoneId = ZoneId.of("Asia/Shanghai");

如果不知道有哪些区域,可以通过Set<String> allZoneIds = ZoneId.getAvailableZoneIds();获取,我当前 jdk 版本是 jdk1.8.0_202,一共有 599 个区域标识。

我们对比我朝与漂亮国的时间,获取当前时间:

ZonedDateTime.now();// 2021-06-11T17:49:53.400+08:00[Asia/Shanghai]
ZonedDateTime.now(ZoneId.systemDefault());// 2021-06-11T17:49:53.400+08:00[Asia/Shanghai]
ZonedDateTime.now(ZoneId.of("America/New_York"));// 2021-06-11T05:49:53.400-04:00[America/New_York]

可以看到,now方法不传参数与使用当前时区参数结果一致,使用漂亮国时区时,在小时上有区别,但是两个时间,都是指当前时间。我们可以通过指定ZoneId获取不同时区的结果:

final ZonedDateTime newyorkZonedDateTime = ZonedDateTime.now(ZoneId.of("America/New_York"));// 2021-06-11T05:57:00.655-04:00[America/New_York]
final ZonedDateTime shanghaiZonedDateTime = newyorkZonedDateTime.withZoneSameInstant(ZoneId.of("Asia/Shanghai"));// 2021-06-11T17:57:00.655+08:00[Asia/Shanghai]
final ZonedDateTime shanghaiZonedDateTime2 = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));// 2021-06-11T17:58:34.221+08:00[Asia/Shanghai]

可以看到,我们直接获取北京时间与通过漂亮国时间转换为北京时间,结果是一样的。

这里有一点需要提醒,这点是我朝程序员容易忽略的。那就是夏令时,我国没有夏令时,但是国外有些国家使用夏令时。所以在处理时间的时候,我们最好不要通过手动加减时区差来计算时间,这样很容易忽略夏令时。

Period、Duration

这两个类都是表示时间量,也就是时间段。不过,Period类以年、月、日这种比较大的单位表示时间量,Duration类以秒、纳秒这种相对较小的单位表示时间量。

Period

Period表示的单位是年或月或日这种相对大一些的单位。我们可以用它来增减时间,或者计算两个时间间的时间差。

比如,我们以 2021 年 6 月 15 日为基准,计算 5 天后的日期:

LocalDate initialDate = LocalDate.parse("2021-06-15");
LocalDate finalDate = initialDate.plus(Period.ofDays(5));// 2021-06-20

Period提供了ofYearsofMonthsofWeeksofDaysof等方法,可以随情况处理时间。

除了锚定的特定时间,Period还可以计算两个日期之间的时间差。比如:

LocalDate initialDate = LocalDate.parse("2021-06-15");
final LocalDate newDate = initialDate.plus(Period.of(1, 2, 3));
final Period period = Period.between(initialDate, newDate);
int years = period.getYears();// 1
int months = period.getMonths();// 2
int days = period.getDays();// 3

这里需要注意一下,这三个方法是两个单位同单位的差,不会进行换算。我们可以借助ChronoUnit实现单位换算状态下的结果:

long allYears = ChronoUnit.YEARS.between(initialDate, newDate);// 1
long allMonths = ChronoUnit.MONTHS.between(initialDate, newDate);// 14
long allDays = ChronoUnit.DAYS.between(initialDate, newDate);// 429

ChronoUnit是借助Duration实现的,所以最细粒度可以到纳秒。

Duration

Duration可以表示的单位是天、小时、分、秒、毫秒、纳秒,其内部结果是通过秒、纳秒进行存储的,其他可表示的单位,都是通过这两个单位组合实现的。比如,一分钟等于 3600 秒,那内部存储就是 3600 秒 0 纳秒;1 毫秒等于 1000000 纳秒,内部存储就是 0 秒 1000000 纳秒。

Duration用法与Period类似。比如,我们给 21 点 03 分 15 秒加 30 秒:

LocalTime initialTime = LocalTime.parse("21:02:15");
LocalTime finalTime = initialTime.plus(Duration.ofSeconds(30));// 21:02:45

Duration也可以计算两个时间之间的时间差,只是单位较小一些,比如:

LocalTime initialTime = LocalTime.parse("21:02:15");
LocalTime newTime = LocalTime.of(21, 02, 20);
Duration duration = Duration.between(initialTime, newTime);
long seconds = duration.getSeconds();// 5
int nano = duration.getNano();// 0

同样的,我们可以借助ChronoUnit实现更多单位的时间差,这里不做赘述。

从老时间 API 创建

Java8 提供了toInstant()方法,可以将老时间 API 的DateCalendar转换为新的对象:

Date date = new Date();
LocalDateTime localDateTime = LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());
Calendar calendar = GregorianCalendar.getInstance();
LocalDateTime localDateTime1 = LocalDateTime.ofInstant(calendar.toInstant(), ZoneId.systemDefault());

时间格式化

Java8 提供了易于使用的时间格式化 API,这里简单说下。比如:

LocalDateTime localDateTime = LocalDateTime.of(2021, Month.JUNE, 15, 21, 23);
String localDateString = localDateTime.format(DateTimeFormatter.ISO_DATE);// 2021-06-15

与老时间 API 一样,我们可以指定格式:

localDateTime.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));

文末总结

从 Java7 到 Java8,提供了很多特性,除了 Lambda 表达式,时间 API 绝对也是良心功能了。而且从发布到现在已经过去这么多年,我们还是要与时俱进,逐渐使用优秀的 API 替换老 API 了。如果还在用 Java7 或者 Java6,然后还想使用 Java8 这种时间 API,这里推荐两个第三方库:threetenbpjoda-time

参考

推荐阅读

  1. Java8 的时间库(1):介绍 Java8 中的时间类及常用 API
  2. 未完待续。..

你好,我是看山,10 年老猿,开源贡献者。游于码界,戏享人生。关注公众号:看山的小屋,领取学习资料。

个人主页:https://www.howardliu.cn
个人博文:Java8 的时间库(1):介绍 Java8 中的时间类及常用 API
CSDN 主页:http://blog.csdn.net/liuxinghao
CSDN 博文:Java8 的时间库(1):介绍 Java8 中的时间类及常用 API

公众号:看山的小屋