JAVA中計算兩個日期時間的差值竟然也有這麼多門道

語言: CN / TW / HK

theme: healer-readable

上半年春招的時候,作為面試官,對於面試表現的不錯的同學會要求其寫一小段程式碼看看。題目很簡單:

給定一個日期,然後計算下距離今天相差的天數。

本以為這麼個問題就是用來活躍面試氛圍的,但是結果卻讓人大跌眼鏡,真正能寫出來的人竟然寥寥無幾,很多人寫了一整張A4紙都寫不下,最後還是沒寫完...他們在做什麼?

先取出今天的日期,然後分別計算得出年、月、日的值,然後將給定的字串進行切割,得到目標的年、月、日,然後再判斷是否閏年之類的邏輯,決定每月應該是加28天還是29天還是30或者31天,最後得出一個天數!

想想都令人窒息的操作...

日期時間的處理,是軟體開發中極其常見的場景,JAVA中與日期、時間相關的一些類與API方法也很多,這裡結合平時的編碼實踐全面的整理了下,希望可以幫助大家釐清其中的門道,更加遊刃有餘的面對此方面的處理~

JAVA中與日期時間相關的類

java.util包中

| 類名 | 具體描述 | | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Date | Date物件算是JAVA中歷史比較悠久的用於處理日期、時間相關的類了,但是隨著版本的迭代演進,其中的眾多方法都已經被棄用,所以Date更多的時候僅被用來做一個數據型別使用,用於記錄對應的日期與時間資訊 | | Calender | 為了彌補Date物件在日期時間處理方法上的一些缺陷,JAVA提供了Calender抽象類來輔助實現Date相關的一些日曆日期時間的處理與計算。 | | TimeZone | Timezone類提供了一些有用的方法用於獲取時區的相關資訊 |

java.time包中

JAVA8之後新增了java.time包,提供了一些與日期時間有關的新實現類:

具體每個類對應的含義說明梳理如下表:

| 類名 | 含義說明 | | -------------- | -------------------------------------------------------------------------------------------------- | | LocalDate | 獲取當前的日期資訊,僅有簡單的日期資訊,不包含具體時間、不包含時區資訊。 | | LocalTime | 獲取當前的時間資訊,僅有簡單的時間資訊,不含具體的日期、時區資訊。 | | LocalDateTime | 可以看做是LocalDate和LocalTime的組合體,其同時含有日期資訊與時間資訊,但是依舊不包含任何時區資訊。 | | OffsetDateTime | 在LocalDateTime基礎上增加了時區偏移量資訊 | | ZonedDateTime | 在OffsetDateTime基礎上,增加了時區資訊 | | ZoneOffset | 時區偏移量資訊, 比如+8:00或者-5:00等 | | ZoneId | 具體的時區資訊,比如Asia/Shanghai或者America/Chicago |

時間間隔計算

Period與Duration類

JAVA8開始新增的java.time包中有提供DurationPeriod兩個類,用於處理日期時間間隔相關的場景,兩個類的區別點如下:

| 類 | 描述 | | --- | --- | | Duration | 時間間隔,用於秒級的時間間隔計算 | | Period| 日期間隔,用於天級別的時間間隔計算,比如年月日維度的 |

DurationPeriod具體使用的時候還需要有一定的甄別,因為部分的方法很容易使用中被混淆,下面分別說明下。

  • Duration

Duration的最小計數單位為納秒,其內部使用secondsnanos兩個欄位來進行組合計數表示duration總長度。

Duration的常用API方法梳理如下:

| 方法 | 描述 | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | between | 計算兩個時間的間隔,預設是 | | ofXxx | 以of開頭的一系列方法,表示基於給定的值建立一個Duration例項。比如ofHours(2L),則表示建立一個Duration物件,其值為間隔2小時 | | plusXxx | 以plus開頭的一系列方法,用於在現有的Duration值基礎上增加對應的時間長度,比如plusDays()表示追加多少天,或者plusMinutes()表示追加多少分鐘 | | minusXxx | 以minus開頭的一系列方法,用於在現有的Duration值基礎上扣減對應的時間長度,與plusXxx相反 | | toXxxx | 以to開頭的一系列方法,用於將當前Duration物件轉換為對應單位的long型資料,比如toDays()表示將當前的時間間隔的值,轉換為相差多少天,而toHours()則標識轉換為相差多少小時。 | | getSeconds | 獲取當前Duration物件對應的秒數, 與toXxx方法類似,只是因為Duration使用秒作為計數單位,所以直接通過get方法即可獲取到值,而toDays()是需要通過將秒數轉為天數換算之後返回結果,所以提供的方法命名上會有些許差異。 | | getNano | 獲取當前Duration對應的納秒數“零頭”。注意這裡與toNanos()不一樣,toNanos是Duration值的納秒單位總長度,getNano()只是獲取不滿1s剩餘的那個零頭,以納秒錶示。 | | isNegative | 檢查Duration例項是否小於0,若小於0返回true, 若大於等於0返回false | | isZero | 用於判斷當前的時間間隔值是否為0 ,比如比較兩個時間是否一致,可以通過between計算出Duration值,然後通過isZero判斷是否沒有差值。 | | withSeconds | 對現有的Duration物件的nanos零頭值不變的情況下,變更seconds部分的值,然後返回一個新的Duration物件 | | withNanos | 對現有的Duration物件的seconds值不變的情況下,變更nanos部分的值,然後返回一個新的Duration物件 |

