時區以及時區對於Java時間類格式化的影響

wastonl發表於2024-09-02

時區基本概念

時區(Time Zone)是指地球上的一個地區與格林尼治標準時間(GMT)或協調世界時(UTC)之間的時間差異。由於地球自轉的原因,不同的地理位置會有不同的時間。時區的劃分使得世界各地能夠更合理地安排時間,保持同步。

UTC(協調世界時): UTC 是一種標準時間,它沒有受到地球自轉速度變化影響,是全世界時間標準的基礎。所有的時區都是相對於 UTC 來定義的,例如 UTC+8 表示比 UTC 早 8 個小時的時間。

GMT(格林尼治標準時間): GMT 是一種基於地球自轉和太陽位置的時間標準,雖然它和 UTC 時間非常接近,但由於 GMT 是一種天文時間,存在微小的變化。

時區偏移(Offset): 時區通常會以 UTC 偏移的方式表示,例如 UTC+0:00、UTC+05:30、UTC-8 :00等等。UTC+8:00 的時區意味著這個時區比 UTC 早 8 個小時,比如中國的北京時間。

夏令時(Daylight Saving Time, DST): 某些國家和地區會使用夏令時,在夏季時將時間撥快一小時,以充分利用日照時間。夏令時的使用會導致某些地方的時間偏移在一年中有變化。

Java中的時區API

JDK8之前的TimeZone

/**
 * 獲取所有可用的zoneId
 */
public static synchronized String[] getAvailableIDs();

/**
 * 獲取所在地預設時區,國內便是Asia/Shanghai,東八區GMT+08:00
 */
public static TimeZone getDefault();

/**
 * 根據ID獲取時區,注意如果是一個不支援的ID,則會降級到GMT時區,而不是報錯,因此有一個坑。
 * 格式:
 * 標準的地區名如Asia/Shanghai
 * UTC、GMT
 * 偏移量: GMT+08:00 (注意不支援UTC+08:00), 然後偏移量只支援到分鐘級別
 * 這個API支援單獨的UTC,但是不支援UTC開頭的偏移量
 * 因此如果你使用了UTC+08:00,則會降級到GMT時區,產生詭異現象。
 */
public static synchronized TimeZone getTimeZone(String ID);

以地區命名的id和偏移量區別

比如: Asia/Shanghai和GMT+08:00,對於大部分情況而言是對等的,然而由於夏令時的存在,使得時鐘撥快了一個小時,使用Asia/Shanghai的話,會根據規則判斷當前時間是不是在夏令時區間,從而計算出的偏移量是+09:00,而使用GMT+08:00則是固定的。因此使用Asia/Shanghai這個地區命名的不用使用者考慮這種繁瑣的夏令時規則。

JDK8新增的ZoneId、ZoneOffset

ZoneId

/**
 * 獲取所有可用的zoneId
 */
public static Set<String> getAvailableZoneIds();

/**
 * 獲取所在地預設時區,國內便是Asia/Shanghai,東八區UTC+08:00
 */
public static ZoneId systemDefault();

/**
 * 根據ID獲取時區,這個方法做了改進,不支援的引數會直接報錯而不是降級到UTC,非常棒。
 * 格式:
 * 標準的地區名如Asia/Shanghai
 * Z、UTC、GMT。這三個等價,相當於UTC+0
 * 偏移量: UTC+08:00 GMT+08:00
 * 這個API同時支援UTC和GMT字首
 */
public static ZoneId of(String zoneId);

ZoneOffset

代表UTC時區的偏移量,沒有了地區名寫法。ZoneOffset繼承了ZoneId

/**
 * 覆蓋了ZoneId的of方法
 * 格式: 不能寫字首,只能寫偏移量
 * +08:00
 * -08:00
 * 特別的這個偏移量可以精確到秒
 * +08:00:30
 */
public static ZoneOffset of(String offsetId);

/**
 * 根據小時的偏移量
 * ofHours(8)相當於+08:00
 * ofHours(-8)相當於-08:00
 */
