17 Java 日期与时间

本文为个人学习摘要笔记。 原文地址:廖雪峰 Java 教程之日期和时间

本地化

在计算机中,通常使用 Locale 表示一个国家或地区的日期、时间、数字、货币等格式。Locale语言_国家 的字母缩写构成,例如,zh_CN 表示中文+中国,en_US 表示英文+美国。语言使用小写,国家使用大写。

对于日期来说,不同的 Locale 会有不同的表示方式,例如,中国和美国的表示方式如下:

zh_CN:2016-11-30
en_US:11/30/2016

计算机用 Locale 在日期、时间、货币和字符串之间进行转换。

Epoch Time

Epoch Time 即我们常说的时间戳,是计算从 1970 年 1 月 1 日零点(格林威治时区/GMT+00:00)到现在所经历的秒数。在不同的编程语言中,会有几种存储方式:

  • 以秒为单位的整数:1574208900,缺点是精度只能到秒;

  • 以毫秒为单位的整数:1574208900123,最后 3 位表示毫秒数;

  • 以秒为单位的浮点数:1574208900.123,小数点后面表示零点几秒。

在 Java 程序中,时间戳通常是用 long 表示的毫秒数:

long t = 1574208900123L;

要获取当前时间戳,可以使用 System.currentTimeMillis(),这是 Java 程序获取时间戳最常用的方法

标准库

Java 标准库有两套处理日期和时间的 API:

  • 一套定义在 java.util 这个包里面,主要包括 DateCalendarTimeZone 这几个类;

  • 一套新的 API 是在 Java 8 引入的,定义在 java.time 这个包里面,主要包括 LocalDateTimeZonedDateTimeZoneId 等。

为什么会有新旧两套 API 呢?因为历史遗留原因,旧的 API 存在很多问题,所以引入了新的 API。因为很多遗留代码仍然使用旧的 API,所以目前仍然需要对旧的 API 有一定了解,很多时候还需要在新旧两种对象之间进行转换。

Date 和 Calendar

Date

java.util.Date 是用于表示一个日期和时间的对象,注意与 java.sql.Date 区分,后者用在数据库中。如果观察 Date 的源码,可以发现它实际上存储了一个 long 类型的以毫秒表示的时间戳:

Date 的基本用法:

执行输入:

注意 getYear() 返回的年份必须加上 1900getMonth() 返回的月份是 0~11 分别表示 1~12 月,所以要加 1,而 getDate() 返回的日期范围是 1~31,又不能加 1。

打印本地时区表示的日期和时间时,不同的计算机可能会有不同的结果。如果我们想要针对用户的偏好精确地控制日期和时间的格式,就可以使用 SimpleDateFormat 对一个 Date 进行转换。它用预定义的字符串表示格式化:

  • yyyy:年

  • MM:月

  • dd:日

  • HH:小时(0-23)

  • mm:分钟

  • ss:秒

  • kk:小时(1-24)

更多格式参考 JDK 文档

自定义格式输出:

Date 对象有几个严重的问题:它不能转换时区,除了 toGMTString() 可以按 GMT+0:00 输出外,Date 总是以当前计算机系统的默认时区为基础进行输出。此外,我们也很难对日期和时间进行加减,计算两个日期相差多少天,计算某个月第一个星期一的日期等。

Calendar

Calendar 可以用于获取并设置年、月、日、时、分、秒,它和 Date 比,主要多了一个可以做简单的日期和时间运算的功能。

Calendar 获取年月日这些信息变成了 get(int field)返回的年份不必转换,返回的月份仍然要加 1,返回的星期要特别注意,1~7 分别表示周日、周一至周六。

Calendar 只有一种方式获取,即 Calendar.getInstance(),而且一获取到就是当前时间。如果我们想给它设置成特定的一个日期和时间,就必须先清除所有字段。

利用 Calendar.getTime() 可以将一个 Calendar 对象转换成 Date 对象,然后就可以用 SimpleDateFormat 进行格式化了。

TimeZone

CalendarDate 相比,它提供了时区转换的功能。时区用 TimeZone 对象表示:

时区的唯一标识是以字符串表示的 ID,我们获取指定 TimeZone 对象也是以这个 ID 为参数获取,GMT+09:00Asia/Shanghai 都是有效的时区 ID。使用 TimeZone.getAvailableIDs() 可以列出系统支持的所有 ID。

利用时区就可以对指定时间进行转换。利用 Calendar 进行时区转换的步骤是:

  1. 清除所有字段;

  2. 设定指定时区;

  3. 设定日期和时间;

  4. 创建 SimpleDateFormat并设定目标时区;

  5. 格式化获取的 Date 对象。