關於Duration的主要API的使用,參見如下示意:

```java

public void testDuration() { LocalTime target = LocalTime.parse("00:02:35.700"); // 獲取當前日期,此處為了保證後續結果固定,注掉自動獲取當前日期,指定固定日期 // LocalDate today = LocalDate.now(); LocalTime today = LocalTime.parse("12:12:25.600"); // 輸出:12:12:25.600 System.out.println(today); // 輸出:00:02:35.700 System.out.println(target); Duration duration = Duration.between(target, today); // 輸出:PT12H9M49.9S System.out.println(duration); // 輸出:43789 System.out.println(duration.getSeconds()); // 輸出:900000000 System.out.println(duration.getNano()); // 輸出:729 System.out.println(duration.toMinutes()); // 輸出:PT42H9M49.9S System.out.println(duration.plusHours(30L)); // 輸出:PT15.9S System.out.println(duration.withSeconds(15L)); } ```

  • Period

Period相關介面與Duration類似,其計數的最小單位是,看下Period內部時間段記錄採用了年、月、日三個field來記錄:

常用的API方法列舉如下:

| 方法 | 描述 | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | between | 計算兩個日期之間的時間間隔。注意,這裡只能計算出相差幾年幾個月幾天。 | | ofXxx | of()或者以of開頭的一系列static方法,用於基於傳入的引數構造出一個新的Period物件 | | withXxx | 以with開頭的方法,比如withYearswithMonthswithDays等方法,用於對現有的Period物件中對應的年、月、日等欄位值進行修改(只修改對應的欄位,比如withYears方法,只修改year,保留month和day不變),並生成一個新的Period物件 | | getXxx | 讀取Period中對應的yearmonthday欄位的值。注意下,這裡是僅get其中的一個欄位值,而非整改Period的不同單位維度的總值。 | | plusXxx | 對指定的欄位進行追加數值操作 | | minusXxx | 對指定的欄位進行扣減數值操作 | | isNegative | 檢查Period例項是否小於0,若小於0返回true, 若大於等於0返回false | | isZero | 用於判斷當前的時間間隔值是否為0 ,比如比較兩個時間是否一致,可以通過between計算出Period值,然後通過isZero判斷是否沒有差值。 |

關於Period的主要API的使用,參見如下示意:

```java

public void calculateDurationDays() { LocalDate target = LocalDate.parse("2021-07-11"); // 獲取當前日期,此處為了保證後續結果固定,注掉自動獲取當前日期,指定固定日期 // LocalDate today = LocalDate.now(); LocalDate today = LocalDate.parse("2022-07-08"); // 輸出:2022-07-08 System.out.println(today); // 輸出:2021-07-11 System.out.println(target); Period period = Period.between(target, today); // 輸出:P11M27D, 表示11個月27天 System.out.println(period); // 輸出:0, 因為period值為11月27天,即year欄位為0 System.out.println(period.getYears()); // 輸出:11, 因為period值為11月27天,即month欄位為11 System.out.println(period.getMonths()); // 輸出:27, 因為period值為11月27天,即days欄位為27 System.out.println(period.getDays()); // 輸出:P14M27D, 因為period為11月27天,加上3月,變成14月27天 System.out.println(period.plusMonths(3L)); // 輸出:P11M15D,因為period為11月27天,僅將days值設定為15,則變為11月15天 System.out.println(period.withDays(15)); // 輸出:P2Y3M44D System.out.println(Period.of(2, 3, 44)); }

```

Duration與Period踩坑記

Duration與Period都是用於日期之間的計算操作。Duration主要用於秒、納秒等維度的資料處理與計算。Period主要用於計算年、月、日等維度的資料處理與計算

先看個例子,計算兩個日期相差的天數,使用Duration的時候:

```java

public void calculateDurationDays(String targetDate) { LocalDate target = LocalDate.parse(targetDate); LocalDate today = LocalDate.now(); System.out.println("today : " + today); System.out.println("target: " + target); long days = Duration.between(target, today).abs().toDays(); System.out.println("相差:" + days + "天"); }

```

