理解 Java8 的时间API(一)时区

发布时间 2023-04-22 17:41:29作者: Recycer

理解 Java8 的时间API:java.time

由于Java的时间API:java.util.Datejava.util.Calendarjava.util.TimeZone使用起来非常混乱,因此 Java8 重新设计了一套时间API,放在java.time.* 包下。

如果需要熟练使用新的LocalDateTimeLocalDateLocalTime类,最好是先了解时区的概念。因此本文先梳理一下 Java8 里新的时区API。

一、时区与时间

首先是时区time zone,定义这里不多介绍,只需要了解地球上划分了很多时区,不同时区当前的日期时间是不一样的。比如北京时间、伦敦时间、洛杉矶时间等。常见的格式是:

其次是时间,这个太熟悉了,甚至不需要定义。时间通常有两种表示法:

  • 时间戳(timestamp):计算机常用的表示方法。定义是以自1970年1月1日经过的秒数(忽略闰秒)来表示时间。也就是说表示某一个“瞬间”,并且不受时区影响,在任何一个时区获取当前的时间戳都是一个相同的值。在类Unix系统里是一个有正负号的32位整数(signed int32),因此存在2038年问题。在Java里则通常用一个long类型的整数表示。
  • 日期时间(date time):日常生活中最常见的表示方法。例如“2023年4月22日15时23分08秒”,格式有很多种,但是通常由两部分组成:date(年+月+日)与 time(时+分+秒)组成。受时区影响,在当前这个“瞬间”,不同时区的日期时间是不同的。

由上面的定义可以知道:时间戳与日期时间之间相互转化是需要时区信息的。这个作为大前提。

二、Java8中的时区

2.1 介绍

对时区和时间有了一个大概的概念之后,这里介绍一下Java中怎么描述这两种概念。

首先是Java中时区类java.time.ZoneId,这里贴一下注释:

/**
 * A time-zone ID, such as {@code Europe/Paris}.
 * <p>
 * A {@code ZoneId} is used to identify the rules used to convert between
 * an {@link Instant} and a {@link LocalDateTime}.
 * There are two distinct types of ID:
 * <ul>
 * <li>Fixed offsets - a fully resolved offset from UTC/Greenwich, that uses
 *  the same offset for all local date-times
 * <li>Geographical regions - an area where a specific set of rules for finding
 *  the offset from UTC/Greenwich apply
 * </ul>
 * Most fixed offsets are represented by {@link ZoneOffset}.
 * Calling {@link #normalized()} on any {@code ZoneId} will ensure that a
 * fixed offset ID will be represented as a {@code ZoneOffset}.
 * <p>
 * The actual rules, describing when and how the offset changes, are defined by {@link ZoneRules}.
 * This class is simply an ID used to obtain the underlying rules.
 * This approach is taken because rules are defined by governments and change
 * frequently, whereas the ID is stable.
 * <p>
 * The distinction has other effects. Serializing the {@code ZoneId} will only send
 * the ID, whereas serializing the rules sends the entire data set.
 * Similarly, a comparison of two IDs only examines the ID, whereas
 * a comparison of two rules examines the entire data set.
 *
 * <h3>Time-zone IDs</h3>
 * The ID is unique within the system.
 * There are three types of ID.
 * <p>
 * The simplest type of ID is that from {@code ZoneOffset}.
 * This consists of 'Z' and IDs starting with '+' or '-'.
 * <p>
 * The next type of ID are offset-style IDs with some form of prefix,
 * such as 'GMT+2' or 'UTC+01:00'.
 * The recognised prefixes are 'UTC', 'GMT' and 'UT'.
 * The offset is the suffix and will be normalized during creation.
 * These IDs can be normalized to a {@code ZoneOffset} using {@code normalized()}.
 * <p>
 * The third type of ID are region-based IDs. A region-based ID must be of
 * two or more characters, and not start with 'UTC', 'GMT', 'UT' '+' or '-'.
 * Region-based IDs are defined by configuration, see {@link ZoneRulesProvider}.
 * The configuration focuses on providing the lookup from the ID to the
 * underlying {@code ZoneRules}.
 * <p>
 * Time-zone rules are defined by governments and change frequently.
 * There are a number of organizations, known here as groups, that monitor
 * time-zone changes and collate them.
 * The default group is the IANA Time Zone Database (TZDB).
 * Other organizations include IATA (the airline industry body) and Microsoft.
 * <p>
 * Each group defines its own format for the region ID it provides.
 * The TZDB group defines IDs such as 'Europe/London' or 'America/New_York'.
 * TZDB IDs take precedence over other groups.
 * <p>
 * It is strongly recommended that the group name is included in all IDs supplied by
 * groups other than TZDB to avoid conflicts. For example, IATA airline time-zone
 * region IDs are typically the same as the three letter airport code.
 * However, the airport of Utrecht has the code 'UTC', which is obviously a conflict.
 * The recommended format for region IDs from groups other than TZDB is 'group~region'.
 * Thus if IATA data were defined, Utrecht airport would be 'IATA~UTC'.
 *
 * <h3>Serialization</h3>
 * This class can be serialized and stores the string zone ID in the external form.
 * The {@code ZoneOffset} subclass uses a dedicated format that only stores the
 * offset from UTC/Greenwich.
 * <p>
 * A {@code ZoneId} can be deserialized in a Java Runtime where the ID is unknown.
 * For example, if a server-side Java Runtime has been updated with a new zone ID, but
 * the client-side Java Runtime has not been updated. In this case, the {@code ZoneId}
 * object will exist, and can be queried using {@code getId}, {@code equals},
 * {@code hashCode}, {@code toString}, {@code getDisplayName} and {@code normalized}.
 * However, any call to {@code getRules} will fail with {@code ZoneRulesException}.
 * This approach is designed to allow a {@link ZonedDateTime} to be loaded and
 * queried, but not modified, on a Java Runtime with incomplete time-zone information.
 *
 * <p>
 * This is a <a href="{@docRoot}/java/lang/doc-files/ValueBased.html">value-based</a>
 * class; use of identity-sensitive operations (including reference equality
 * ({@code ==}), identity hash code, or synchronization) on instances of
 * {@code ZoneId} may have unpredictable results and should be avoided.
 * The {@code equals} method should be used for comparisons.
 *
 * @implSpec
 * This abstract class has two implementations, both of which are immutable and thread-safe.
 * One implementation models region-based IDs, the other is {@code ZoneOffset} modelling
 * offset-based IDs. This difference is visible in serialization.
 *
 * @since 1.8
 */