注意 Date 对象无时区信息,时区信息存储在 SimpleDateFormat,本质上时区转换只能通过 SimpleDateFormat 在显示的时候完成。

下面的例子演示了如何将北京时间 2019-11-20 8:15:00 转换为纽约时间:

Calendar 也可以对日期和时间进行简单的加减:

LocalDateTime

从 Java 8 开始,java.time 包提供了新的日期和时间 API,主要涉及的类型有:

  • 本地日期和时间:LocalDateTimeLocalDateLocalTime

  • 带时区的日期和时间:ZonedDateTime

  • 时刻:Instant

  • 时区:ZoneIdZoneOffset

  • 时间间隔:Duration

  • 以及一套新的用于取代 SimpleDateFormat 的格式化类型 DateTimeFormatter

和旧的 API 相比,新 API 严格区分了时刻、本地日期、本地时间和带时区的日期时间,并且,对日期和时间进行运算更加方便。

此外,新 API 修正了旧 API 不合理的常量设计:

  • Month 的范围用 1~12 表示 1 月到 12 月;

  • Week 的范围用 1~7 表示周一到周日。

最后,新 API 的类型几乎全部是不变类型(和 String 类似),可以放心使用不必担心被修改。

LocalDateTime 表示一个本地日期和时间,本地日期和时间通过 now() 获取,且总是以当前默认时区返回,和旧 API 不同,LocalDateTimeLocalDateLocalTime 默认严格按照 ISO 8601 规定的日期和时间格式进行打印。

在上面栗子中,在获取 3 个类型的时候,由于执行一行代码总会消耗一点时间,因此,3 个类型的日期和时间很可能对不上(毫秒数不同)。为了保证获取到同一时刻的日期和时间,可以通过互相转换来获取一个相同的时刻:

同理,也可以反过来,通过指定的日期和时间创建 LocalDateTime 可以通过 of() 方法:

因为严格按照 ISO 8601 的格式,因此,将字符串转换为 LocalDateTime 就可以传入标准格式:

ISO 8601 规定的日期和时间分隔符是 T。标准格式如下:

  • 日期:yyyy-MM-dd

  • 时间:HH:mm:ss

  • 带毫秒的时间:HH:mm:ss.SSS

  • 日期和时间:yyyy-MM-dd'T'HH:mm:ss

  • 带毫秒的日期和时间:yyyy-MM-dd'T'HH:mm:ss.SSS

LocalDateTime 提供了对日期和时间进行加减的非常简单的链式调用:

月份加减会自动调整日期,例如从 2019-10-31 减去 1 个月得到的结果是 2019-09-30,因为 9 月没有 31 日。

对日期和时间进行调整则使用 withXxx() 方法,例如:withHour(15) 会把 10:11:12 变为 15:11:12

  • 调整年:withYear()

  • 调整月:withMonth()

  • 调整日:withDayOfMonth()

  • 调整时:withHour()

  • 调整分:withMinute()

  • 调整秒:withSecond()

LocalDateTime 还有一个通用的 with() 方法允许我们做更复杂的运算:

要判断两个 LocalDateTime 的先后,可以使用 isBefore()isAfter() 方法,对于 LocalDateLocalTime 类似:

DateTimeFormatter

DateTimeFormatter 可以自定义输出的格式,或者要把一个非 ISO 8601 格式的字符串解析成 LocalDateTime

注意到 LocalDateTime 无法与时间戳进行转换,因为 LocalDateTime 没有时区,无法确定某一时刻。后面我们要介绍的 ZonedDateTime 相当于 LocalDateTime 加时区的组合,它具有时区,可以与 long 表示的时间戳进行转换。

Duration 和 Period

Duration 表示两个时刻之间的时间间隔。另一个类似的 Period 表示两个日期之间的天数:

注意到两个 LocalDateTime 之间的差值使用 Duration 表示,类似 PT1235H10M30S,表示 1235 小时 10 分钟 30 秒。而两个 LocalDate 之间的差值用 Period 表示,类似 P1M21D,表示 1 个月 21 天。

DurationPeriod 的表示方法也符合 ISO 8601 的格式,它以 P...T... 的形式表示,P...T 之间表示日期间隔,T 后面表示时间间隔。如果是 PT... 的格式表示仅有时间间隔。

利用 ofXxx() 或者 parse() 方法也可以直接创建 Duration

ZonedDateTime

LocalDateTime 总是表示本地日期和时间,要表示一个带时区的日期和时间,我们就需要 ZonedDateTime

