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

2022-07-08 21:01:05

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

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

本以為這麼個問題就是用來活躍面試氛圍的,但是結果卻讓人大跌眼鏡,真正能寫出來的人竟然寥寥無幾,很多人寫了一整張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包中有提供Duration和Period兩個類,用於處理日期時間間隔相關的場景,兩個類的區別點如下:

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

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

  • Duration

Duration的最小計數單位為納秒,其內部使用seconds和nanos兩個欄位來進行組合計數表示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的使用,參見如下示意:


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開頭的方法,比如withYears、withMonths、withDays等方法,用於對現有的Period物件中對應的年、月、日等欄位值進行修改(只修改對應的欄位,比如withYears方法,只修改year,保留month和day不變),並生成一個新的Period物件
getXxx 讀取Period中對應的year、month、day欄位的值。注意下,這裡是僅get其中的一個欄位值,而非整改Period的不同單位維度的總值。
plusXxx 對指定的欄位進行追加數值操作
minusXxx 對指定的欄位進行扣減數值操作
isNegative 檢查Period範例是否小於0,若小於0返回true, 若大於等於0返回false
isZero 用於判斷當前的時間間隔值是否為0 ,比如比較兩個時間是否一致,可以通過between計算出Period值,然後通過isZero判斷是否沒有差值。

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


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的時候:


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的實現:


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值換算為天數,而是單獨計算年、月、日,此處只是返回天數這個單獨的值。

再看下面的寫法:


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可返回當前時間距離原點時間之間的天數,可以基於這一點,來實現計算兩個日期之間相差的天數:

程式碼如下:


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日期轉換為毫秒時間戳的方式相減然後將毫秒數轉為天數的方式來得到結果。需要注意的是通過毫秒數計算日期天數的差值時,需要遮蔽掉時分秒帶來的誤差影響。


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天等計數邏輯,純數學硬懟方式計算。

不推薦、程式碼略...

計算介面處理耗時

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


public void doSomething() {
    // 記錄開始時間戳
    long startMillis = System.currentTimeMillis();
    // do something ...
    
    // 計算結束時間戳
    long endMillis = System.currentTimeMillis();
    
    // 計算相差的毫秒數
    System.out.println(endMillis - startMillis);
}

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


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類來處理日期與字串之間的相互轉換:


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


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資料進行處理。


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

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

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