public static ZoneOffset ofHours(int hours);

ZoneOffset不僅僅提供了ofHours方法,諸如ofHoursMinutes等方法,一眼就懂。

新老時區API互轉

TimeZone提供了兩個方法

/**
 * 靜態方法
 */
public static TimeZone getTimeZone(ZoneId zoneId);

/**
 * 成員方法
 */
public ZoneId toZoneId();

紀元毫秒(epochMillis)

紀元毫秒是從計算機的紀元時間(Epoch time)開始經過的毫秒數。紀元時間通常被定義為 1970年1月1日 00:00:00 UTC。紀元毫秒通常用來表示計算機系統中日期和時間的時間戳。

注意是UTC時區的1970-01-01 00:00:00

給定一個毫秒數,它代表非UTC時區的具體哪個時間,必須要指定時區才有意義。

比如1000這個紀元毫秒數,即1秒

等同於UTC時區的1970-01-01 00:00:01

也等同於UTC+8:00時區的1970-01-01 08:00:01

想反的,給定一個時間格式字串,必須指定時區,才能轉成紀元毫秒數

java.util.Date

Date類本身儲存的就是紀元毫秒數,getTime方法就可以獲取到這個儲存的紀元毫秒數。

但是Date類中的其它方法,諸如getYeargetDategetSeconds都是配合預設時區(中國是Asia/Shanghai)的一個時間來獲取的。

這些getXXX方法都已經被標註過時了。

Date類中的toString方法也一樣,作為顯示用,內部呼叫了這些getXXX方法。

使用DateFormat格式化成字元時,得到的結果具體是哪個時間,取決於DateFormat中設定的timeZone,若沒有指定,則預設值TimeZone.getDefault()

總結: Date類儲存的值是紀元毫秒數,與時區無關,不過內部中的一些方法返回值使用了預設時區來計算。

java.sql.Timestamp

java.sql.Timestamp繼承了java.util.Date,然後多加了一個nanos屬性用來儲存納秒,值為[0 , 999,999,999]中的一個。也就是說增加了精度,其餘和java.util.Date一樣。

Instant

Instant為JDK8新增的一個類,可以用來代替java.util.Date,內部儲存的是紀元納秒數,精度會更高。可以翻譯為時刻。

內部有兩個屬性

seconds:距離紀元時間的秒數

nanos:納秒數,[0, 999,999,999],用於提高精度。

建立方法

/**
 * 根據毫秒數建立
 */
public static Instant ofEpochMilli(long epochMilli);

/**
 * 一個引數是秒
 * 第二個引數是納秒數,可以為正也可以為負,用來調整最終的一個結果
 * 注意這兩個引數不等同於Instant中的連個成員變數seconds、nanos
 */
public static Instant ofEpochSecond(long epochSecond, long nanoAdjustment);

/**
 * 當前時間對應的時刻
 */
public static Instant now();

基本使用

@Test
public void testInstant() {
    // 該毫秒數對應UTC時間為2024-09-02 13:45:49
    long millis = 1725284749641L;
    Instant instant = Instant.ofEpochMilli(millis);
    // 2024-09-02T13:45:49.641Z
    System.out.println(instant);
    instant = Instant.parse("2024-09-02T13:45:49.641Z");
    // 1725284749641
    System.out.println(instant.toEpochMilli());
    // 配合時區轉成ZonedDateTime, 2024-09-02T21:45:49.641+08:00[Asia/Shanghai],可以看到時間加了8小時
    System.out.println(instant.atZone(ZoneId.of("Asia/Shanghai")));
    // 配合時區轉成OffsetDateTime,2024-09-02T21:45:49.641+08:00
    System.out.println(instant.atOffset(ZoneOffset.ofHours(8)));
}

java.util.Date格式化