可以简单地把 ZonedDateTime 理解成 LocalDateTimeZoneIdZoneIdjava.time 引入的新的时区类,注意和旧的 java.util.TimeZone 区别。

要创建一个 ZonedDateTime 对象,有以下几种方法:

  1. 通过 now() 方法返回当前时间:

  1. 通过给一个 LocalDateTime 附加一个 ZoneId,就可以变成 ZonedDateTime

时区转换

要转换时区,首先我们需要有一个 ZonedDateTime 对象,然后,通过 withZoneSameInstant() 将关联时区转换到另一个时区,转换后日期和时间都会相应调整。

举个栗子,将北京时间转换为纽约时间:

DateTimeFormatter

使用旧的 Date 对象时,我们用 SimpleDateFormat 进行格式化显示。使用新的 LocalDateTimeZonedLocalDateTime 时,我们要进行格式化显示,就要使用 DateTimeFormatter

SimpleDateFormat 不同的是,DateTimeFormatter 不但是不变对象,它还是线程安全的。现在我们只需要记住:因为 SimpleDateFormat 不是线程安全的,使用的时候,只能在方法内部创建新的局部变量。而 DateTimeFormatter 可以只创建一个实例,到处引用

创建 DateTimeFormatter 时,通过传入格式化字符串实现:

格式化字符串的使用方式与 SimpleDateFormat 完全一致。

另一种创建 DateTimeFormatter 的方法是,传入格式化字符串时,同时指定 Locale

使用效果:

Instant

计算机存储的当前时间,本质上只是一个不断递增的整数。Java 提供的 System.currentTimeMillis() 返回的就是以毫秒表示的当前时间戳。

这个当前时间戳在 java.time 中以 Instant 类型表示,我们用 Instant.now() 获取当前时间戳,效果和 System.currentTimeMillis() 类似:

实际上,Instant 内部只有两个核心字段:

一个是以秒为单位的时间戳,一个是更精确的纳秒精度。它和 System.currentTimeMillis() 返回的 long 相比,只是多了更高精度的纳秒。

既然 Instant 就是时间戳,那么,给它附加上一个时区,就可以创建出 ZonedDateTime

对于某一个时间戳,给它关联上指定的 ZoneId,就得到了 ZonedDateTime,继而可以获得了对应时区的 LocalDateTime

所以,LocalDateTimeZoneIdInstantZonedDateTimelong 都可以互相转换:

最佳实践

由于 Java 提供了新旧两套日期和时间的 API,除非涉及到遗留代码,否则我们应该坚持使用新的 API。

旧 API 转新 API

如果要把旧式的 DateCalendar 转换为新 API 对象,可以通过 toInstant() 方法转换为 Instant 对象,再继续转换为 ZonedDateTime

上面栗子可以看出,旧的 TimeZone 提供了一个 toZoneId(),可以把自己变成新的 ZoneId

新 API 转旧 API

如果要把新的 ZonedDateTime 转换为旧的 API 对象,只能借助 long 型时间戳做一个“中转”:

上面栗子可以看出,新的 ZoneId 转换为旧的 TimeZone,需要借助 ZoneId.getId() 返回的 String 完成。

在数据库中存储日期和时间

除了旧式的 java.util.Date,我们还可以找到另一个 java.sql.Date,它继承自 java.util.Date,但会自动忽略所有时间相关信息。这个奇葩的设计原因要追溯到数据库的日期与时间类型。

在数据库中,也存在几种日期和时间类型:

  • DATETIME:表示日期和时间;

  • DATE:仅表示日期;

  • TIME:仅表示时间;

  • TIMESTAMP:和 DATETIME 类似,但是数据库会在创建或者更新记录的时候同时修改 TIMESTAMP

在使用 Java 程序操作数据库时,我们需要把数据库类型与 Java 类型映射起来。下表是数据库类型与 Java 新旧 API 的映射关系:

数据库
对应 Java 类(旧)
对应 Java 类(新)

DATETIME

java.util.Date

LocalDateTime

DATE

java.sql.Date

LocalDate

TIME

java.sql.Time

LocalTime

TIMESTAMP

java.sql.Timestamp

LocalDateTime

实际上,在数据库中,我们需要存储的最常用的是时刻(Instant),因为有了时刻信息,就可以根据用户自己选择的时区,显示出正确的本地时间。所以,最好的方法是直接用长整数 long 表示,在数据库中存储为 BIGINT 类型。时间戳具有省空间,效率高,不依赖数据库的优点。

通过存储一个 long 型时间戳,我们可以编写一个 timestampToString() 的方法,非常简单地为不同用户以不同的偏好来显示不同的本地时间:

最后更新于

这有帮助吗?