java.util.Calendar 是 Java 1.1 時代引入的日期時間抽象,雖然 Java 8+ 推出 java.time (JSR-310) 作為現代替代,但大量存量系統、Android 平台、第三方庫仍廣泛使用 Calendar。本文系統梳理其核心架構、17 個欄位語義、關鍵 API、常見陷阱與遷移建議。
一、核心架構設計
工廠模式獲取實例
// 1. 默認 Locale/TimeZone → GregorianCalendar (公曆)
Calendar cal = Calendar.getInstance();
// 2. 指定時區/地區 → 可能返回 JapaneseImperialCalendar, BuddhistCalendar 等
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("Asia/Tokyo"), Locale.JAPAN);
內部雙重狀態機制
- 時間戳:
protected long time — 毫秒級絕對時間 (自 1970-01-01 UTC)。
- 欄位快取:
protected int[] fields — 17 個日曆欄位 (YEAR, MONTH...)。
- 狀態標記:
isTimeSet, areFieldsSet, areAllFieldsSet、stamp[] (修改時間戳)。
核心不變量:time 與 fields[] 雙向同步。get() 觸發 complete() → computeFields() (time → fields);set() 修改 fields[] 並標記 isTimeSet=false,下次 getTime() 觸發 updateTime() → computeTime() (fields → time)。
二、17 個欄位完整語義表
| 常量 | 索引 | 含義 | 取值範圍/說明 |
ERA | 0 | 紀元 | 0=BC (公元前), 1=AD (公元) |
YEAR | 1 | 年 | 如 2024 |
MONTH | 2 | 月 (0 基) | 0=JANUARY ... 11=DECEMBER, 12=UNDECIMBER (希伯來曆) |
WEEK_OF_YEAR | 3 | 年中第幾週 | 1~53,依 setFirstDayOfWeek/setMinimalDaysInFirstWeek |
WEEK_OF_MONTH | 4 | 月內第幾週 | 1~6 |
DATE / DAY_OF_MONTH | 5 | 月內第幾天 | 1~31 |
DAY_OF_YEAR | 6 | 年中第幾天 | 1~366 |
DAY_OF_WEEK | 7 | 星期幾 | 1=SUNDAY ... 7=SATURDAY |
DAY_OF_WEEK_IN_MONTH | 8 | 月中第幾個該星期幾 | 1~5 (如「第 2 個週三」) |
AM_PM | 9 | 上午/下午 | 0=AM, 1=PM |
HOUR | 10 | 12 小時制時 | 0~11 (0 代表 12 點) |
HOUR_OF_DAY | 11 | 24 小時制時 | 0~23 |
MINUTE | 12 | 分 | 0~59 |
SECOND | 13 | 秒 | 0~59 |
MILLISECOND | 14 | 毫秒 | 0~999 |
ZONE_OFFSET | 15 | 時區偏移 (毫秒) | 不含夏令時,如 GMT+8 = 28800000 |
DST_OFFSET | 16 | 夏令時偏移 (毫秒) | 通常 0 或 3600000 |
三、關鍵 API 分類詳解
1. 獲取/設置欄位
int year = cal.get(Calendar.YEAR); // 獲取
cal.set(Calendar.YEAR, 2024); // 設置單欄位
cal.set(2024, Calendar.JUNE, 15); // 設置 年月日
cal.set(2024, Calendar.JUNE, 15, 14, 30, 0); // 設置 年月日時分秒
2. 日期運算:add vs roll 核心差異
| 方法 | 行為 | 影響大欄位 | 典型場景 |
add(field, amount) | 增加/減少指定欄位,**自動進位/借位** | 是 | 「3 個月後」、「10 天前」、「明年這天」 |
roll(field, amount) | 在欄位最小/最大值間**循環滾動**,**不影響大欄位** | 否 | 「下一個月」(年份不變)、「下一小時」(日期不變) |
Calendar cal = Calendar.getInstance();
cal.set(2024, Calendar.JUNE, 15); // 2024-06-15
cal.add(Calendar.MONTH, 3); // 2024-09-15 (年份可能變)
cal.roll(Calendar.MONTH, 3); // 2024-09-15 (年份不變,若 12 月 roll +1 → 1 月同年)
cal.add(Calendar.MONTH, -10); // 2023-08-15 (年份變)
cal.roll(Calendar.MONTH, -10); // 2024-08-15 (年份不變!)
3. 邊界查詢
int maxDay = cal.getActualMaximum(Calendar.DATE); // 當月實際天數 (28/29/30/31)
int minDay = cal.getActualMinimum(Calendar.DATE); // 總是 1
int maxMonth = cal.getMaximum(Calendar.MONTH); // 11 (DECEMBER) - 全年最大
int actualMaxMonth = cal.getActualMaximum(Calendar.MONTH); // 視當年曆法,通常也是 11
4. 寬容/非寬容模式
cal.setLenient(true); // 默認:允許月份 13 → 自動進位到明年 1 月
cal.setLenient(false); // 嚴格:月份 13 → get() 時拋 IllegalArgumentException
// 非寬容模式下,set() 不檢查,get()/getTime()/add()/roll() 才檢查拋異常
5. 日期比較
Calendar c1 = ..., c2 = ...;
boolean before = c1.before(c2);
boolean after = c1.after(c2);
boolean equals = c1.equals(c2); // 比較 time + 時區 + 首周設置等所有狀態
int cmp = c1.compareTo(c2); // -1/0/1
6. 時區與毫秒互轉
cal.setTimeZone(TimeZone.getTimeZone("UTC"));
Date date = cal.getTime(); // Calendar → Date
cal.setTime(new Date()); // Date → Calendar
long ms = cal.getTimeInMillis(); // Calendar → 毫秒時間戳
cal.setTimeInMillis(System.currentTimeMillis()); // 毫秒 → Calendar
四、常見陷阱與避坑指南
| 陷阱 | 現象 | 根因 | 修正 |
| MONTH 0 基 | 設 6 月傳 6 → 實際 7 月 | JANUARY=0 | 用常量 Calendar.JUNE 或 month-1 |
| WEEK_OF_YEAR 邊界 | 1 月 1 日屬於上一年第 52/53 週 | 首周定義 | 配置 setFirstDayOfWeek/setMinimalDaysInFirstWeek |
| 可變性共享 | 多線程共享 Calendar 實例數據錯亂 | 非線程安全 | **每線程獨立實例** 或用 ThreadLocal,或遷移 java.time |
| set() 不即時生效 | set 後立即 get 其他欄位未更新 | 惰性計算 | 調用 getTime() 或 getTimeInMillis() 強制同步 |
| ZONE_OFFSET/DST_OFFSET 不可 roll | roll 這兩欄位拋異常 | 設計限制 | 用 setTimeZone() 管理時區 |
五、自定義工具類封裝 (生產就緒)
public final class Calendars {
private Calendars() {}
public static int year(Calendar c) { return c.get(Calendar.YEAR); }
public static int month(Calendar c) { return c.get(Calendar.MONTH); } // 0-based
public static int month1(Calendar c) { return month(c) + 1; } // 1-based
public static int day(Calendar c) { return c.get(Calendar.DATE); }
public static int dayOfWeek(Calendar c) { return c.get(Calendar.DAY_OF_WEEK); } // 1=Sun
public static int daysInMonth(Calendar c) { return c.getActualMaximum(Calendar.DATE); }
public static int daysInPrevMonth(Calendar c) {
Calendar tmp = (Calendar) c.clone();
tmp.add(Calendar.MONTH, -1);
return tmp.getActualMaximum(Calendar.DATE);
}
public static int firstDayOfWeek(Calendar c) {
Calendar tmp = (Calendar) c.clone();
tmp.set(Calendar.DATE, 1);
return tmp.get(Calendar.DAY_OF_WEEK);
}
public static int lastDayOfWeek(Calendar c) {
Calendar tmp = (Calendar) c.clone();
tmp.set(Calendar.DATE, 1);
tmp.roll(Calendar.DATE, -1);
return tmp.get(Calendar.DAY_OF_WEEK);
}
// ... 更多常用封裝
}
六、遷移到 java.time (JSR-310) 的對照表
| Calendar | java.time (Java 8+) | 優勢 |
Calendar.getInstance() | LocalDate.now() / ZonedDateTime.now() | 不可變、線程安全、領域模型清晰 |
cal.set(y, m, d) | LocalDate.of(y, m+1, d) | 月份 1-based,符合直覺 |
cal.add(MONTH, 3) | date.plusMonths(3) | 流式 API、不可變返回新對象 |
cal.getTimeInMillis() | zonedDateTime.toInstant().toEpochMilli() | 明確時區語義 |
cal.setTimeZone(tz) | zonedDateTime.withZoneSameInstant(ZoneId) | 不可變轉換 |
SimpleDateFormat | DateTimeFormatter | 線程安全、預定義格式豐富 |
七、總結
- Calendar 核心是
time (long) ↔ fields[17] (int[]) 雙向同步,理解這點可解決 90% 困惑。
- MONTH 0-based、WEEK_OF_YEAR 依賴首周配置、add/roll 進位差異 是三大核心認知點。
- 非線程安全、可變性 是最大架構缺陷,新專案**強制使用 java.time**。
- 存量系統維護時,封裝工具類、嚴格單線程使用、遷移邊界隔離。
Calendar 是 Java 日期時間處理的「上一代標準」,理解其設計權衡與陷阱,既能維護舊代碼,也能更深刻體會 java.time 不可變、領域驅動設計的優雅。