執行後會報錯:

```

today : 2022-07-07 target: 2022-07-11 Exception in thread "main" java.time.temporal.UnsupportedTemporalTypeException: Unsupported unit: Seconds at java.time.LocalDate.until(LocalDate.java:1614) at java.time.Duration.between(Duration.java:475) at com.veezean.demo5.DateService.calculateDurationDays(DateService.java:24)

```

點選看下Duration.between原始碼,可以看到註釋上明確有標註著,這個方法是用於秒級的時間段間隔計算,而我們這裡傳入的是兩個級別的資料,所以就不支援此型別運算,然後拋異常了。

再看下使用Period的實現:

```java

public void calculateDurationDays(String targetDate) { LocalDate target = LocalDate.parse(targetDate); LocalDate today = LocalDate.now(); System.out.println("today : " + today); System.out.println("target: " + target); // 注意,此處寫法錯誤!這裡容易踩坑: long days = Math.abs(Period.between(target, today).getDays()); System.out.println("相差:" + days + "天"); }

```

執行結果:

``` today : 2022-07-07 target: 2021-07-07 相差:0天

```

執行是不報錯,但是結果明顯是錯誤的。這是因為getDays()並不會將Period值換算為天數,而是單獨計算年、月、日,此處只是返回天數這個單獨的值。

再看下面的寫法:

```java

public void calculateDurationDays(String targetDate) { LocalDate target = LocalDate.parse(targetDate); LocalDate today = LocalDate.now(); System.out.println("today : " + today); System.out.println("target: " + target); Period between = Period.between(target, today); System.out.println("相差:" + Math.abs(between.getYears()) + "年" + Math.abs(between.getMonths()) + "月" + Math.abs(between.getDays()) + "天"); }

```

結果為:

```

today : 2022-07-07 target: 2021-07-11 相差:0年11月26天

```

所以說,如果想要計算兩個日期之間相差的絕對天數,用Period不是一個好的思路

計算日期差

  • 通過LocalDate來計算

LocalDate中的toEpocDay可返回當前時間距離原點時間之間的天數,可以基於這一點,來實現計算兩個日期之間相差的天數:

程式碼如下:

```java

public void calculateDurationDays(String targetDate) { LocalDate target = LocalDate.parse(targetDate); LocalDate today = LocalDate.now(); System.out.println("today : " + today); System.out.println("target: " + target); long days = Math.abs(target.toEpochDay() - today.toEpochDay()); System.out.println("相差:" + days + "天"); }

```

結果為:

```

today : 2022-07-07 target: 2021-07-11 相差:361天

```

  • 通過時間戳來計算

如果是使用的Date物件,則可以通過將Date日期轉換為毫秒時間戳的方式相減然後將毫秒數轉為天數的方式來得到結果。需要注意的是通過毫秒數計算日期天數的差值時,需要遮蔽掉時分秒帶來的誤差影響

```java

public void calculateDaysGap(Date start, Date end) { final long ONE_DAY_MILLIS = 1000L * 60 * 60 * 24; // 此處要注意,去掉時分秒的差值影響,此處採用先換算為天再相減的方式 long gapDays = Math.abs(end.getTime()/ONE_DAY_MILLIS - start.getTime()/ONE_DAY_MILLIS); System.out.println(gapDays); }

```

輸出結果:

```

today : 2022-07-08 target: 2021-07-11 相差:362天

```

  • 數學邏輯計算

分別算出年、月、日差值,然後根據是否閏年、每月是30還是31天等計數邏輯,純數學硬懟方式計算。

不推薦、程式碼略...

計算介面處理耗時

在一些效能優化的場景中,我們需要獲取到方法處理的執行耗時,很多人都是這麼寫的:

```java

public void doSomething() { // 記錄開始時間戳 long startMillis = System.currentTimeMillis(); // do something ...

// 計算結束時間戳
long endMillis = System.currentTimeMillis();

// 計算相差的毫秒數
System.out.println(endMillis - startMillis);

}

```

當然啦,如果你使用的是JDK8+的版本,你還可以這麼寫:

```java

public void doSomething() { // 記錄開始時間戳 Instant start = Instant.now(); // do something ...

// 計算結束時間戳
Instant end = Instant.now();

// 計算相差的毫秒數
System.out.println(Duration.between(start, end).toMillis());

}

```

時間格式轉換

專案中,時間格式轉換是一個非常典型的日期處理操作,可能會涉及到將一個字串日期轉換為JAVA物件,或者是將一個JAVA日期物件轉換為指定格式的字串日期時間。

SimpleDataFormat實現

在JAVA8之前,通常會使用SimpleDateFormat類來處理日期與字串之間的相互轉換:

