Appearance
Java 基础:日期和时间
Java 1.0 有一个 Date 类,事后证明它过于简单了,当 Java 1.1 引入 Calendar 类之后,Date 类中的大部分方法就被弃用了。但是,Calendar 的 API 还不够给力,它的实例是可修改的,并且它没有处理诸如闰秒这样的问题。第 3 次升级很吸引人,那就是 Java SE 8 中引入的 java.time API,它修正了过去的缺陷,并且应该会服役相当长的一段时间。
1. 时间线
在 Java 中,Instant 表示时间线上的某个点。被称为 “新纪元” 的时间线原点被设置为穿过伦敦格林尼治皇家天文台的本初子午线所处时区的 1970 年 1 月 1 日的午夜。这与 UNIX/POSIX 时间中使用的惯例相同。从该原点开始,时间按照每天 86400 秒向前或向回度量,精确到纳秒。Instant 的值往回可追溯 10 亿年(Instant.MIN)。这对于表示宇宙年龄(大约 135 亿年)来说还差得远,但是对于所有实际应用来说,应该足够了。毕竟,10 亿年前,地球表面还覆盖着冰层,只有当今植物和动物的微生物祖先在繁殖生衍。最大的值 Instant.MAX 是公元 1,000,000,000 年的 12 月 31 日。
静态方法调用 Instant.now() 会给出当前的时刻。你可以按照常用的方式,用 equals 和 compareTo 方法来比较两个 Instant 对象,因此你可以将 Instant 对象用作时间戳。
为了得到两个时刻之间的时间差,可以使用静态方法 Duration.between。例如,下面的代码展示了如何度量算法的运行时间:
Java
Instant start = Instant.now();
runAlgorithm();
Instant end = Instant.now();
Duration timeElapsed = Duration.between(start, end);
long millis = timeElapsed.toMillis();1
2
3
4
5
2
3
4
5
Duration 是两个时刻之间的时间量。你可以通过调用 toNanos、toMillis、toSeconds、toMinutes、toHours 和 toDays 来获得 Duration 按照传统单位度量的时间长度。
Note:在 Java 8 中,必须调用
getSeconds而不是toSeconds。
如果想要让计算精确到纳秒级,那么就需要当心上溢问题。long 值可以存储大约 300 年时间对应的纳秒数。如果你需要的 Duration 短于这个时间,那么可以直接将其转换为纳秒数。你可以使用更长的 Duration,即让 Duration 对象用一个 long 来存储秒数,用另外一个 int 来存储纳秒数。Duration 接口包含了大量在本节末尾展示的用于执行算术运算的方法。
例如,如果想要检查某个算法是否至少比另一个算法快 10 倍,那么你可以执行如下的计算:
Java
Duration timeElapsed2 = Duration.between(start2, end2);
boolean overTenTimesFaster = timeElapsed.multipliedBy(10).minus(timeElapsed2).isNegative();1
2
2
这里只展示了语法。因为算法不会运行数百年,所以可以直接使用下面的方法:
Java
boolean overTenTimesFaster = timeElapsed.toNanos() * 10 < timeElapsed2.toNanos();Tip:
Instant和Duration类都是不可修改的类,所以诸如multipliedBy和minus这样的方法都会返回一个新的实例。
在下方的示例程序中,可以看到如何使用 Instant 和 Duration 类来对两个算法计时:
Java
package timeline;
/**
* @version 1.0 2016-05-10
* @author Cay Horstmann
*/
import java.time.*;
import java.util.*;
import java.util.stream.*;
public class Timeline {
public static void main(String[] args) {
Instant start = Instant.now();
runAlgorithm();
Instant end = Instant.now();
Duration timeElapsed = Duration.between(start, end);
System.out.printf("%d milliseconds\n", timeElapsed.toMillis());
Instant start2 = Instant.now();
runAlgorithm2();
Instant end2 = Instant.now();
Duration timeElapsed2 = Duration.between(start2, end2);
System.out.printf("%d milliseconds\n", timeElapsed2.toMillis());
boolean overTenTimesFaster = timeElapsed.multipliedBy(10).minus(timeElapsed2).isNegative();
System.out.printf("The first algorithm is %smore than ten times faster", overTenTimesFaster ? "" : "not ");
}
public static void runAlgorithm() {
int size = 10;
List<Integer> list = new Random().ints().map(i -> i % 100).limit(size).boxed().collect(Collectors.toList());
Collections.sort(list);
System.out.println(list);
}
public static void runAlgorithm2() {
int size = 10;
List<Integer> list = new Random().ints().map(i -> i % 100).limit(size).boxed().collect(Collectors.toList());
while (!IntStream.range(1, list.size()).allMatch(i -> list.get(i - 1).compareTo(list.get(i)) <= 0))
Collections.shuffle(list);
System.out.println(list);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
2. 本地日期
在 Java API 中有两种人类时间,本地日期/时间和时区时间。本地日期/时间包含日期和当天的时间,但是与时区信息没有任何关联 1903 年 6 月 14 日就是一个本地日期的示例(lambda 演算的发明者 Alonzo Church 在这一天诞生)。因为这个日期既没有当天的时间,也没有时区信息,因此它并不对应精确的时刻。与之相反的是,1969 年 7 月 16 日 09:32:00 EDT(阿波罗 11 号发射的时刻)是一个时区日期/时间,表示的是时间线上的一个精确的时刻。
有许多计算并不需要时区,在某些情况下,时区甚至是一种障碍。假设你安排每周 10:00 开一次会。如果你加 7 天(即 7 × 24 × 60 × 60 秒)到最后一次会议的时区时间上,那么你可能会碰巧跨越了夏令时的时间调整边界,这次会议可能会早一小时或晚一小时!
正是考虑到这个原因,API 的设计者们推荐程序员不要使用时区时间,除非确实想要表示绝对时间的实例。生日、假日、计划时间等通常最好都表示成本地日期和时间。
LocalDate 是带有年、月、日的日期。为了构建 LocalDate 对象,可以使用 now 或 of 静态方法:
Java
LocalDate today = LocalDate.now(); // Today’s date
LocalDate alonzosBirthday = LocalDate.of(1903, 6, 14);
alonzosBirthday = LocalDate.of(1903, Month.JUNE, 14); // Uses the Month enumeration1
2
3
2
3
与 UNIX 和 java.util.Date 中使用的月从 0 开始计算而年从 1900 开始计算的不规则的惯用法不同,你需要提供通常使用的月份的数字或 Month 枚举。
例如,程序员日是每年的第 256 天。下面展示了如何很容易地计算出它:
Java
LocalDate programmersDay = LocalDate.of(2014, 1, 1).plusDays(255);
// September 13, but in a leap year it would be September 121
2
2
回忆一下,两个 Instant 之间的时长是 Duration,而用于本地日期的等价物是 Period, 它表示的是流逝的年、月或日的数量。可以调用 birthday.plus(Period.ofYears(1)) 来获取下一年的生日。当然,也可以直接调用 birthday.plusYears(1)。但是 birthday.plus(Period.ofDays(365)) 在闰年是不会产生正确结果的。
until 方法会产生两个本地日期之间的时长。例如:
Java
independenceDay.until(christmas)会产生 5 个月 21 天的一段时长。这实际上并不是很有用,因为每个月的天数不尽相同。为了确定到底有多少天,可以使用:
Java
independenceDay.until(christmas, ChronoUnit.DAYS) // 174 daysNote
LocalDateAPI 能够正确地避开那些 “不存在” 的日期。例如,在 1 月 31 日上加上 1 个月不应该产生 2 月 31 日。这些方法并不会抛出异常,而是会返回该月有效的最后一天。例如:JavaLocalDate.of(2016, 1, 31).plusMonths(1)和
JavaLocalDate.of(2016, 3, 31).minusMonths(1)都将产生 2016 年 2 月 29 日。
getDayOfWeek 会产生星期日期,即 DayOfWeek 枚举的某个值。DayOfWeek.MONDAY 的枚举值为 1,而 DayOfWeek.SUNDAY 的枚举值为 7。例如:
Java
LocalDate.of(1900, 1, 1).getDayOfWeek().getValue()会产生 1。DayOfWeek 枚举具有便捷方法 plus 和 minus,以 7 为模计算星期日期。例如,DayOfWeek.SATURDAY.plus(3) 会产生 DayOfWeek.TUESDAY。
Warning:周末实际上在每周的末尾。这与
java.util.Calendar有所差异,在后者中,星期日的值为1,而星期六的值为7。
Java 9 添加了两个有用的 datesUntil 方法,它们会产生 LocalDate 对象流:
Java
LocalDate start = LocalDate.of(2000, 1, 1);
LocalDate endExclusive = LocalDate.now();
Stream<LocalDate> allDays = start.datesUntil(endExclusive);
Stream<LocalDate> firstDaysInMonth = start.datesUntil(endExclusive, Period.ofMonths(1));1
2
3
4
2
3
4
除了 LocalDate 之外,还有 MonthDay、YearMonth 和 Year 类可以描述部分日期。例如,12 月 25 日(没有指定年份)可以表示成一个 MonthDay 对象。
3. 日期调整器
对于日程安排应用来说,经常需要计算诸如 “每个月的第一个星期二” 这样的日期。TemporalAdjusters 类提供了大量用于常见调整的静态方法。你可以将调整方法的结果传递给 with 方法。例如,某个月的第一个星期二可以像下面这样计算:
Java
LocalDate firstTuesday = LocalDate.of(year, month, 1).with(TemporalAdjusters.nextOrSame(DayOfWeek.TUESDAY));一如既往,with 方法会返回一个新的 LocalDate 对象,而不会修改原来的对象。
还可以通过实现 TemporalAdjuster 接口来创建自己的调整器。下面是用于计算下一个工作日的调整器:
Java
TemporalAdjuster NEXT_WORKDAY = w -> {
var result = (LocalDate) w;
do {
result = result.plusDays(1);
}
while (result.getDayOfWeek().getValue() >= 6);
return result;
};
LocalDate backToWork = today.with(NEXT_WORKDAY);1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
注意,lambda 表达式的参数类型为 Temporal,它必须被强制转型为 LocalDate。你可以用 ofDateAdjuster 方法来避免这种强制转型,该方法期望得到的参数是类型为 UnaryOperator<LocalDate> 的 lambda 表达式。
Java
TemporalAdjuster NEXT_WORKDAY = TemporalAdjusters.ofDateAdjuster(w -> {
LocalDate result = w; // No cast
do {
result = result.plusDays(1);
}
while (result.getDayOfWeek().getValue() >= 6);
return result;
});1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
4. 本地时间
LocalTime 表示当日时刻,例如 15:30:00。可以用 now 或 of 方法创建其实例:
Java
LocalTime rightNow = LocalTime.now();
LocalTime bedtime = LocalTime.of(22, 30); // or LocalTime.of(22, 30, 0)1
2
2
plus 和 minus 操作是按照一天 24 小时循环操作的。例如:
Java
LocalTime wakeup = bedtime.plusHours(8); // wakeup is 6:30:00Note:
LocalTime自身并不关心 AM/PM。这种愚蠢的设计将问题抛给格式器去解决。
还有一个表示日期和时间的 LocalDateTime 类。这个类适合存储固定时区的时间点,例如,用于排课或排程。但是,如果你的计算需要跨越夏令时,或者需要处理不同时区的用户,那么就应该使用接下来要讨论的 ZonedDateTime 类。
5. 时区时间
时区问题比较复杂。在理性的世界中,我们都会遵循格林尼治时间,有些人在 02:00 吃午饭,而有些人却在 22:00 吃午饭。中国横跨了 4 个时区,但是使用了同一个时间。在其他地方,时区显得并不规则,并且还有国际日期变更线,而夏令时则使事情变得更复杂了。
尽管时区显得变化繁多,但这就是无法回避的现实生活。在实现日历应用时,它需要能够为坐飞机在不同国家之间穿梭的人们提供服务。如果你有个 10:00 在纽约召开的电话会议,但是碰巧你人在柏林,那么你肯定希望该应用能够在正确的本地时间点上发出提醒。
互联网编码分配管理机构(Internet Assigned Numbers Authority,IANA)保存着一个数据库,里面存储着世界上所有已知的时区(http://www.iana.org/time-zones),它每年会更新数次,而批量更新会处理夏令时的变更规则。Java 使用了 IANA 数据库。
每个时区都有一个 ID,例如 America/New_York 和 Europe/Berlin。要想找出所有可用的时区,可以调用 ZoneId.getAvailableZoneIds。
给定一个时区 ID,静态方法 ZoneId.of(id) 可以产生一个 ZoneId 对象。可以通过调用 local.atZone(zoneId) 用这个对象将 LocalDateTime 对象转换为 ZonedDateTime 对象,或者可以通过调用静态方法 ZonedDateTime.of(year, month, day, hour, minute, second, nano, zoneId) 来构造一个 ZonedDateTime 对象。例如:
Java
// 1969-07-16T09:32-04:00[America/New_York]
ZonedDateTime apollo11launch = ZonedDateTime.of(1969, 7, 16, 9, 32, 0, 0, ZoneId.of("America/New_York"));1
2
2
这是一个具体的时刻,调用 apollo11launch.toInstant 可以获得对应的 Instant 对象。反过来,如果你有一个时刻对象,调用 instant.atZone(ZoneId.of("UTC")) 可以获得格林尼治皇家天文台的 ZonedDateTime 对象,或者使用其他的 ZoneId 获得地球上其他地方的 ZoneId。
Note:UTC 代表 “协调世界时”,这是英文 “Coordinated Universal Time” 和法文 “Temps Universel Coordiné” 首字母缩写的折中,它与这两种语言中的缩写都不一致。UTC 是不考虑夏令时的格林尼治皇家天文台时间。
ZonedDateTime 的许多方法都与 LocalDateTime 的方法相同,它们大多数都很直观,但是夏令时带来了一些复杂性。
当夏令时开始时,时钟要向前拨快一小时。当你构建的时间对象正好落入了这跳过去的一个小时内时,会发生什么?例如,在 2013 年,中欧地区在 3 月 31 日 2:00 切换到夏令时,如果你试图构建的时间是不存在的 3 月 31 日 2:30,那么你实际上得到的是 3:30。
Java
// Constructs March 31 3:30
ZonedDateTime skipped = ZonedDateTime.of(LocalDate.of(2013, 3, 31), LocalTime.of(2, 30), ZoneId.of("Europe/Berlin"));1
2
2
反过来,当夏令时结束时,时钟要向回拨慢一小时,这样同一个本地时间就会有出现两次。当你构建位于这个时间段内的时间对象时,就会得到这两个时刻中较早的一个:
Java
// 2013-10-27T02:30+02:00[Europe/Berlin]
ZonedDateTime ambiguous = ZonedDateTime.of(
LocalDate.of(2013, 10, 27), // End of daylight savings time
LocalTime.of(2, 30),
ZoneId.of("Europe/Berlin")
);
// 2013-10-27T02:30+01:00[Europe/Berlin]
ZonedDateTime anHourLater = ambiguous.plusHours(1);1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
一个小时后的时间会具有相同的小时和分钟,但是时区的偏移量会发生变化。
Warning
你还需要在调整跨越夏令时边界的日期时特别注意。例如,如果你将会议设置在下个星期,不要直接加上一个 7 天的
Duration:Java// Caution! Won’t work with daylight savings time ZonedDateTime nextMeeting = meeting.plus(Duration.ofDays(7));1
2而是应该使用
Period类。JavaZonedDateTime nextMeeting = meeting.plus(Period.ofDays(7)); // OK
Note:还有一个
OffsetDateTime类,它表示与 UTC 具有偏移量的时间,但是没有时区规则的束缚。这个类被设计用于专用应用,这些应用特别需要剔除这些规则的约束,例如某些网络协议。对于人类时间,还是应该使用ZonedDateTime。
6. 格式化和解析
DateTimeFormatter 类提供了三种用于打印日期/时间值的格式器:
预定义的格式器;
格式器 描述 示例 BASIC_ISO_DATE年、月、日、时区偏移量,中间没有分隔符 19690716-0500ISO_LOCAL_DATE、ISO_LOCAL_TIME、ISO_LOCAL_DATE_TIME分隔符为 -、:、T1969-07-16、09:32:00、1969-07-16T09:32:00ISO_OFFSET_DATE、ISO_OFFSET_TIME、ISO_OFFSET_DATE_TIME类似 ISO_LOCAL_XXX,但是有时区偏移量1969-07-16-05:00、09:32:00-05:00、1969-07-16T09:32:00-05:00ISO_ZONED_DATE_TIME有时区偏移量和时区 ID 1969-07-16T09:32:00-05:00[America/New_York]ISO_INSTANT在 UTC 中,用 Z时区 ID 来表示1969-07-16T14:32:00ZISO_DATE、ISO_TIME、ISO_DATE_TIME类似 ISO_OFFSET_DATE、ISO_OFFSET_TIME和ISO_ZONED_DATE_TIME,但是时区信息是可选的1969-07-16-05:00、09:32:00-05:00、1969-07-16T09:32:00-05:00[America/New_York]ISO_ORDINAL_DATELocalDate的年和年日期1969-197ISO_WEEK_DATELocalDate的年、星期和星期日期1969-W29-3RFC_1123_DATE_TIME用于邮件时间戳的标准,编纂于 RFC822,并在 RFC1123 中将年份更新到 4 位 Wed, 16 Jul 1969 09:32:00-0500表 6.1 - 预定义的格式器 本地化(locale-specific)的格式器;
带有定制模式的格式器;
要使用标准的格式器,可以直接调用其 format 方法:
Java
// 1969-07-16T09:32:00-04:00"
String formatted = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(apollo11launch);1
2
2
标准格式器主要是为了机器可读的时间戳而设计的。为了向人类读者表示日期和时间,可以使用本地化(locale)相关的格式器。对于日期和时间而言,有 4 种与本地化(locale)相关的格式化风格,即 SHORT、MEDIUM、LONG 和 FULL,参见表 6.2。
| 风格 | 日期 | 时间 |
|---|---|---|
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 可以创建这种格式器。例如:
Java
DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG);
String formatted = formatter.format(apollo11launch); // July 16, 1969 9:32:00 AM EDT1
2
2
这些方法使用了默认的地区(locale)。为了切换到不同的地区,可以直接使用 withLocale 方法
Java
// 16 juillet 1969 09:32:00 EDT
formatted = formatter.withLocale(Locale.FRENCH).format(apollo11launch);1
2
2
DayOfWeek 和 Month 枚举都有 getDisplayName 方法,可以按照不同的地区和格式给出星期日期和月份的名字。
Java
// Prints Mon Tue Wed Thu Fri Sat Sun
for (DayOfWeek w : DayOfWeek.values())
System.out.print(w.getDisplayName(TextStyle.SHORT, Locale.ENGLISH) + " ");1
2
3
2
3
Note:
java.time.format.DateTimeFormatter类被设计用来替代java.util.DateFormat如果你为了向后兼容性而需要后者的实例,那么可以调用formatter.toFormat()。
最后,可以通过指定模式来定制自己的日期格式。例如:
Java
formatter = DateTimeFormatter.ofPattern("E yyyy-MM-dd HH:mm");会将日期格式化为 Wed 1969-07-16 09:32 的形式。按照人们日积月累而制定的显得有些晦涩的规则,每个字母都表示一个不同的时间域,而字母重复的次数对应于所选择的特定格式。表 6.3 展示了最有用的模式元素:
| 时间域或目的 | 示例 |
|---|---|
ERA | G: AD、GGGG: Anno Domini、GGGGG: A |
YEAR_OF_ERA | yy: 69、yyyy: 1969 |
MONTH_OF_YEAR | M: 7、MM: 07、MMM: Jul、MMMM: July、MMMMM: J |
DAY_OF_MONTH | d: 6、dd: 06 |
DAY_OF_WEEK | e: 3、E: Wed、EEEE: Wednesday、EEEEE: W |
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 | VV: America/New_York |
| 时区名 | z: EDT、zzzz: Eastern Daylight Time、v:ET、vvvv: Eastern Time |
| 时区偏移量 | x: -04、xx: -0400、xxx: -04:00、XXX 与 xxx 相同,但是 Z 表示 0 |
| 本地化的时区偏移量 | O: GMT-4、OOOO: GMT-04:00 |
| 修改后的儒略日 | g: 58243 |
为了解析字符串中的日期/时间值,可以使用众多的静态 parse 方法之一。例如:
Java
LocalDate churchsBirthday = LocalDate.parse("1903-06-14");
ZonedDateTime apollo11launch = ZonedDateTime.parse(
"1969-07-16 03:32:00-0400",
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssxx")
);1
2
3
4
5
2
3
4
5
第一个调用使用了标准的 ISO_LOCAL_DATE 格式器,而第二个调用使用的是一个定制的格式器。
7. 与遗留代码的互操作
作为全新的创造,Java Date 和 Time API 必须能够与已有类之间进行互操作,特别是无处不在的 java.util.Date、java.util.GregorianCalendar 和 java.sql.Date/Time/Timestamp。
Instant 类近似于 java.util.Date。在 Java 8 中,这个类有两个额外的方法:将 Date 转换为 Instant 的 toInstant 方法,以及反方向转换的静态的 from 方法。
类似地,ZonedDateTime 近似于 java.util.GregorianCalendar, 在 Java 8 中,这个类有细粒度的转换方法。toZonedDateTime 方法可以将 GregorianCalendar 转换为 ZonedDateTime,而静态的 from 方法可以执行反方向的转换。
另一个可用于日期和时间类的转换集位于 java.sql 包中。你还可以传递一个 DateTimeFormatter 给使用 java.text.Format 的遗留代码。表 7.4 对这些转换进行了总结。
| 类 | 转换到遗留类 | 转换自遗留类 |
|---|---|---|
Instant ↔ java.util.Date | Date.from(instant) | date.toInstant() |
ZonedDateTime ↔ java.util.GregorianCalendar | GregorianCalendar.from(zonedDateTime) | cal.toZonedDateTime() |
Instant ↔ java.sql.Timestamp | Timestamp.from(instant) | timestamp.toInstant() |
LocalDateTime ↔ java.sql.Timestamp | Timestamp.valueOf(localDateTime) | timestamp.toLocalDateTime() |
LocalDate ↔ java.sql.Date | Date.valueOf(localDate) | date.toLocalDate() |
LocalTime ↔ java.sql.Time | Time.valueOf(localTime) | time.toLocalTime() |
DateTimeFormatter → java.text.DateFormat | formatter.toFormat() | 无 |
java.util.TimeZone ↔ ZoneId | TimeZone.getTimeZone(id) | timeZone.toZoneId() |
java.nio.file.attribute.FileTime ↔ Instant | FileTime.from(instant) | fileTime.toInstant() |