public abstract class ZoneId implements Serializable {
}

从注释中可以总结一下要点:

  1. 时区ID 定义了InstantLocalDateTime之间相互转化的规则;
  2. 时区ID 有两种不同的类型:
    • 固定偏移:以与某个标准时间(如UTC、GMT、UT等)的偏移量来定义时区;例如“UTC+08:00”、“UTC+8”、“GMT+8”等。(UTC、GMT、UT的定义可以自行搜索,这里只需要知道UTC和GMT可以简单理解成一个东西。)
    • 地理区域:以具体的地理位置来表示当地的时区。通常是定义在TZDB中的。由于人类的活动,使用按经度来划分的时区可能会有诸多不便,例如“America/Los_Angeles”、“Asia/Shanghai”等。

此外,还有时区缩写(time-zone abbreviations),也叫短时区ID:例如"PST"(太平洋标准时间,Pacific Standard Time,代表城市洛杉矶,因此等效于America/Los_Angeles),但是由于很多时区的缩写相同,容易引起歧义,因此不太建议用这种。

固定偏移类型的时区ID通常是根据地理经度来划分的,日常生活中使用这种类型的时区会带来诸多不便,例如中国在地理上横跨很多时区,但是全国使用同一的北京时间("UTC+8",国际上定义"Asia/Shanghai")。并且,欧美很多国家会实行夏时制(又称夏令时、日光节约时间),例如2023年,美国在3月12日启用夏时制,11月5日结束夏时制。(这里日期是美国东部时间,"EST"),洛杉矶时区America/Los_Angeles在夏时制等效于UTC-7,不在夏时制等效于UTC-8

2.2 ZoneId使用

经过前面的介绍,正式介绍Java里的时区类:java.time.ZoneId,这是一个抽象类,有两个具体子类:public的java.time.ZoneOffset 和 package的java.time.ZoneRegion

ZoneId可以使用其静态方法来构造:public static ZoneId of(String zoneId),参数中"zoneId"合法的格式:

  1. 只包含与UTC/GMT的偏移量:"Z"(即UTC/GMT),"+8","+08:00",之所以精确到分,是因为还有一些"+04:30"之类的时区;
  2. 包含前缀以及与其的偏移量:例如 "UTC" / "UTC+0","UTC+8","UTC+08:00";(合法的前缀只有 'UTC', 'GMT' and 'UT')
  3. 包含定义在TZDB中的地区格式:例如:"Asia/Shanghai","America/Los_Angeles"。

其中,格式1初始化出来的时区类是ZoneOffset,格式2和格式3初始化出来的是ZoneRegion

几个额外的点:

  1. 如果需要使用缩写:"EST","PST",为了消除歧义,需要使用重载的public static ZoneId of(String zoneId, Map<String, String> aliasMap)方法,指定缩写与全称的映射关系。
  2. public ZoneId normalized() 方法:如果 ZoneId 实例实质上是一个UTC/GMT的固定偏移,那么会返回ZoneOffset实例;否则返回自身。
  3. public abstract ZoneRules getRules()方法:获取当前时区实例的java.time.zone.ZoneRulesZoneRules包含了InstantLocalDateTime互相转化的时区规则,并且已经考虑了夏时制等其他规则。了解即可。因此可以使用java.time.zone.ZoneRules#getOffset(java.time.Instant)方法获取当前时间的偏移量(因为夏时制的存在,不同时间的偏移量可能不同)。

代码验证:

public static void main(String[] args) {
    System.out.println(ZoneId.of("Z").getClass().getSimpleName()); // ZoneOffset
    System.out.println(ZoneId.of("+8").getClass().getSimpleName()); // ZoneOffset
    System.out.println(ZoneId.of("UTC").getClass().getSimpleName()); // ZoneRegion
    System.out.println(ZoneId.of("UTC+8").getClass().getSimpleName()); // ZoneRegion
    System.out.println(ZoneId.of("Asia/Shanghai").getClass().getSimpleName()); // ZoneRegion
    System.out.println(ZoneId.of("PST", ZoneId.SHORT_IDS).getClass().getSimpleName()); // ZoneRegion
    System.out.println(ZoneId.of("UTC+8").normalized().getClass().getSimpleName()); // ZoneOffset

    ZoneId los = ZoneId.of("America/Los_Angeles");
    ZoneRules rules = los.getRules();
    ZoneOffset offset1 = rules.getOffset(Instant.now()); // 现在是2023/4/22 17:29
    ZoneOffset offset2 = rules.getOffset(LocalDateTime.of(2023, 11, 10, 0, 0, 0));
    System.out.println(offset1); // -07:00
    System.out.println(offset2); // -08:00
}