Java8 时间 API

时间:2022-12-01 06:18:20

前言

Java8 中最为人津津乐道的新改变恐怕当属函数式 API 的加入。但实际上,Java8 所加入的新功能远不止这个。 本文将基于《Java SE8 for the Really Impatient》的第 5 章,归纳一下 Java8 加入的位于java.time包下的日期和时间 API。


时间点与时间间隔

在我们常说的四维空间体系中,时间轴往往作为除长宽高三轴以外的第四轴。时间轴由无穷多个时间点组成,而两个时间点之间的距离组成一个时间间隔。相较于我们常说的日期、时间,时间点本身所携带的信息是很少的,不会携带如时区等冗余的信息。作为时间轴上的一个点,我们可以将它称为绝对时间。

Java8 引入了 Instant 类(瞬时)来表示时间轴上的一个点。Instant 的构造方法是 private 的,我们只能通过调用它的静态工厂方法来产生一个 Instant 实例。其中最常用的是 Instant.now() 方法,返回当前的时间点。Instant 类也实现了 comparesTo 和 equals 方法来对比两个瞬时点。

通过调用 Duration.between() 方法我们便可以计算两个时间点之间的时间间隔:

1
2
3
4
5
6
7
8
Instant start = Instant.now();
 
runAlgorithm();
 
Instant end = Instant.now();
 
Duration timeElapsed = Duration.between(start, end);
long millis = timeElapsed.toMillis();

between 方法返回一个 Duration 实例。Duration 内部以 long 成员来存储时间间隔信息,最小单位可去到纳秒,同时提供了如 toMillistoSeconds 等方法。

Instant 和 Duration 类常用的方法包括如下:

方法 描述
plusminus 对当前 Instant 或 Duration 增加或减少一段时间
plusNanosplusMillisplusSeconds
plusMinutesplusHoursplusDays
根据指定的时间单位,对当前 Instant 或者 Duration 添加一段时间。
minusNanosminusMillisminusSeconds
minusMinutesminusHoursminusDays
根据指定的时间单位,对当前 Instant 或者 Duration 减少一段时间。
multipliedBydividedBynegated 返回当前 Duration 与指定 long 值相乘或相除得到的时间间隔
isZeroisNegative 检查 Duration 是否为0或负数

注意:Instant 类和 Duration 类都是不可变的,上述方法都会返回一个新的实例。


本地日期

在新的时间 API 中,Java 提供了两种时间格式:不带时区信息的本地时间和带时区的时间。本地日期表示一个日期,而本地时间还包含(一天中的)时间,但它们都不包含任何有关时区的信息。 例如,June 14, 1903 就是一个本地日期。由于日期不含一天中的时间,也不含时区信息,所以它无法与一个准确的瞬时点对应。相反,July 16, 1969, 09:32:00 EDT 就是一个带时区的时间, 它表示了时间轴上准确的一点。但有很多计算是不需要考虑时区的,在某些情况下考虑时区甚至可能导致错误的结果。出于此原因,API 设计者们更推荐使用不带时区的时间,除非你真的需要这个时区信息。

LocalDate就是一个不带时区的本地日期:它只带有年份、月份和当月的天数。你可以通过LocalDate的静态工厂方法nowof来创建一个实例:

1
2
LocalDate alonzosBirthday = LocalDate.of(1903, 6, 14);
alonzosBirthday = LocalDate.of(1903, Month.JUNE, 14);

这里我们看到,静态工厂方法中指示月份的数字是以1开始的,因此6就代表着六月。如果你实在是太喜欢以0开始,无法接受这种设定,你也可以使用枚举类型Month来指定月份。

下表中列出了LocalDate对象的一些常用方法。详细的方法说明请参考LocalDate的 JavaDoc

