1. 程式人生 > >全網最全!徹底弄透Java處理GMT/UTC日期時間

全網最全!徹底弄透Java處理GMT/UTC日期時間

[TOC] ![](https://img-blog.csdnimg.cn/20210118055156568.jpg#pic_center) # 前言 你好,我是A哥(YourBatman)。 本系列的目的是明明白白、徹徹底底的搞定日期/時間處理的幾乎所有case。[上篇文章](https://mp.weixin.qq.com/s/VdoQt88JfjPJTL9XgohZJQ) 鋪設所有涉及到的概念解釋,例如GMT、UTC、夏令時、時間戳等等,若你還沒看過,不僅強烈建議而是**強制建議**你前往用花5分鐘看一下,因為日期時間處理較為特殊,實戰必須基於對概念的瞭解,否則很可能依舊霧裡看花。 > 說明:日期/時間的處理是日常開發非常常見的老大難,究其原因就是對日期時間的相關概念、應用場景不熟悉,所以不要忽視它 上篇概念,本文落地實操,二者相輔相成,缺一不可。本文內容較多,文字較長,預計超2w字,旨在全面的徹底幫你搞定Java對日期時間的處理,**建議你可收藏**,作為參考書留以備用。 ## 本文提綱 ![](https://img-blog.csdnimg.cn/20210118055954767.png#pic_center) ## 版本約定 - JDK:8 # 正文 上文鋪了這麼多概念,作為一枚Javaer最關心當然是這些“概念”在Java裡的落地。平時工作中遇到時間如何處理?用Date還是JDK 8之後的日期時間API?如何解決跨時區轉換等等頭大問題。A哥向來管生管養,管殺管埋,因此本文就帶你領略一下,Java是如何實現GMT和UTC的? 眾所周知,JDK以版本8為界,有兩套處理日期/時間的API: ![](https://img-blog.csdnimg.cn/20210115104456702.png#pic_center) 雖然我一直鼓勵棄用Date而支援在專案中只使用JSR 310日期時間型別,但是呢,由於Date依舊有龐大的存量使用者,所以本文也不落單,對二者的實現均進行闡述。 ## Date型別實現 java.util.Date在JDK 1.0就已存在,用於表示日期 + 時間的型別,縱使年代已非常久遠,並且此類的具有職責不單一,使用很不方便等諸多毛病,但由於十幾二十年的歷史原因存在,它的生命力依舊頑強,使用者量巨大。 先來認識下Date,看下這個例子的輸出: ```java @Test public void test1() { Date currDate = new Date(); System.out.println(currDate.toString()); // 已經@Deprecated System.out.println(currDate.toLocaleString()); // 已經@Deprecated System.out.println(currDate.toGMTString()); } ``` 執行程式,輸出: ```java Fri Jan 15 10:22:34 CST 2021 2021-1-15 10:22:34 15 Jan 2021 02:22:34 GMT ``` **第一個:標準的UTC時間(CST就代表了偏移量 +0800)** 第二個:本地時間,根據本地時區顯示的時間格式 第三個:GTM時間,也就是格林威治這個時候的時間,可以看到它是凌晨2點(北京時間是上午10點哦) 第二個、第三個其實在JDK 1.1就都標記為@Deprecated過期了,基本禁止再使用。若需要轉換為本地時間 or GTM時間輸出的話,請使用格式化器java.text.DateFormat去處理。 ### 時區/偏移量TimeZone 在JDK8之前,Java對時區和偏移量都是使用`java.util.TimeZone`來表示的。 一般情況下,使用靜態方法`TimeZone#getDefault()`即可獲得當前JVM所執行的時區,比如你在中國執行程式,這個方法返回的就是中國時區(也叫北京時區、北京時間)。 有的時候你需要做**帶時區**的時間轉換,譬如:介面返回值中既要有展示北京時間,也要展示紐約時間。這個時候就要獲取到紐約的時區,以北京時間為基準在其上進行帶時區轉換一把: ```java @Test public void test2() { String patternStr = "yyyy-MM-dd HH:mm:ss"; // 北京時間(new出來就是預設時區的時間) Date bjDate = new Date(); // 得到紐約的時區 TimeZone newYorkTimeZone = TimeZone.getTimeZone("America/New_York"); // 根據此時區 將北京時間轉換為紐約的Date DateFormat newYorkDateFormat = new SimpleDateFormat(patternStr); newYorkDateFormat.setTimeZone(newYorkTimeZone); System.out.println("這是北京時間:" + new SimpleDateFormat(patternStr).format(bjDate)); System.out.println("這是紐約時間:" + newYorkDateFormat.format(bjDate)); } ``` 執行程式,輸出: ```java 這是北京時間:2021-01-15 11:48:16 這是紐約時間:2021-01-14 22:48:16 ``` (11 + 24) - 22 = 13,北京比紐約快13個小時沒毛病。 > 注意:兩個時間表示的應該是同一時刻,也就是常說的時間戳值是相等的 那麼問題來了,你怎麼知道獲取紐約的時區用`America/New_York`這個zoneId呢?隨便寫個字串行不行? 答案是當然不行,這是有章可循的。下面我介紹兩種查閱zoneId的方式,任你挑選: **方式一**:用Java程式把所有可用的zoneId打印出來,然後查閱 ```java @Test public void test3() { String[] availableIDs = TimeZone.getAvailableIDs(); System.out.println("可用zoneId總數:" + availableIDs.length); for (String zoneId : availableIDs) { System.out.println(zoneId); } } ``` 執行程式,輸出(大部分符合規律:/前表示所屬州,/表示城市名稱): ```java 可用zoneId總數:628 Africa/Abidjan Africa/Accra ... Asia/Chongqing // 亞洲/重慶 Asia/Shanghai // 亞洲/上海 Asia/Dubai // 亞洲/迪拜 ... America/New_York // 美洲/紐約 America/Los_Angeles // 美洲/洛杉磯 ... Europe/London // 歐洲/倫敦 ... Etc/GMT Etc/GMT+0 Etc/GMT+1 ... ``` 值得注意的是並沒有 Asia/Beijing 哦。 > 說明:此結果基於JDK 8版本,不同版本輸出的總個數可能存在差異,但主流的ZoneId一般不會有變化 **方式二**: zoneId的列表是jre維護的一個文字檔案,路徑是你JDK/JRE的安裝路徑。地址在.\jre\lib目錄的為未`tzmappings`的文字檔案裡。開啟這個檔案去ctrl + f找也是可以達到查詢的目的的。 這兩種房子可以幫你找到ZoneId的字典方便查閱,但是還有這麼一種情況:當前所在的城市呢,在**tzmappings**檔案里根本沒有(比如沒有收錄),那要獲取這個地方的時間去顯示怎麼破呢?雖然概率很小,但不見得沒有嘛,畢竟全球那麼多國家那麼多城市呢~ Java自然也考慮到了這一點,因此也是有辦法的:指定其時區數字表示形式,其實也叫偏移量(不要告訴我這個地方的時區都不知道,那就真沒救了),如下示例 ```java @Test public void test4() { System.out.println(TimeZone.getTimeZone("GMT+08:00").getID()); System.out.println(TimeZone.getDefault().getID()); // 紐約時間 System.out.println(TimeZone.getTimeZone("GMT-05:00").getID()); System.out.println(TimeZone.getTimeZone("America/New_York").getID()); } ``` 執行程式,輸出: ```java GMT+08:00 // 效果等同於Asia/Shanghai Asia/Shanghai GMT-05:00 // 效果等同於America/New_York America/New_York ``` 值得注意的是,這裡只能用`GMT+08:00`,而不能用`UTC+08:00`,原因下文有解釋。 #### 設定預設時區 一般來說,JVM在哪裡跑,預設時區就是哪。對於國內程式設計師來講,一般只會接觸到東八區,也就是北京時間(本地時間)。隨著國際合作越來越密切,很多時候需要日期時間國際化處理,舉個很實際的例子:同一份應用在阿里雲部署、在AWS(海外)上也部署一份供海外使用者使用,此時**同一份程式碼**部署在不同的時區了,怎麼破? 倘若時區不同,那麼勢必影響到程式的執行結果,很容易帶來計算邏輯的錯誤,很可能就亂套了。Java讓我們有多種方式可以**手動**設定/修改預設時區: 1. API方式: 強制將時區設為北京時區`TimeZone.setDefault(TimeZone.getDefault().getTimeZone("GMT+8"));` 2. JVM引數方式:`-Duser.timezone=GMT+8` 3. 運維設定方式:將作業系統主機時區設定為北京時區,這是推薦方式,可以完全對開發者無感,也方便了運維統一管理 據我瞭解,很多公司在阿里雲、騰訊雲、國內外的雲主機上部署應用時,全部都是採用運維設定統一時區:中國時區,這種方式來管理的,這樣對程式來說就消除了預設時區不一致的問題,對開發者友好。 ### 讓人惱火的夏令時 你知道嗎,中國曾經也使用過夏令時。 > 什麼是夏令時?[戳這裡](https://mp.weixin.qq.com/s/VdoQt88JfjPJTL9XgohZJQ) 離現在最近是1986年至1991年用過夏令時(每年4月中旬的第一個週日2時 - 9月中旬的第一個星期日2時止): *1986年5月4日至9月14日* *1987年4月12日至9月13日* *1988年4月10日至9月11日* *1989年4月16日至9月17日* *1990年4月15日至9月16日* *1991年4月14日至9月15日* 夏令時是一個“非常煩人”的東西,大大的增加了日期時間處理的複雜度。比如這個靈魂拷問:若你的出生日期是1988-09-11 00:00:00(夏令時最後一天)且存進了資料庫,想一想,對此日期的格式化有沒有可能就會出問題呢,有沒有可能被你格式化成1988-09-10 23:00:00呢? 針對此拷問,我模擬瞭如下程式碼: ```java @Test public void test5() throws ParseException { String patterStr = "yyyy-MM-dd"; DateFormat dateFormat = new SimpleDateFormat(patterStr); String birthdayStr = "1988-09-11"; // 字串 -> Date -> 字串 Date birthday = dateFormat.parse(birthdayStr); long birthdayTimestamp = birthday.getTime(); System.out.println("老王的生日是:" + birthday); System.out.println("老王的生日的時間戳是:" + birthdayTimestamp); System.out.println("==============程式經過一番週轉,我的同時 方法入參傳來了生日的時間戳============="); // 字串 -> Date -> 時間戳 -> Date -> 字串 birthday = new Date(birthdayTimestamp); System.out.println("老王的生日是:" + birthday); System.out.println("老王的生日的時間戳是:" + dateFormat.format(birthday)); } ``` 這段程式碼,在不同的JDK版本下執行,**可能**出現不同的結果,有興趣的可copy過去自行試試。 關於JDK處理夏令時(特指中國的夏令時)確實出現過問題且造成過bug,當時對應的JDK版本是`1.8.0_2xx`之前版本格式化那個日期出問題了,在這之後的版本貌似就沒問題了。這裡我提供的版本資訊僅供參考,若有遇到類似case就升級JDK版本到最新吧,一般就不會有問題了。 > 發生這個情況是在JDK非常小的版本號之間,不太好定位精確版本號界限,所以僅供參考 總的來說,只要你使用的是較新版本的JDK,開發者是無需關心夏令時問題的,即使全球仍有很多國家在使用夏令時,咱們只需要面向**時區**做時間轉換就沒問題。 ### Date時區無關性 類Date表示一個特定的時間**瞬間**,精度為毫秒。既然表示的是瞬間/時刻,那它必然和時區是無關的,看下面程式碼: ```java @Test public void test6() { String patterStr = "yyyy-MM-dd HH:mm:ss"; Date currDate = new Date(System.currentTimeMillis()); // 北京時區 DateFormat bjDateFormat = new SimpleDateFormat(patterStr); bjDateFormat.setTimeZone(TimeZone.getDefault()); // 紐約時區 DateFormat newYorkDateFormat = new SimpleDateFormat(patterStr); newYorkDateFormat.setTimeZone(TimeZone.getTimeZone("America/New_York")); // 倫敦時區 DateFormat londonDateFormat = new SimpleDateFormat(patterStr); londonDateFormat.setTimeZone(TimeZone.getTimeZone("Europe/London")); System.out.println("毫秒數:" + currDate.getTime() + ", 北京本地時間:" + bjDateFormat.format(currDate)); System.out.println("毫秒數:" + currDate.getTime() + ", 紐約本地時間:" + newYorkDateFormat.format(currDate)); System.out.println("毫秒數:" + currDate.getTime() + ", 倫敦本地時間:" + londonDateFormat.format(currDate)); } ``` 執行程式,輸出: ```java 毫秒數:1610696040244, 北京本地時間:2021-01-15 15:34:00 毫秒數:1610696040244, 紐約本地時間:2021-01-15 02:34:00 毫秒數:1610696040244, 倫敦本地時間:2021-01-15 07:34:00 ``` 也就是說,同一個毫秒值,根據時區/偏移量的不同可以展示多地的時間,這就證明了Date它的時區無關性。 **確切的說:Date物件裡存的是自格林威治時間( GMT)1970年1月1日0點至Date所表示時刻所經過的毫秒數**,是個數值。 ### 讀取字串為Date型別 這是開發中極其常見的一種需求:client請求方扔給你一個字串如"2021-01-15 18:00:00",然後你需要把它轉為Date型別,怎麼破? 問題來了,光禿禿的扔給我個字串說是15號晚上6點時間,我咋知道你指的是北京的晚上6點,還是東京的晚上6點呢?還是紐約的晚上6點呢? ![](https://img-blog.csdnimg.cn/20210115154410245.png#pic_center) 因此,對於字串形式的日期時間,只有指定了時區才有意義。也就是說**字串 + 時區** 才能精確知道它是什麼時刻,否則是存在歧義的。 也許你可能會說了,自己平時開發中前端就是扔個字串給我,然後我就給格式化為一個Date型別,並沒有傳入時區引數,執行這麼久也沒見出什麼問題呀。如下所示: ```java @Test public void test7() throws ParseException { String patterStr = "yyyy-MM-dd HH:mm:ss"; // 模擬請求引數的時間字串 String dateStrParam = "2020-01-15 18:00:00"; // 模擬服務端對此服務換轉換為Date型別 DateFormat dateFormat = new SimpleDateFormat(patterStr); System.out.println("格式化器用的時區是:" + dateFormat.getTimeZone().getID()); Date date = dateFormat.parse(dateStrParam); System.out.println(date); } ``` 執行程式,輸出: ```java 格式化器用的時區是:Asia/Shanghai Wed Jan 15 18:00:00 CST 2020 ``` 看起來結果沒問題。事實上,這是因為預設情況下你們互動雙發就達成了契約:雙方均使用的是北京時間(時區),既然是相同時區,所以互通有無不會有任何問題。不信你把你介面給海外使用者除錯試? 對於格式化器來講,雖然說程式設計過程中一般情況下我們並不需要給DateFormat設定時區(那就用預設時區唄)就可正常轉換。但是作為高手的你必須清清楚楚,明明白白的知道這是由於互動雙發預設**有個相同時區的契約存在**。 ### SimpleDateFormat格式化 Java中對Date型別的輸入輸出/格式化,推薦使用DateFormat而非用其`toString()`方法。 DateFormat是一個時間格式化器抽象類,SimpleDateFormat是其具體實現類,用於以**語言環境敏感**的方式格式化和解析日期。它允許格式化(日期→文字)、解析(文字→日期)和規範化。 > 劃重點:對語言環境敏感,也就是說對環境Locale、時區TimeZone都是敏感的。既然敏感,那就是**可定製的** 對於一個格式化器來講,**模式**(模版)是其關鍵因素,瞭解一下: **日期/時間模式**: 格式化的模式由指定的字串組成,未加引號的大寫/小寫字母(A-Z a-z)代表特定模式,用來表示模式含義,若想**原樣輸出**可以用單引號''包起來,除了英文字母其它均不解釋原樣輸出/匹配。下面是它規定的模式字母(其它字母原樣輸出): 字母 | 含義 | 匹配型別 | 示例 -------- | ----- | ----- | ----- **y** | 年 | Year | 2020,20 **M** | 月 | Month | July; Jul; 07 **d** | 月中的天數(俗稱日,最大值31) | Number | 10 **H** | 小時(0-23) | Number| 0,23 **m** | 分鐘(0-59) | Number | 30,59 **s** | 秒(0-59) | Number | 30,59 --- | --- | --- | yyyy-MM-dd HH:mm:ss(分隔符可以是任意字元,甚至漢字) **Y** | 當前周所在的年份 | Year | 2020(不建議使用,周若跨年有坑) **S** | 毫秒數(1-999) | Number | 999 **a** | am/pm | Text | PM **z** | 時區 | 通用時區 | Pacific Standard Time; PST; GMT-08:00 **Z** | 時區 | RFC 822時區 | -0800,+0800 **X** | 時區 | ISO 8601時區 | -08; -0800; -08:00 **G** | 年代 | Text | AD(公元)、BC(公元前) **D** | 年中的天數(1-366) | Number | 360 **w** | 年中的週數(1-54) | Number | 27 **W** | 月中的週數(1-5) | Number | 3 **E** | 星期幾名稱 | Text | Tuesday; Tue **u** | 星期幾數字(1=Monday...) | Number | 1 **k** | 小時(1-24) | Number | 不建議使用 **K/h** | am/pm小時數字 | Number | 一般配合a一起使用 這個表格裡出現了一些“特殊”的匹配型別,做如下解釋: - **Text**:格式化(Date -> String),如果模式字母的數目是4個或更多,則使用完整形式;否則,如果可能的話,使用簡短或縮寫形式。對於解析(String -> Date),這兩種形式都一樣,與模式字母的數量無關 ```java @Test public void test9() throws ParseException { String patternStr = "G GG GGGGG E EE EEEEE a aa aaaaa"; Date currDate = new Date(); System.out.println("↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓中文地區模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓"); System.out.println("====================Date->String===================="); DateFormat dateFormat = new SimpleDateFormat(patternStr, Locale.CHINA); System.out.println(dateFormat.format(currDate)); System.out.println("====================String->Date===================="); String dateStrParam = "公元 公元 公元 星期六 星期六 星期六 下午 下午 下午"; System.out.println(dateFormat.parse(dateStrParam)); System.out.println("↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓英文地區模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓"); System.out.println("====================Date->String===================="); dateFormat = new SimpleDateFormat(patternStr, Locale.US); System.out.println(dateFormat.format(currDate)); System.out.println("====================String->Date===================="); dateStrParam = "AD ad bC Sat SatUrday sunDay PM PM Am"; System.out.println(dateFormat.parse(dateStrParam)); } ``` 執行程式,輸出: ```java ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓中文地區模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ ====================Date->String==================== 公元 公元 公元 星期六 星期六 星期六 下午 下午 下午 ====================String->Date==================== Sat Jan 03 12:00:00 CST 1970 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓英文地區模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ ====================Date->String==================== AD AD AD Sat Sat Saturday PM PM PM ====================String->Date==================== Sun Jan 01 00:00:00 CST 1970 ``` 觀察列印結果,除了符合模式規則外,還能在**String -> Date解析時**總結出兩點結論: 1. 英文單詞,不分割槽大小寫。如SatUrday sunDay都是沒問題,但是**不能**有拼寫錯誤 2. 若有多個part表示一個意思,那麼last win。如Sat SatUrday sunDay最後一個生效 對於Locale地域引數,因為中文不存在格式、縮寫方面的特性,因此這些規則只對英文地域(如Locale.US生效) - **Number**:格式化(Date -> String),模式字母的數量是數字的【最小】數量,較短的數字被零填充到這個數量。對於解析(String -> Date),模式字母的數量將被忽略,除非需要分隔兩個相鄰的欄位 - **Year**:對於格式化和解析,如果模式字母的數量是4個或更多,則使用特定於日曆的長格式。否則,使用日曆特定的簡短或縮寫形式 - **Month**:如果模式字母的數量是3個或更多,則被解釋為文字;否則,它將被解釋為一個數字。 - **通用時區**:如果該時區有名稱,如Pacific Standard Time、PST、CST等那就用名稱,否則就用GMT規則的字串,如:GMT-08:00 - **RFC 822時區**:遵循RFC 822格式,向下相容通用時區(名稱部分除外) - **ISO 8601時區**:對於格式化,如果與GMT的偏移值為0(也就是格林威治時間嘍),則生成“Z”;如果模式字母的數量為1,則忽略小時的任何分數。例如,如果模式是“X”,時區是“GMT+05:30”,則生成“+05”。在進行解析時,“Z”被解析為UTC時區指示符。一般時區不被接受。如果模式字母的數量是4個或更多,在構造SimpleDateFormat或應用模式時丟擲IllegalArgumentException。 - 這個規則理解起來還是比較費勁的,在開發中一般不太建議使用此種模式。若要使用請務必本地做好測試 SimpleDateFormat的使用很簡單,重點是瞭解其規則模式。最後關於SimpleDateFormat的使用再強調這兩點哈: 1. SimpleDateFormat並非執行緒安全類,使用時請務必注意併發安全問題 2. 若使用SimpleDateFormat去格式化成非本地區域(預設Locale)的話,那就必須在構造的時候就指定好,如Locale.US 3. 對於Date型別的任何格式化、解析請統一使用SimpleDateFormat ## JSR 310型別 曾經有個人做了個很有意思的投票,統計對Java API的**不滿意**程度。最終Java Date/Calendar API斬獲第二爛(第一爛是Java XML/DOM),體現出它爛的點較多,這裡給你例舉幾項: 1. 定義並不一致,在java.util和java.sql包中竟然都有Date類,而且呢對它進行格式化/解析類竟然又跑到java.text去了,精神分裂啊 2. java.util.Date等類在建模日期的設計上行為不一致,缺陷明顯。包括易變性、糟糕的偏移值、預設值、命名等等 3. java.util.Date同時包含日期和時間,而其子類java.sql.Date卻僅包含日期,這是什麼神繼承? ![](https://img-blog.csdnimg.cn/20210116214805226.png#pic_center) ```java @Test public void test10() { long currMillis = System.currentTimeMillis(); java.util.Date date = new Date(currMillis); java.sql.Date sqlDate = new java.sql.Date(currMillis); java.sql.Time time = new Time(currMillis); java.sql.Timestamp timestamp = new Timestamp(currMillis); System.out.println("java.util.Date:" + date); System.out.println("java.sql.Date:" + sqlDate); System.out.println("java.sql.Time:" + time); System.out.println("java.sql.Timestamp:" + timestamp); } ``` 執行程式,輸出: ```java java.util.Date:Sat Jan 16 21:50:36 CST 2021 java.sql.Date:2021-01-16 java.sql.Time:21:50:36 java.sql.Timestamp:2021-01-16 21:50:36.733 ``` - 國際化支援得並不是好,比如跨時區操作、夏令時等等 Java 自己也實在忍不了這麼難用的日期時間API了,於是在2014年隨著Java 8的釋出引入了全新的JSR 310日期時間。JSR-310源於精品時間庫joda-time打造,解決了上面提到的**所有問題**,是整個Java 8最大亮點之一。 JSR 310日期/時間 **所有的** API都在java.time這個包內,沒有例外。 ![](https://img-blog.csdnimg.cn/20210117054303212.png#pic_center) 當然嘍,本文重點並不在於討論JSR 310日期/時間體系,而是看看JSR 310日期時間型別是如何處理上面Date型別遇到的那些case的。 ### 時區/偏移量ZoneId 在JDK 8之前,Java使用`java.util.TimeZone`來表示時區。而在JDK 8裡分別使用了ZoneId表示時區,ZoneOffset表示UTC的偏移量。 值得提前強調,時區和偏移量在概念和實際作用上是有較大區別的,主要體現在: 1. UTC偏移量僅僅記錄了偏移的小時分鐘而已,除此之外無任何其它資訊。舉個例子:+08:00的意思是比UTC時間早8小時,沒有地理/時區含義,相應的-03:30代表的意思僅僅是比UTC時間晚3個半小時 2. 時區是特定於地區而言的,它和地理上的地區(包括規則)強繫結在一起。比如整個中國都叫東八區,紐約在西五區等等 > 中國沒有夏令時,所有東八區對應的偏移量永遠是+8;紐約有夏令時,因此它的偏移量可能是-4也可能是-5哦 **綜合來看,時區更好用**。令人惱火的夏令時問題,若你使用UTC偏移量去表示那麼就很麻煩,因為它可變:一年內的某些時期在原來基礎上偏移量 +1,某些時期 -1;但若你使用ZoneId時區去表示就很方便嘍,比如紐約是西五區,你在任何時候獲取其當地時間都是能得到正確答案的,因為它內建了對夏令時規則的處理,也就是說啥時候+1啥時候-1時區自己門清,不需要API呼叫者關心。 UTC偏移量更像是一種寫死偏移量數值的做法,這在天朝這種沒有時區規則(沒有夏令時)的國家不會存在問題,東八區和UTC+08:00效果永遠一樣。但在一些夏令時國家(如美國、法國等等),就只能根據時區去獲取當地時間嘍。所以當你不瞭解當地規則時,最好是使用時區而非偏移量。 #### ZoneId ![](https://img-blog.csdnimg.cn/20210117061051448.png#pic_center) 它代表一個時區的ID,如Europe/Paris。它規定了一些規則可用於將一個Instant時間戳轉換為本地日期/時間LocalDateTime。 上面說了時區ZoneId是包含有規則的,實際上描述偏移量何時以及如何變化的實際規則由`java.time.zone.ZoneRules`定義。ZoneId則只是一個用於獲取底層規則的ID。之所以採用這種方法,是因為**規則是由政府定義的,並且經常變化,而ID是穩定的**。 對於API呼叫者來說只需要使用這個ID(也就是ZoneId)即可,而需無關心更為底層的時區規則ZoneRules,和“政府”同步規則的事是它領域內的事就交給它嘍。如:夏令時這條規則是由各國政府制定的,而且不同國家不同年一般都不一樣,這個事就交由JDK底層的ZoneRules機制自行sync,使用者無需關心。 ZoneId在系統內是唯一的,它共包含三種類型的ID: 1. 最簡單的ID型別:ZoneOffset,它由'Z'和以'+'或'-'開頭的id組成。如:Z、+18:00、-18:00 2. 另一種型別的ID是帶有某種字首形式的偏移樣式ID,例如'GMT+2'或'UTC+01:00'。可識別的(合法的)字首是'UTC', 'GMT'和'UT' 3. 第三種類型是基於區域的ID(推薦使用)。基於區域的ID必須包含兩個或多個字元,且不能以'UTC'、'GMT'、'UT' '+'或'-'開頭。基於區域的id由配置定義好的,如Europe/Paris 概念說了一大推,下面給幾個程式碼示例感受下吧。 1、獲取系統預設的ZoneId: ```java @Test public void test1() { // JDK 1.8之前做法 System.out.println(TimeZone.getDefault()); // JDK 1.8之後做法 System.out.println(ZoneId.systemDefault()); } 輸出: Asia/Shanghai sun.util.calendar.ZoneInfo[id="Asia/Shanghai",offset=28800000,dstSavings=0,useDaylight=false,transitions=29,lastRule=null] ``` 二者結果是一樣的,都是Asia/Shanghai。因為ZoneId方法底層就是依賴TimeZone,如圖: ![](https://img-blog.csdnimg.cn/20210117064928306.png#pic_center) ![](https://img-blog.csdnimg.cn/20210117065004959.png#pic_center) 2、指定字串得到一個ZoneId: ```java @Test public void test2() { System.out.println(ZoneId.of("Asia/Shanghai")); // 報錯:java.time.zone.ZoneRulesException: Unknown time-zone ID: Asia/xxx System.out.println(ZoneId.of("Asia/xxx")); } ``` 很明顯,這個字串也是不能隨便寫的。那麼問題來了,可寫的有哪些呢?同樣的ZoneId提供了API供你獲取到所有可用的字串id,有興趣的同學建議自行嘗試: ```java @Test public void test3() { ZoneId.getAvailableZoneIds(); } ``` 3、根據偏移量得到一個ZoneId: ```java @Test public void test4() { ZoneId zoneId = ZoneId.ofOffset("UTC", ZoneOffset.of("+8")); System.out.println(zoneId); // 必須是大寫的Z zoneId = ZoneId.ofOffset("UTC", ZoneOffset.of("Z")); System.out.println(zoneId); } 輸出: UTC+08:00 UTC ``` 這裡第一個引數傳的字首,可用值為:"GMT", "UTC", or "UT"。當然還可以傳空串,那就直接返回第二個引數ZoneOffset。若以上都不是就報錯 注意:根據偏移量得到的ZoneId內部並無現成時區規則可用,因此對於有夏令營的國家轉換可能出問題,一般不建議這麼去做。 4、從日期裡面獲得時區: ```java @Test public void test5() { System.out.println(ZoneId.from(ZonedDateTime.now())); System.out.println(ZoneId.from(ZoneOffset.of("+8"))); // 報錯:java.time.DateTimeException: Unable to obtain ZoneId from TemporalAccessor: System.out.println(ZoneId.from(LocalDateTime.now())); System.out.println(ZoneId.from(LocalDate.now())); } ``` 雖然方法入參是TemporalAccessor,但是隻接受帶時區的型別,LocalXXX是不行的,使用時稍加註意。 #### ZoneOffset 距離格林威治/UTC的時區偏移量,例如+02:00。值得注意的是它繼承自ZoneId,所以也可當作一個ZoneId來使用的,當然並不建議你這麼去做,請獨立使用。 時區偏移量是時區與格林威治/UTC之間的時間差。這通常是固定的小時數和分鐘數。世界不同的地區有不同的時區偏移量。在ZoneId類中捕獲關於偏移量如何隨一年的地點和時間而變化的規則(主要是夏令時規則),所以繼承自ZoneId。 1、最小/最大偏移量:因為偏移量傳入的是數字,這個是有限制的哦 ```java @Test public void test6() { System.out.println("最小偏移量:" + ZoneOffset.MIN); System.out.println("最小偏移量:" + ZoneOffset.MAX); System.out.println("中心偏移量:" + ZoneOffset.UTC); // 超出最大範圍 System.out.println(ZoneOffset.of("+20")); } 輸出: 最小偏移量:-18:00 最小偏移量:+18:00 中心偏移量:Z java.time.DateTimeException: Zone offset hours not in valid range: value 20 is not in the range -18 to 18 ``` 2、通過時分秒構造偏移量(使用很方便,推薦): ```java @Test public void test7() { System.out.println(ZoneOffset.ofHours(8)); System.out.println(ZoneOffset.ofHoursMinutes(8, 8)); System.out.println(ZoneOffset.ofHoursMinutesSeconds(8, 8, 8)); System.out.println(ZoneOffset.ofHours(-5)); // 指定一個精確的秒數 獲取例項(有時候也很有用處) System.out.println(ZoneOffset.ofTotalSeconds(8 * 60 * 60)); } // 輸出: +08:00 +08:08 +08:08:08 -05:00 +08:00 ``` 看來,偏移量是能精確到秒的哈,只不過一般來說精確到分鐘已經到頂了。 ##### 設定預設時區 ZoneId並沒有提供設定預設時區的方法,但是通過文章可知ZoneId獲取預設時區底層依賴的是`TimeZone.getDefault()`方法,因此設定預設時區方式完全遵照TimeZone的方式即可(共三種方式,還記得嗎?)。 ### 讓人惱火的夏令時 因為有夏令時規則的存在,讓操作日期/時間的複雜度大大增加。但還好JDK儘量的遮蔽了這些規則對使用者的影響。因此:推薦使用時區(ZoneId)轉換日期/時間,一般情況下不建議使用偏移量ZoneOffset去搞,這樣就不會有夏令時的煩惱啦。 ### JSR 310時區相關性 java.util.Date型別它具有時區無關性,帶來的弊端就是一旦涉及到國際化時間轉換等需求時,使用Date來處理是很不方便的。 JSR 310解決了Date存在的一系列問題:對日期、時間進行了分開表示(LocalDate、LocalTime、LocalDateTime),對本地時間和帶時區的時間進行了分開管理。LocalXXX表示本地時間,也就是說是當前JVM所在時區的時間;ZonedXXX表示是一個**帶有時區**的日期時間,它們能非常方便的互相完成轉換。 ```java @Test public void test8() { // 本地日期/時間 System.out.println("================本地時間================"); System.out.println(LocalDate.now()); System.out.println(LocalTime.now()); System.out.println(LocalDateTime.now()); // 時區時間 System.out.println("================帶時區的時間ZonedDateTime================"); System.out.println(ZonedDateTime.now()); // 使用系統時區 System.out.println(ZonedDateTime.now(ZoneId.of("America/New_York"))); // 自己指定時區 System.out.println(ZonedDateTime.now(Clock.systemUTC())); // 自己指定時區 System.out.println("================帶時區的時間OffsetDateTime================"); System.out.println(OffsetDateTime.now()); // 使用系統時區 System.out.println(OffsetDateTime.now(ZoneId.of("America/New_York"))); // 自己指定時區 System.out.println(OffsetDateTime.now(Clock.systemUTC())); // 自己指定時區 } ``` 執行程式,輸出: ```java ================本地時間================ 2021-01-17 09:18:40.703 2021-01-17T09:18:40.703 ================帶時區的時間ZonedDateTime================ 2021-01-17T09:18:40.704+08:00[Asia/Shanghai] 2021-01-16T20:18:40.706-05:00[America/New_York] 2021-01-17T01:18:40.709Z ================帶時區的時間OffsetDateTime================ 2021-01-17T09:18:40.710+08:00 2021-01-16T20:18:40.710-05:00 2021-01-17T01:18:40.710Z ``` 本地時間的輸出非常“乾淨”,可直接用於顯示。帶時區的時間顯示了該時間代表的是哪個時區的時間,畢竟不指定時區的時間是沒有任何意義的。LocalXXX因為它具有時區無關性,因此它不能代表一個瞬間/時刻。 另外,關於LocalDateTime、OffsetDateTime、ZonedDateTime三者的跨時區轉換問題,以及它們的詳解,因為內容過多放在了下文專文闡述,保持關注。 ### 讀取字串為JSR 310型別 一個獨立的日期時間型別字串如2021-05-05T18:00-04:00它是沒有任何意義的,因為沒有時區無法確定它代表那個瞬間,這是理論當然也適合JSR 310型別嘍。 遇到一個日期時間格式字串,要解析它一般有這兩種情況: 1. 不帶時區/偏移量的字串:要麼不理它說轉換不了,要麼就**約定一個時區**(一般用系統預設時區),使用LocalDateTime來解析 ```java @Test public void test11() { String dateTimeStrParam = "2021-05-05T18:00"; LocalDateTime localDateTime = LocalDateTime.parse(dateTimeStrParam); System.out.println("解析後:" + localDateTime); } 輸出: 解析後:2021-05-05T18:00 ``` 2. 帶時區字/偏移量的符串: ```java @Test public void test12() { // 帶偏移量 使用OffsetDateTime String dateTimeStrParam = "2021-05-05T18:00-04:00"; OffsetDateTime offsetDateTime = OffsetDateTime.parse(dateTimeStrParam); System.out.println("帶偏移量解析後:" + offsetDateTime); // 帶時區 使用ZonedDateTime dateTimeStrParam = "2021-05-05T18:00-05:00[America/New_York]"; ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateTimeStrParam); System.out.println("帶時區解析後:" + zonedDateTime); } 輸出: 帶偏移量解析後:2021-05-05T18:00-04:00 帶時區解析後:2021-05-05T18:00-04:00[America/New_York] ``` 請注意**帶時區解析後**這個結果:字串引數偏移量明明是-05,為毛轉換為ZonedDateTime後偏移量成為了-04呢??? 這裡是我故意造了這麼一個case引起你的重視,對此結果我做如下解釋: ![](https://img-blog.csdnimg.cn/20210117194528171.png#pic_center) 如圖,在2021.03.14 - 2021.11.07期間,紐約的偏移量是-4,其餘時候是-5。本例的日期是2021-05-05處在夏令時之中,因此偏移量是-4,這就解釋了為何你顯示的寫了-5最終還是成了-4。 ### JSR 310格式化 針對JSR 310日期時間型別的格式化/解析,有個專門的類`java.time.format.DateTimeFormatter`用於處理。 DateTimeFormatter也是一個不可變的類,所以是執行緒安全的,比SimpleDateFormat靠譜多了吧。另外它還內建了非常多的格式化模版**例項**供以使用,形如: 格式化器 | 示例 -------- | ----- ofLocalizedDate(dateStyle) | '2021-01-03' ofLocalizedTime(timeStyle) | '10:15:30' ofLocalizedDateTime(dateTimeStyle) | '3 Jun 2021 11:05:30' **ISO_LOCAL_DATE** | '2021-12-03' **ISO_LOCAL_TIME** | '10:15:30' **ISO_LOCAL_DATE_TIME** | '2021-12-03T10:15:30' ISO_OFFSET_DATE_TIME | '2021-12-03T10:15:30+01:00' ISO_ZONED_DATE_TIME | '2021-12-03T10:15:30+01:00[Europe/Paris]' ```java @Test public void test13() { System.out.println(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now())); System.out.println(DateTimeFormatter.ISO_LOCAL_TIME.format(LocalTime.now())); System.out.println(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now())); } 輸出: 2021-01-17 22:43:21.398 2021-01-17T22:43:21.4 ``` 若想自定義模式pattern,和Date一樣它也可以自己指定任意的pattern **日期/時間模式**。由於本文在Date部分詳細介紹了日期/時間模式,各個字母代表什麼意思以及如何使用,這裡就不再贅述了哈。 > 雖然DateTimeFormatter支援的模式比Date略有增加,但大體還保持一致,個人覺得這塊無需再花精力。若真有需要再查官網也不遲 ```java @Test public void test14() { DateTimeFormatter formatter = DateTimeFormatter.ofPattern("第Q季度 yyyy-MM-dd HH:mm:ss", Locale.US); // 格式化輸出 System.out.println(formatter.format(LocalDateTime.now())); // 解析 String dateTimeStrParam = "第1季度 2021-01-17 22:51:32"; LocalDateTime localDateTime = LocalDateTime.parse(dateTimeStrParam, formatter); System.out.println("解析後的結果:" + localDateTime); } ``` Q/q:季度,如3; 03; Q3; 3rd quarter。 ## 最佳實踐 - **棄用Date,擁抱JSR 310** 每每說到JSR 310日期/時間時我都會呼籲,保持慣例我這裡繼續囉嗦一句:放棄Date甚至禁用Date,使用JSR 310日期/時間吧,它才是日期時間處理的最佳實踐。 另外,在使用期間關於制定時區(預設時區時)依舊有一套我心目中的最佳實踐存在,這裡分享給你: - **永遠顯式的指定你需要的時區,即使你要獲取的是預設時區** ```java // 方式一:普通做法 LocalDateTime.now(); // 方式二:最佳實踐 LocalDateTime.now(ZoneId.systemDefault()); ``` 如上程式碼二者效果一模一樣。但是方式二是最佳實踐。 理由是:這樣做能讓程式碼帶有**明確的意圖**,消除模稜兩可的可能性,即使獲取的是預設時區。拿方式一來說吧,它就存在意圖不明確的地方:到底是程式碼編寫者忘記指定時區欠考慮了,還是就想用預設時區呢?這個答案如果不通讀上下文是無法確定的,從而造成了不必要的溝通維護成本。因此即使你是要獲取預設時區,也請顯示的用ZoneId.systemDefault()寫上去。 - **使用JVM的預設時區需當心,建議時區和當前會話保持繫結** 這個最佳實踐在特殊場景用得到。這麼做的理由是:JVM的預設時區通過靜態方法TimeZone#setDefault()可全域性設定,因此JVM的任何一個執行緒都可以隨意更改預設時區。若關於時間處理的程式碼**對時區非常敏感**的話,最佳實踐是你把時區資訊和當前會話繫結,這樣就可以不用再受到其它執行緒潛在影響了,確保了健壯性。 > 說明:會話可能只是當前請求,也可能是一個Session,具體case具體分析 # 總結 通過[上篇文章](https://mp.weixin.qq.com/s/VdoQt88JfjPJTL9XgohZJQ) 對日期時間相關概念的鋪墊,加上本文的實操程式碼演示,達到弄透Java對日期時間的處理基本不成問題。 兩篇文章的內容較多,資訊量均比較大,消化起來需要些時間。一方面我建議你先蒐藏留以當做參考書備用,另一方面建議多實踐,程式碼這東西只有多寫寫才能有更深體會。 後面會**再用3 -4篇文章**對這前面這兩篇的細節、使用場景進行補充,比如如何去匹配ZoneId和Offset的對應關係,LocalDateTime、OffsetDateTime、ZonedDateTime跨時區互轉問題、在Spring MVC場景下使用的最佳實踐等等,敬請關注,一起進步。 ## 本文思考題 看完了不一定懂,看懂了不一定會。來,文末3個思考題幫你覆盤: 1. Date型別如何處理夏令時? 2. ZoneId和ZoneOffset有什麼區別? 3. 平時專案若遇到日期時間的處理,有哪些最佳實踐? ## 推薦閱讀 [**GMT UTC CST ISO 夏令時 時間戳,都是些什麼鬼?**](https://mp.weixin.qq.com/s/VdoQt88JfjPJTL9XgohZJQ) ## 關注我 分享、成長,拒絕淺藏輒止。關注【BAT的烏托邦】回覆關鍵字**專欄**有Spring技術棧、中介軟體等小而美的純原創專欄。本文已被 [https://www.yourbatman.cn](https://www.yourbatman.cn) 收錄。 本文所屬專欄:**JDK日期時間**,公號後臺回覆專欄名即可獲取全部內容。 A哥(YourBatman):Spring Framework/Boot開源貢獻者,Java架構師。非常注重**基本功修養**,相信底層基礎決定上層建築,堅實基礎才能煥發程式設計師更強生命力。文章特點為以小而美專欄形式重構知識體系,抽絲剝繭,致力於做人人能看懂的最好的專欄系列。可加我好友(**fsx1056342982**)共勉哦! ![](https://img-blog.csdnimg.cn/20210121074215630.gif#pic_center)