```java

public void testDateFormatter() { SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); // 日期轉字串 String format = simpleDateFormat.format(new Date()); System.out.println("當前時間:" + format);

try {
    // 字串轉日期
    Date parseDate = simpleDateFormat.parse("2022-07-08 06:19:27");
    System.out.println("轉換後Date物件: " + parseDate);
    // 按照指定的時區進行轉換,可以對比下前面轉換後的結果,會發現不一樣
    simpleDateFormat.setTimeZone(TimeZone.getTimeZone("GMT+5:00"));
    parseDate = simpleDateFormat.parse("2022-07-08 06:19:27");
    System.out.println("指定時區轉換後Date物件: " + parseDate);
} catch (Exception e) {
    e.printStackTrace();
}

}

```

輸出結果如下:

```

當前時間:2022-07-08 06:25:31 轉換後Date物件: Fri Jul 08 06:19:27 CST 2022 指定時區轉換後Date物件: Fri Jul 08 09:19:27 CST 2022

```

補充說明:

SimpleDateFormat物件是非執行緒安全的,所以專案中在封裝為工具方法使用的時候需要特別留意,最好結合ThreadLocal來適應在多執行緒場景的正確使用。 JAVA8之後,推薦使用DateTimeFormat替代SimpleDateFormat。

DataTimeFormatter實現

JAVA8開始提供DataTimeFormatter作為新的用於日期與字串之間轉換的類,它很好的解決了SimpleDateFormat多執行緒的弊端,也可以更方便的與java.time中心的日期時間相關類的整合呼叫。

```java

public void testDateFormatter() { DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); LocalDateTime localDateTime = LocalDateTime.now(); // 格式化為字串 String format = localDateTime.format(dateTimeFormatter); System.out.println("當前時間:" + format); // 字串轉Date LocalDateTime parse = LocalDateTime.parse("2022-07-08 06:19:27", dateTimeFormatter); Date date = Date.from(parse.atZone(ZoneId.systemDefault()).toInstant()); System.out.println("轉換後Date物件: " + date); }

```

輸出結果:

```

當前時間:2022-07-08 18:37:46 轉換後Date物件: Fri Jul 08 06:19:27 CST 2022

```

日期時間格式模板

對於計算機而言,時間處理的時候按照基於時間原點的數字進行處理即可,但是轉為人類方便識別的場景顯示時,經常會需要轉換為不同的日期時間顯示格式,比如:

```

2022-07-08 12:02:34 2022/07/08 12:02:34.238 2022年07月08日 12點03分48秒

```

在JAVA中,為了方便各種格式轉換,提供了基於時間模板進行轉換的實現能力:

時間格式模板中的字幕含義說明如下:

| 字母 | 使用說明 | |---|---| | yyyy | 4位數的年份 | | yy | 顯示2位數的年份,比如2022年,則顯示為22年 | | MM | 顯示2位數的月份,不滿2位數的,前面補0,比如7月份顯示07月 | | M | 月份,不滿2位的月份不會補0 | | dd | 天, 如果1位數的天數,則補0 | | d | 天,不滿2位數字的,不補0 | | HH | 24小時制的時間顯示,小時數,兩位數,不滿2位數字的前面補0 | | H | 24小時制的時間顯示,小時數,不滿2位數字的不補0 | | hh | 12小時制的時間顯示,小時數,兩位數,不滿2位數字的前面補0 | | ss | 秒數,不滿2位的前面補0 | | s | 秒數,不滿2位的不補0 | | SSS | 毫秒數 | | z | 時區名稱,比如北京時間東八區,則顯示CST | | Z | 時區偏移資訊,比如北京時間東八區,則顯示+0800 |

消失的8小時問題

日期字串存入DB後差8小時

在後端與資料庫互動的時候,可能會遇到一個問題,就是往DB中儲存了一個時間欄位之後,後面再查詢的時候,就會發現時間數值差了8個小時,這個需要在DB的連線資訊中指定下時區資訊:

```

spring.datasource.druid.url=jdbc:mysql://127.0.0.1:3306/test?serverTimezone=Asia/Shanghai

```

介面時間與後臺時間差8小時

在有一些前後端互動的專案中,可能會遇到一個問題,就是前端選擇並儲存了一個時間資訊,再查詢的時候就會發現與設定的時間差了8個小時,這個其實就是後端時區轉換設定的問題。

SpringBoot的配置檔案中,需要指定時間字串轉換的時區資訊:

```

spring.jackson.time-zone=GMT+8

```

這樣從介面json中傳遞過來的時間資訊,jackson框架可以根據對應時區轉換為正確的Date資料進行處理。


我是悟道,聊技術、又不僅僅聊技術~

如果覺得有用,請點個關注,也可以關注下我的公眾號【架構悟道】,獲取更及時的更新。

期待與你一起探討,一起成長為更好的自己。


我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