方法 描述
nowof 静态工厂方法,可以根据当前时间或指定的年月日来创建一个LocalDate对象
plusDaysplusWeeks
plusMonthsplusYears
返回在当前LocalDate的基础上加上几天、几周、几个月或者几年后的新的LocalDate对象,原有的LocalDate对象保持不变
minusDaysminusWeeks
minusMonthsminusYears
返回在当前LocalDate的基础上减去几天、几周、几个月或者几年后的新的LocalDate对象,原有的LocalDate对象保持不变
plusminus 返回在当前LocalDate的基础上加上或减去一个Duration或者Period的新的LocalDate对象,原有的LocalDate对象保持不变
withDayOfMonthwithDayOfYear
withMonthwithYear
返回一个月份天数、年份天数、月份、年份修改为指定的值的新的LocalDate对象,原有的LocalDate对象保持不变
getDayOfMonth 获取月份天数(在 [1,31][1,31] 之间)
getDayOfYear 获取年份天数(在 [1,366][1,366] 之间)
getDayOfWeek 获取星期几(返回一个DayOfWeek枚举值)
getMonthgetMonthValue 获取月份,返回一个Month枚举的值,或者是 [1,12][1,12] 之间的一个数字
getYear 获取年份,在 [−999999999,999999999][−999999999,999999999] 之间
until 获取两个日期之间的Period对象,或者以指定ChronoUnits为单位的数值
isBeforeisAfter 比较两个LocalDate
isLeapYear 是否为闰年

注意:LocalDate 类是不可变的,上述方法都会返回一个新的实例。

在上一节中我们提到,两个瞬时点Instant之间的是一个持续时间Duration。对于本地时间,对应的对象就是时段Period, 它表示一段逝去的年月日。


本地时间

LocalTime代表一天中的某个时间,例如下午3点30分。 同样,你可以通过LocalTime的静态工厂方法nowof来创建一个实例。

1
2
LocalTime rightNow = LocalTime.now();
LocalTime bedtime= LocalTime.of(22, 30)

下表中列出了LocalTime对象的一些常用方法。详细的方法说明请参考LocalTime的 JavaDoc

方法 描述
nowof 静态工厂方法,可以根据当前时间或指定的时分秒来创建一个LocalTime对象
plusHoursplusMinutes
plusSecondsplusNanos
返回在当前LocalTime的基础上加上几小时、几分钟、几秒或者几纳秒后的新的LocalTime对象,原有的LocalTime对象保持不变
minusHoursminusMinutes
minusSecondsminusNanos
返回在当前LocalTime的基础上减去几小时、几分钟、几秒或者几纳秒后的新的LocalTime对象,原有的LocalTime对象保持不变
plusminus 返回在当前LocalTime的基础上加上或减去一个Duration的新的LocalTime对象,原有的LocalTime对象保持不变
withHourwithMinute
withSecondwithNano
返回一个小时数、分钟数、秒数、纳秒数修改为指定的值的新的LocalTime对象,原有的LocalTime对象保持不变
getHourgetMinute
getSecondgetNano
返回该LocalTime的小时、分钟、秒钟及纳秒值
isBeforeisAfter 比较两个LocalTime

注意:LocalTime 类是不可变的,上述方法都会返回一个新的实例。

LocalDateTime类则可看作是LocalDateLocalTime的结合。它用于存储本地时区中的某个时间点,包含当前的年月日等日期信息, 同时也包含了时钟、分钟、秒钟等时间信息。同样,LocalDateTime也是不可变的。

详细的方法说明请参考LocalDateTime的 JavaDoc


带时区的时间

Java8 的时间 API 当然也加入了对时区的支持。分别对应着LocalDateLocalTimeLocalDateTime,带时区的时间类为ZonedDate、 ZonedTimeZonedDateTime

Java 中的时区信息来自于 IANA(Internet Assigned Numbers Authority)的数据库,其中每个时区都有着对应的 ID,例如America/New_York或者Europe/Berlin。 调用ZoneId.getAvailableIds方法即可获取所有可用的时区信息。

你还可以使用ZoneId.of(id)方法,用指定的时区 ID 来获取对应的ZoneId对象。通过调用local.atZone(zoneId)方法, 你可以将一个LocalDateTime转换成一个ZonedDateTime对象,或者通过调用静态方法ZonedDateTime.of来创建一个对象。

ZonedDateTime的许多方法都与LocalDateTime一致。下表中列出了ZonedDateTime特有的常用方法,详细的方法说明请参考ZonedDateTime的 JavaDoc

方法 描述
nowofofInstant 根据当前时间或指定的年月日时分秒、纳秒和ZoneId,或者一个Instant和一个ZoneId来创建一个ZonedDateTime对象
withZoneSameInstantwithZoneSameLocal 返回时区失去中的一个新的ZonedDateTime对象,它表示相同的瞬时点或本地时间
getOffset 获得与 UTC 之间的时差,返回一个ZoneOffset对象
toLocalDatetoLocalTimetoInstant 返回对应的本地日期、本地时间或瞬时点