@Test
public void testFormatDate() {
    // 該毫秒數對應UTC時間為2024-09-02 13:45:49
    long millis = 1725284749641L;
    Date date = new Date(millis);

    String pattern = "yyyy-MM-dd HH:mm:ss";
    /*
     * SimpleDateFormat執行緒不安全, 方法會修改自身
     * timeZone預設為TimeZone.getDefault(),國內即Asia/Shanghai
     */
    DateFormat dateFormat = new SimpleDateFormat(pattern);

    DateFormat dateFormatGmt9 = new SimpleDateFormat(pattern);
    dateFormatGmt9.setTimeZone(TimeZone.getTimeZone("GMT+09:00"));

    // 東八區: 2024-09-02 21:45:49
    System.out.println(dateFormat.format(date));
    // 東九區: 2024-09-02 22:45:49
    System.out.println(dateFormatGmt9.format(date));
}

JSR310時間類格式化

@Test
public void testFormatJsr310Date() {
    /*
     * 下面4個都是代表東八區的2024-09-02 12:45:49
     * LocalDateTime沒有時區概念
     * Instant + 東八區便能得到時間024-09-02 12:45:49
     */
    LocalDateTime localDateTime = LocalDateTime.of(2024,9, 2, 21, 45, 49);
    ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, ZoneId.of("Asia/Shanghai"));
    OffsetDateTime offsetDateTime = OffsetDateTime.of(localDateTime, ZoneOffset.ofHours(8));
    Instant instant = Instant.ofEpochMilli(1725284749641L);

    // 2024-09-02T21:45:49
    System.out.println(localDateTime);
    // 2024-09-02T21:45:49+08:00[Asia/Shanghai]
    System.out.println(zonedDateTime);
    // 2024-09-02T21:45:49+08:00
    System.out.println(offsetDateTime);
    /*
     * 2024-09-02T13:45:49.641Z
     * Instant toString方法展示的是UTC時區的時間, 末尾的Z代表UTC
     * 那麼對應於東八區的時間就是2024-09-02T21:45:49.64
     */
    System.out.println(instant);

    /*
     * DateTimeFormatter執行緒安全,和String一樣,為不可變物件
     * 與SimpleDateFormat不同,時區欄位預設是null,沒有預設值
     */
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    // 2024-09-02 21:45:49
    System.out.println(formatter.format(localDateTime));
    // 2024-09-02 21:45:49
    System.out.println(formatter.format(zonedDateTime));
    // 2024-09-02 21:45:49
    System.out.println(formatter.format(offsetDateTime));
    // 會報錯, 格式化成年月日這樣形式, Formatter必須要指定時區
    Assertions.assertThrowsExactly(UnsupportedTemporalTypeException.class, () -> formatter.format(instant));

    // 指定東九區時區
    DateTimeFormatter utc9Formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
            .withZone(ZoneOffset.ofHours(9));

    // 2024-09-02 21:45:49
    System.out.println(utc9Formatter.format(localDateTime));
    // 2024-09-02 22:45:49
    System.out.println(utc9Formatter.format(zonedDateTime));
    // 2024-09-02 22:45:49
    System.out.println(utc9Formatter.format(offsetDateTime));
    // 2024-09-02 22:45:49
    System.out.println(utc9Formatter.format(instant));
}

總結:

DateTimeFormatter是和String一樣的不可變物件,執行緒安全。因此修改時一定記得要返回,否則無效哦。

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
formatter = formatter.withZone(ZoneOffset.ofHours(9));

DateTimeFormatter中的時區屬性預設值為null,而不是系統所在時區,和SimpleDateFormat不同。

LocalDateTime格式化時不會進行時區轉換,因為本身沒有時區概念。

ZonedDateTimeOffsetDateTime格式化時,只有當DateTimeFormatter明確指定了時區才會進行轉換。ZonedDateTimeOffsetDateTime攜帶的時區為源,DateTimeFormatter中的時區為目標時區。

Instant格式化時,DateTimeFormatter必須指定時區,否則會報錯。畢竟紀元毫秒數(納秒數)搭配時區才能轉成時間。

相關文章