計算兩個日期相差天數是業務開發高頻需求,但陷阱重重:時區、夏令時、閏年、毫秒除法精度丟失。本文系統梳理 5 種主流算法,從舊 `Date/Calendar` 到現代 `java.time`,提供生產級工具類、單元測試用例與選型建議,助你徹底告別日期計算 Bug。
| 陷阱 | 錯誤示例 | 後果 | 正確做法 |
|---|---|---|---|
| 直接毫秒相減除 86400000 | `(d2.getTime()-d1.getTime())/(1000*60*60*24)` | 跨夏令時/閏秒/時區導致 ±1 天 | 置零時分秒毫秒後相減,或用 ChronoUnit |
| Calendar.DAY_OF_YEAR 跨年 | `cal2.get(DAY_OF_YEAR)-cal1.get(DAY_OF_YEAR)` | 跨年結果為負、閏年 366 天未處理 | 逐年累加 getActualMaximum(DAY_OF_YEAR) |
| SimpleDateFormat 線程不安全 | static 共享 sdf | 高併發解析亂碼/異常 | 局部變量/ThreadLocal/DateTimeFormatter |
| Date 可變性導致副作用 | 傳入 Date 被內部修改 | 調用方數據被竄改 | 防御性拷貝/用不可變 LocalDate |
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
public class DateDiffUtils {
/**
* 計算兩個日期相差天數(自然日,忽略時間部分)
* @param start 開始日期(包含)
* @param end 結束日期(不包含),若 end < start 返回負數
* @return 相差天數
*/
public static long daysBetween(LocalDate start, LocalDate end) {
if (start == null || end == null) throw new IllegalArgumentException("Date cannot be null");
return ChronoUnit.DAYS.between(start, end);
}
/**
* 兼容字符串輸入(ISO-8601: yyyy-MM-dd)
*/
public static long daysBetween(String start, String end) {
return daysBetween(LocalDate.parse(start), LocalDate.parse(end));
}
/**
* 兼容 Date/Instant 輸入(按系統默認時區截取日期部分)
*/
public static long daysBetween(java.util.Date start, java.util.Date end) {
if (start == null || end == null) throw new IllegalArgumentException("Date cannot be null");
ZoneId zone = ZoneId.systemDefault();
LocalDate sd = start.toInstant().atZone(zone).toLocalDate();
LocalDate ed = end.toInstant().atZone(zone).toLocalDate();
return ChronoUnit.DAYS.between(sd, ed);
}
}
public static long daysBetweenManual(LocalDate start, LocalDate end) {
if (start.isAfter(end)) return -daysBetweenManual(end, start);
long days = 0;
LocalDate cursor = start;
while (cursor.isBefore(end)) {
cursor = cursor.plusDays(1);
days++;
}
return days;
}
邏輯清晰但 O(n) 性能差,僅供教學或極小範圍使用。
public static int daysBetweenCalendar(java.util.Date start, java.util.Date end) {
if (start == null || end == null) throw new IllegalArgumentException("Date cannot be null");
Calendar cal = Calendar.getInstance();
cal.setTime(start);
// 置零時分秒毫秒
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.clear(Calendar.MINUTE, Calendar.SECOND, Calendar.MILLISECOND);
long startDay = cal.getTimeInMillis();
cal.setTime(end);
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.clear(Calendar.MINUTE, Calendar.SECOND, Calendar.MILLISECOND);
long endDay = cal.getTimeInMillis();
return (int) ((endDay - startDay) / (1000 * 60 * 60 * 24));
}
注意:`clear(int... fields)` 為 Java 8+ 方法,JDK 7 需分別 `clear(MINUTE)` 等。
// 依賴:joda-time:joda-time:2.12.7
import org.joda.time.LocalDate;
import org.joda.time.Days;
public static int daysBetweenJoda(String start, String end) {
LocalDate sd = LocalDate.parse(start); // ISO 格式
LocalDate ed = LocalDate.parse(end);
return Days.daysBetween(sd, ed).getDays();
}
Joda-Time 已進入維護模式,新項目請遷移至 `java.time`。
-- MySQL
SELECT DATEDIFF('2024-03-15', '2024-01-01'); -- 74
-- PostgreSQL
SELECT ('2024-03-15'::date - '2024-01-01'::date); -- 74
-- Oracle
SELECT DATE '2024-03-15' - DATE '2024-01-01' FROM dual; -- 74
-- SQL Server
SELECT DATEDIFF(DAY, '2024-01-01', '2024-03-15'); -- 74
海量數據統計時,下推到數據庫層面計算效率最高。
package com.example.util;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Date;
public final class DateDiffUtils {
private DateDiffUtils() {}
private static final DateTimeFormatter[] COMMON_PATTERNS = {
DateTimeFormatter.ISO_LOCAL_DATE,
DateTimeFormatter.ofPattern("yyyyMMdd"),
DateTimeFormatter.ofPattern("yyyy/MM/dd"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss")
};
/**
* 核心:自然日差值
*/
public static long daysBetween(LocalDate start, LocalDate end) {
return ChronoUnit.DAYS.between(start, end);
}
/**
* 字符串輸入(自動嘗試常見格式)
*/
public static long daysBetween(String startStr, String endStr) {
LocalDate start = parseFlexible(startStr);
LocalDate end = parseFlexible(endStr);
return daysBetween(start, end);
}
/**
* Date/Instant 輸入(指定時區,默認系統區)
*/
public static long daysBetween(Date start, Date end) {
return daysBetween(start, end, ZoneId.systemDefault());
}
public static long daysBetween(Date start, Date end, ZoneId zone) {
if (start == null || end == null) throw new IllegalArgumentException("Date cannot be null");
LocalDate sd = start.toInstant().atZone(zone).toLocalDate();
LocalDate ed = end.toInstant().atZone(zone).toLocalDate();
return daysBetween(sd, ed);
}
/**
* 靈活解析字符串為 LocalDate
*/
public static LocalDate parseFlexible(String str) {
if (str == null || str.isEmpty()) throw new IllegalArgumentException("Empty date string");
for (DateTimeFormatter fmt : COMMON_PATTERNS) {
try {
return LocalDate.parse(str, fmt);
} catch (DateTimeParseException ignored) {}
}
throw new IllegalArgumentException("Unparsable date: " + str);
}
/**
* 計算年齡/工齡等「滿年」數
*/
public static int yearsBetween(LocalDate start, LocalDate end) {
return (int) ChronoUnit.YEARS.between(start, end);
}
/**
* 計算月數差
*/
public static int monthsBetween(LocalDate start, LocalDate end) {
return (int) ChronoUnit.MONTHS.between(start, end);
}
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DateDiffUtilsTest {
@Test void basic() {
assertEquals(1, DateDiffUtils.daysBetween("2024-01-01", "2024-01-02"));
assertEquals(-1, DateDiffUtils.daysBetween("2024-01-02", "2024-01-01"));
assertEquals(0, DateDiffUtils.daysBetween("2024-01-01", "2024-01-01"));
}
@Test void leapYear() {
// 2024 閏年 2 月 29 天
assertEquals(1, DateDiffUtils.daysBetween("2024-02-28", "2024-02-29"));
assertEquals(1, DateDiffUtils.daysBetween("2024-02-29", "2024-03-01"));
}
@Test void crossYear() {
assertEquals(366, DateDiffUtils.daysBetween("2024-01-01", "2025-01-01")); // 閏年
assertEquals(365, DateDiffUtils.daysBetween("2023-01-01", "2024-01-01")); // 平年
}
@Test void formats() {
assertEquals(10, DateDiffUtils.daysBetween("20240101", "20240111"));
assertEquals(10, DateDiffUtils.daysBetween("2024/01/01", "2024/01/11"));
assertEquals(10, DateDiffUtils.daysBetween("2024-01-01 00:00:00", "2024-01-11 23:59:59"));
}
@Test void dateInput() {
java.util.Date d1 = java.util.Date.from(LocalDate.of(2024,1,1).atStartOfDay(ZoneId.systemDefault()).toInstant());
java.util.Date d2 = java.util.Date.from(LocalDate.of(2024,1,11).atStartOfDay(ZoneId.systemDefault()).toInstant());
assertEquals(10, DateDiffUtils.daysBetween(d1, d2));
}
}
| 場景 | 推薦方案 | 理由 |
|---|---|---|
| Java 8+ 新項目 | ChronoUnit.DAYS.between(LocalDate, LocalDate) | 標準、不可變、線程安全、自動處理閏年/時區 |
| JDK 7 維護項目 | Calendar 置零後毫秒相減 | 無第三方依賴、邏輯可控 |
| 舊項目已用 Joda-Time | Days.daysBetween() | 零改動、穩定 |
| 海量統計/報表 | SQL DATEDIFF | 下推計算、避免數據傳輸 |
| 需支援多種字符串格式 | 工具類 parseFlexible + ChronoUnit | 兼容性強、統一入口 |
日期計算看似簡單,實則暗藏時區、夏令時、閏年、不可變性等陷阱。確立 `java.time` + `ChronoUnit` 為唯一標準,封裝統一工具類,配合完整測試用例,才能從根源消除日期 Bug,讓業務邏輯專注於核心價值。