除此之外,Java8 还提供了一个OffsetDateTime类,用来表示带有(与 UTC 相比的)偏移量的时间。这个类专门用于一些不需要时区规则的业务场景, 比如某些网络协议。对于人类可读的时间,ZonedDateTime是更好的选择。

详情请查阅OffsetDateTime的 JavaDoc


日期校正器

有些时候,我们可以能需要得到类似“每月的第一个星期二”这样的日期。Java8 提供了TemporalAdjuster接口,用以实现自定义的日期校正逻辑。 通过将创建好的TemporalAdjuster传递给日期时间类的with方法便可在原有日期时间对象的基础上产生出一个符合要求的日期时间。 例如,你可以通过如下代码来计算下一个星期二:

1
2
3
4
5
6
7
8
9
10
11
TemporalAdjuster NEXT_TUESDAY = (Temporal temporal) -> {
    int dowValue = DayOfWeek.TUESDAY.getValue();
    int calDow = temporal.get(ChronoField.DAY_OF_WEEK);
    if (calDow == dowValue) {
        return temporal;
    }
    int daysDiff = calDow - dowValue;
    return temporal.plus(daysDiff >= 0 ? 7 - daysDiff : -daysDiff, DAYS);
};
 
LocalDate nextTuesDay = today.with(NEXT_TUESDAY);

此处利用 Lambda 表达式快速实现了一个匿名的TemporalAdjuster对象。注意 Lambda 表达式的参数类型为 Temporal, 某些LocalDate或者LocalDateTime之类的类特有的方法将不可用,在使用前必须进行强制转换。 你可以通过ofDateAdjuster方法和一个UnaryOperator<LocalDate>来避免强制转换:

1
2
3
4
5
6
7
8
9
10
11
12
TermporalAdjuster NEXT_WORKDAY = TemporalAdjusters.ofDateAdjuster((LocalDate w) -> {
    LocalDate result;
    DayOfWeek dow = w.getDayOfWeek();
    if (dow == DayOfWeek.FRIDAY)
        result = w.plusDays(3);
    else if (dow == DayOfWeek.SATURDAY)
        result = w.plusDays(2);
    else
        result = w.plusDays(1);
 
    return result;
});

上述代码中使用的ofDateAdjuster方法来自类TemporalAdjusters。实际上这个类通过静态方法提供了大量的常用TemporalAdjuster实现。 比如,我们也可以通过如下代码来计算下一个星期二:

1
2
3
LocalDate nextTuesDay = LocalDate.now().with(
  TemporalAdjusters.nextOrSame(DayOfWeek.TUESDAY)
);

下表中列出了TemporalAdjusters的一些常用方法。详细的方法说明请参考TemporalAdjustersJavaDoc

方法 描述
previous(dayOfWeek)next(dayOfWeek) 返回被校正日期之后或之前最近的指定星期几
previoursOrSame(dayOfWeek)nextOrSame(dayOfWeek) 返回从被校正日期开始,之前或之后的指定星期几。如果被校正日期已吻合条件,被校正的日期实例将被直接返回
dayOfWeekInMonth(n, dayOfWeek) 返回该月中指定的第几个星期几
firstInMonth(dayOfWeek)lastInMonth(dayOfWeek) 返回该月第一个或最后一个星期几
firstDayOfMonth()firstDayOfNextMonth()
firstDayOfNextYear()lastDayOfMonth()
lastDayOfPreviousMonth()lastDayOfYear()
返回方法名所描述的日期

格式化和解析

除了日期校正,日期与字符串之间的相互转换也是十分常见的操作。对于原有的java.util.Date等类,我们使用java.text.DateFormat来对日期进行格式化和解析。 对于 Java8 新引入的日期时间类,我们使用java.time.format.DateTimeFormatter类。

DateTimeFormatter类提供了三种格式化方法来打印日期时间:

  • 预定义的标准格式
  • 语言环境相关的格式
  • 自定义的格式

下表中列出了所有预定义的DateTimeFormatter。详细说明可参考DateTimeFormatter的 JavaDoc

格式 示例
BASIC_ISO_DATE 20111203
ISO_LOCAL_DATE
ISO_LOCAL_TIME
ISO_LOCAL_DATE_TIME
2011-12-03
10:15:30
2011-12-03T10:15:30
ISO_OFFSET_DATE
ISO_OFFSET_TIME
ISO_OFFSET_DATE_TIME
2011-12-03+01:00
10:15:30+01:00
2011-12-03T10:15:30+01:00
ISO_ZONED_DATE_TIME 2011-12-03T10:15:30+01:00[Europe/Paris]
ISO_INSTANT 2011-12-03T10:15:30Z
ISO_ORDINAL_DATE 2012-337
ISO_WEEK_DATE 2012-W48-6
ISO_DATE
ISO_TIME
ISO_DATE_TIME
2011-12-03+01:002011-12-03
10:15:30+01:0010:15:30
2011-12-03T10:15:30+01:00[Europe/Paris]
RFC_1123_DATE_TIME Tue, 3 Jun 2008 11:05:30 GMT

通过调用DateTimeFormatter类的format方法即可对日期进行格式化:

1
2
String formatted = DateTimeFormatter.ISO_DATE_TIME.format(apollolllaunch);
  // 1969-07-16T09:32:00-0500[America/New_York]

标准格式主要用于机器可读的时间戳。为了产生人类可读的日期和时间,你需要使用语言环境相关的格式。 下表中列出了 Java8 提供的 4 种风格:

风格 日期 时间
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

你可以通过静态方法ofLocalizedDateofLocalizedTimeofLocalizedDateTime来创建这些格式:

1
2
3
4
5
DateTimeFormatter formatter =
    DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG);
 
String formatted = formatter.format(apollolllaunch);
    // July 16, 1969 9:32:00 AM EDT

这些方法使用的都是默认的语言环境。通过使用withLocale方法可以更改为其他语言环境:

1
2
String formatted = formatter.withLocale(Locale.FRENCH).format(apollolllaunch);
    // 16 juillet 1969 09:32:00 EDT

你可以通过调用formatter.toFormat()方法来获取一个等效的java.util.DateFormat对象。

最后,你可以通过指定的模式来自定义日期的格式。例如:

1
formatter = DateTimeFormatter.ofPattern("E yyyy-MM-dd HH:mm");

其中不同的符号对应着不同的含义。下表中列出了不同符号的具体含义和实例,详情可查阅DateTimeFormatter的 JavaDoc

含义 符号 示例
纪元 G
GGGG
GGGGG
AD
Anno Domini
A
年份 yy
yyyy
69
1969
月份 M
MM
MMM
MMMM
MMMMM
7
07
Jul
July
J
日份 d
dd
6
06
星期几 e
E
EEEE
EEEEE
3
Wed
Wednesday
W
24小时制时钟([0,23][0,23]) H
HH
9
09
12小时制时钟([0,11][0,11]) K
KK
9
09
AM/PM a AM
分钟 mm 02
秒钟 ss 00
时区 ID VV America/New_York
时区名称 z
zzzz
EDT
Eastern Daylight Time
时差 x
xx
xxx
XXX
-04
-0400
-04:00
-Z4:ZZ
本地化的时差 O
OOOO
GMT-4
GMT-04:00

要从一个字符串中解析出日期时间,可以使用静态方法parse的各个重载方法。例如:

1
2
3
4
LocalDate churchsBirthday = LocalDate.parse("1903-06-14");
ZonedDateTime apollolllaunch =
    ZonedDateTime.parse("1969-07-16 03:32:00-0400",
                        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssxx"));

与遗留代码互操作

尽管使用全新的 API 可以获得更好的开发体验,但兼容遗留代码总是不可避免的。因此,熟知新的日期时间类和旧的日期时间类之间的转换方法也是我们必须学习的。

总体来讲,转换规则可以归纳为下表:

To 遗留类 From 遗留类
java.time.Instant
java.util.Date
Date.from(instant) date.toInstant()
java.time.Instant
java.sql.Timestamp
Timestamp.from(instant) timestamp.toInstant()
java.time.Instant
java.nio.file.attribute.FileTime
FileTime.from(instant) fileTime.toInstant()
java.time.ZonedDateTime
java.util.GregorianCalendar
GregorianCalendar.from(zonedDateTime) cal.toZonedDateTime()
java.time.LocalDate
java.sql.Time
Date.valueOf(localDate) date.toLocalDate()
java.time.LocalTime
java.sql.Time
Date.valueOf(localDate) date.toLocalTime()
java.time.LocalDateTime
java.sql.Timestamp
Timestamp.valueOf(localDateTime) timestamp.toLocalDateTime()
java.time.ZoneId
java.util.TimeZone
Timezone.getTimeZone(id) timeZone.toZoneId()
java.time.format.DateTimeFormatter
java.text.DateFormat
formatter.toFormat()