三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Java Calendar类深度解析:历史遗留类的核心缺陷与现代API迁移指南

Java Calendar类深度解析:历史遗留类的核心缺陷与现代API迁移指南

1. 项目概述:为什么我们还在讨论Calendar?

如果你是一个Java初学者,或者刚从其他语言转过来,第一次接触Java的日期时间处理,大概率会先遇到java.util.Date,然后很快就会被它的各种“反人类”设计搞得晕头转向。接着,你可能会在搜索引擎或者老项目的代码里,发现一个叫Calendar的类。它看起来比Date强大,能加减年月日,能获取星期几,似乎解决了Date的不少问题。

但我要告诉你一个可能会让你惊讶的事实:在今天的Java开发中,Calendar类已经是一个“不推荐在新代码中使用”的遗留类了。从Java 8开始,官方就推出了全新的日期时间API(java.time包),它更清晰、更强大、更不易出错。那为什么我们还要花时间学习Calendar呢?原因很现实:存量代码面试八股文

大量的老系统、遗留框架(比如一些老版本的Spring、Hibernate配置)以及第三方库的内部实现,依然在使用Calendar。作为开发者,你不可避免地需要阅读、维护甚至修改这些代码。同时,Calendar的诸多“坑点”和设计缺陷,是Java面试中经久不衰的经典题目,理解它有助于你更深刻地理解为什么新的java.timeAPI如此设计。

所以,这篇指南的目的不是鼓励你在新项目中使用Calendar,而是帮你彻底搞懂这个“历史文物”,让你能从容应对老代码,并在面试中游刃有余。我们会从它的核心设计缺陷讲起,再到每一个关键方法的使用和陷阱,最后对比现代API,让你知其然,更知其所以然。

2. Calendar的核心设计:可变性与反直觉的月份

Calendar是一个抽象类,你不能直接new Calendar()。最常用的实现是GregorianCalendar,即公历日历。它的核心设计思想在今天看来有两个非常突出的问题。

2.1 可变性(Mutability):一切错误的根源

Calendar对象是可变的(mutable)。这意味着你调用一个方法(比如add,set)后,对象内部的状态就被永久改变了。这在多线程环境下是灾难性的,也违背了函数式编程中“不可变对象更安全”的理念。

Calendar cal1 = Calendar.getInstance(); cal1.set(2023, Calendar.DECEMBER, 25); // 设置圣诞节 Calendar cal2 = cal1; // cal2 和 cal1 指向同一个对象! cal2.add(Calendar.DATE, 7); // 给 cal2 加7天 System.out.println(cal1.getTime()); // 输出:Mon Jan 01 00:00:00 CST 2024 // 你只是想操作cal2,结果cal1也被意外修改了!

注意:这是Calendar最经典的坑之一。如果你需要基于一个现有日期进行计算并保留原日期,必须先克隆(clone)一个副本

Calendar cal1 = Calendar.getInstance(); cal1.set(2023, Calendar.DECEMBER, 25); Calendar cal2 = (Calendar) cal1.clone(); // 关键:克隆! cal2.add(Calendar.DATE, 7); // 此时cal1仍然是圣诞节,cal2是新年

2.2 反直觉的常量:月份从0开始

这是另一个让无数新手(甚至老手)抓狂的设计。在Calendar中,表示月份的常量Calendar.JANUARY的值是0Calendar.DECEMBER的值是11

Calendar cal = Calendar.getInstance(); cal.set(2023, 11, 25); // 你想设置12月25日?错了!这里11代表12月。 // 更清晰的写法(但仍然容易忘): cal.set(2023, Calendar.DECEMBER, 25); // 使用常量,可读性稍好

当你用cal.set(2023, 12, 25)时,你心里想的是12月25日,但程序实际会将其解释为“2023年第13个月的第25天”。Calendar内部有自动规范化(normalization)机制,它会将“第13个月”转换为“下一年的第1个月”,所以最终日期会变成2024年1月25日。这个静默的转换是许多隐蔽Bug的来源。

实操心得:在set年、月、日时,强烈建议使用Calendar提供的月份常量(如Calendar.JANUARY),这能极大减少因数字混淆导致的错误。虽然写起来长一点,但代码的意图清晰得多。

3. Calendar的实战操作:获取、设置与计算

理解了核心设计缺陷,我们来看具体怎么用它。首先,如何获取一个Calendar实例?

3.1 实例化与初始状态

Calendar.getInstance()是工厂方法,它会根据你的默认时区(TimeZone.getDefault())和区域设置(Locale.getDefault())返回一个Calendar实例(通常是GregorianCalendar)。

Calendar calendar = Calendar.getInstance(); // 这个calendar对象被创建时,其内部字段已经被设置为当前时刻。 // 注意:这个“当前时刻”包含了年、月、日、时、分、秒、毫秒所有信息。

刚获取的实例就代表了“现在”。你可以通过getTime()方法将其转换回那个古老的Date对象,或者用getTimeInMillis()获取毫秒时间戳。

3.2 字段的获取(get)与解读

Calendar将日期时间分解为多个字段(field),如年、月、日、时、分、秒、星期等。使用get(int field)方法获取。

Calendar cal = Calendar.getInstance(); cal.set(2023, Calendar.DECEMBER, 25, 14, 30, 0); // 2023-12-25 14:30:00 int year = cal.get(Calendar.YEAR); // 2023 int month = cal.get(Calendar.MONTH); // 11 (记住,DECEMBER常量就是11) int dayOfMonth = cal.get(Calendar.DAY_OF_MONTH); // 25 int hourOfDay = cal.get(Calendar.HOUR_OF_DAY); // 14 (24小时制) int hour = cal.get(Calendar.HOUR); // 2 (12小时制,需要配合AM_PM) int minute = cal.get(Calendar.MINUTE); // 30 int second = cal.get(Calendar.SECOND); // 0 int dayOfWeek = cal.get(Calendar.DAY_OF_WEEK); // 2 (注意:SUNDAY是1, MONDAY是2... SATURDAY是7) int weekOfYear = cal.get(Calendar.WEEK_OF_YEAR); // 52 (这一周是今年的第几周)

这里有几个关键点:

  1. DAY_OF_WEEK:星期几。Calendar.SUNDAY = 1,Calendar.MONDAY = 2, ...,Calendar.SATURDAY = 7。这个设计也和很多人的直觉(周一为1)不符。
  2. WEEK_OF_YEARWEEK_OF_MONTH:这两个字段的计算依赖于Calendar的“一周起始日”和“最小天数在首周”的设置(通过setFirstDayOfWeeksetMinimalDaysInFirstWeek控制)。不同区域对“第一周”的定义可能不同(例如,是包含1月1日的那周,还是第一个完整的周?),这会导致跨年跨周的周数计算出现不一致,是另一个著名的坑。
  3. HOURvsHOUR_OF_DAYHOUR是12小时制(0-11),需要结合Calendar.AM_PM字段(0=AM, 1=PM)才能确定具体是上午还是下午的几点。在绝大多数需要明确时间的业务场景中,你应该使用HOUR_OF_DAY

3.3 字段的设置(set)与叠加计算(add, roll)

设置字段主要用set方法。它可以一次设置多个字段,也可以单独设置。

Calendar cal = Calendar.getInstance(); // 方法1:分别设置年、月、日 cal.set(Calendar.YEAR, 2024); cal.set(Calendar.MONTH, Calendar.JANUARY); // 月份用常量! cal.set(Calendar.DAY_OF_MONTH, 1); // 方法2:一次性设置年、月、日 cal.set(2024, Calendar.JANUARY, 1); // 方法3:一次性设置年、月、日、时、分、秒 cal.set(2024, Calendar.JANUARY, 1, 10, 30, 15);

更强大的功能是日期计算,主要靠addroll方法。

  • add(int field, int amount):对指定时间字段进行加减。这个方法会触发规范化。例如,给月份加13个月,年份会自动进位。
    cal.set(2023, Calendar.DECEMBER, 31); cal.add(Calendar.MONTH, 1); // 加一个月 // 结果:2024-01-31。它智能地处理了跨年。 cal.add(Calendar.DAY_OF_MONTH, -1); // 减一天 // 结果:2024-01-30。
  • roll(int field, int amount):在指定字段上“滚动”,但不会影响更大的字段。这是addroll最大的区别。
    cal.set(2023, Calendar.DECEMBER, 31); cal.roll(Calendar.MONTH, 1); // 在月份上滚动+1 // 结果:2023-01-31。年份不变,月份从12月(11)滚动到了1月(0)。 // 注意:因为1月31日有效,所以日期保持31。如果从1月31日`roll`到2月,日期会被规范化为2月的最后一天(28或29日)。

避坑指南add方法是最常用也最符合直觉的日期计算方式。而roll的行为非常特殊,除非你明确需要“只在当前字段循环而不影响上级字段”的场景(这种场景极少),否则请避免使用roll,它的结果常常出乎意料。

4. 格式化输出与字符串解析

Calendar本身没有toString()方法能直接输出可读的日期字符串。你需要借助DateFormat或其子类SimpleDateFormat

4.1 使用SimpleDateFormat格式化

Calendar cal = Calendar.getInstance(); cal.set(2023, Calendar.DECEMBER, 25, 14, 30, 5); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String formattedDate = sdf.format(cal.getTime()); // 先将Calendar转为Date System.out.println(formattedDate); // 输出:2023-12-25 14:30:05

这里又引出了Calendar(以及Date)的另一个大问题:时区信息是附着在Calendar实例和DateFormat实例上的。上面的代码使用了默认时区。如果你的服务器时区是UTC,而你需要显示东八区时间,就需要显式设置。

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); sdf.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai")); // 设置为上海时区 String shanghaiTime = sdf.format(cal.getTime());

4.2 从字符串解析到Calendar

解析过程是格式化的逆过程,但陷阱更多。

String dateStr = "2023-12-25 14:30:05"; SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); try { Date parsedDate = sdf.parse(dateStr); // 1. 先解析成Date Calendar cal = Calendar.getInstance(); cal.setTime(parsedDate); // 2. 用setTime方法将Date设置给Calendar // 现在cal就代表了字符串对应的日期时间 } catch (ParseException e) { e.printStackTrace(); }

重大警告SimpleDateFormat非线程安全的!绝对不要在多线程环境下共享同一个SimpleDateFormat实例,否则会导致解析结果错乱或抛出异常。这是Java遗留日期API中著名的“线程杀手”。常见的解决方案有:

  1. 每次使用都创建新实例(性能差)。
  2. 使用ThreadLocal为每个线程分配一个独立的实例(推荐)。
  3. 使用第三方库如Apache Commons Lang的FastDateFormat(线程安全)。 当然,最好的办法是升级到Java 8+,使用线程安全的DateTimeFormatter

5. 常见陷阱与面试题深度剖析

结合网络热词和常见面试问题,我们来深入剖析几个Calendar的经典陷阱。

5.1 陷阱一:月份数字的坑(再现)

面试常问:“cal.set(2023, 12, 25)得到的日期是什么?” 我们现在知道,月份12会被当作第13个月,内部规范化后,日期变为2024年1月25日。永远记住:用常量,别用数字。

5.2 陷阱二:星期的开始

Calendar.DAY_OF_WEEK,周日是1,周一是2。如果你需要按中国习惯(周一为每周第一天)进行周计算,你需要:

cal.setFirstDayOfWeek(Calendar.MONDAY); // 设置周一为一周的第一天 // 然后再获取WEEK_OF_YEAR等,结果才会符合中国习惯。

很多国际化应用在这里栽跟头,因为不同地区的周起始日不同。

5.3 陷阱三:get方法的延迟计算与set的副作用

Calendar内部有一个boolean[] isSet字段来标记哪些字段被显式设置过,还有一个boolean isTimeSet标记时间是否被计算过。当你调用一系列set方法后,并不会立即重新计算所有字段(如时间戳)。直到你调用getTime()getTimeInMillis()get某个字段时,它才会进行“计算”和“规范化”。

这个延迟计算机制在某些连续set操作时,会因为内部状态不一致导致意外结果。一个经典的例子是清除字段:

Calendar cal = Calendar.getInstance(); cal.set(2023, Calendar.JULY, 31); // 2023-07-31 cal.set(Calendar.MONTH, Calendar.AUGUST); // 你想改成8月31日? // 但8月只有31天吗?不,8月有31天,所以这里没问题,结果是2023-08-31。 // 但如果是从1月31日改成2月呢? cal.set(2023, Calendar.JANUARY, 31); cal.set(Calendar.MONTH, Calendar.FEBRUARY); // 2月没有31号! // 此时,Calendar不会立即报错。它会进行“规范化”,将日期调整为2023-03-03(因为2023年2月只有28天,31-28=3,溢出到3月3日)。 System.out.println(cal.getTime()); // 输出:Fri Mar 03 ... 这很可能不是你想要的结果。

正确的做法是,在更改月份等可能引起日期无效的字段时,使用set方法的重载版本一次性设置,或者使用add/roll方法,或者更安全地,在设置后检查日期是否有效。

5.4 面试题:“如何获取某个月的最后一天?”

这是一个高频问题。利用Calendar的规范化机制,可以巧妙地实现:

Calendar cal = Calendar.getInstance(); cal.set(Calendar.YEAR, 2023); cal.set(Calendar.MONTH, Calendar.FEBRUARY); // 设置为2月 // 关键步骤:将日期设置为0 cal.set(Calendar.DAY_OF_MONTH, 0); // 内部规范化:0日意味着上个月的最后一天。所以这里会变成2023-01-31。 // 但这不对,我们要的是2月的最后一天。 // 正确做法:先将日期设为1号,然后月份加1,再日期减1。 cal.set(Calendar.DAY_OF_MONTH, 1); // 设为当月1号 cal.add(Calendar.MONTH, 1); // 月份加1,变成3月1号 cal.add(Calendar.DAY_OF_MONTH, -1); // 日期减1,变成2月最后一天 int lastDay = cal.get(Calendar.DAY_OF_MONTH); // 得到28或29

更简单的办法(如果你知道是2月)是判断闰年,但上述方法是通用的。而在Java 8+中,一行代码搞定:LocalDate.of(2023, 2, 1).lengthOfMonth()

6. 与现代java.time API的对比与迁移建议

理解了Calendar的种种不便,你就能明白java.time(JSR-310)的伟大。这里做一个快速对比,作为你未来代码迁移的指南。

特性java.util.Calendar/Datejava.time(Java 8+)
核心类Date,Calendar,GregorianCalendarInstant,LocalDate,LocalTime,LocalDateTime,ZonedDateTime
可变性可变(线程不安全)不可变(线程安全)
设计清晰度糟糕。Date代表瞬间,Calendar处理日历,职责混乱。优秀。明确区分了时刻、日期、时间、时区。
月份0-11(反直觉)1-12(符合直觉)
星期周日=1(Calendar.SUNDAY周一=1(DayOfWeek.MONDAY),符合ISO标准
创建方式Calendar.getInstance()new Date()工厂方法:LocalDate.now(),LocalDate.of(...)
计算与修改cal.add(field, amount)(原地修改)date.plusDays(1)(返回新对象)
格式化/解析SimpleDateFormat非线程安全DateTimeFormatter线程安全
时区处理繁琐,通过TimeZoneCalendar结合清晰,ZonedDateTime,withZoneSameInstant
周期计算非常困难简单,Period,Duration

迁移建议

  1. 新项目:毫不犹豫地使用java.time包。它是现代Java日期时间处理的唯一选择。
  2. 老项目维护
    • 如果只是小范围修改,继续用Calendar,但务必注意本文提到的所有陷阱。
    • 如果进行较大规模重构,或者新增模块,强烈建议在新代码中使用java.time。两者可以通过以下方式桥接:
      // Calendar -> java.time Calendar cal = Calendar.getInstance(); Instant instant = cal.toInstant(); // 转换为Instant ZonedDateTime zdt = instant.atZone(cal.getTimeZone().toZoneId()); LocalDate localDate = zdt.toLocalDate(); // java.time -> Calendar (不推荐,但必要时) LocalDate ld = LocalDate.now(); ZonedDateTime zdt = ld.atStartOfDay(ZoneId.systemDefault()); Calendar cal = Calendar.getInstance(); cal.clear(); cal.setTimeInMillis(zdt.toInstant().toEpochMilli());

7. 总结与最终建议

回顾Calendar类,它是在Date基础上的一次重要改进,引入了字段化、本地化、计算等概念,支撑了Java十多年的日期时间处理。然而,其固有的可变性、反直觉的API设计(0起始月份)、非线程安全的配套类(SimpleDateFormat)以及时区处理的晦涩,使得它在现代软件开发中显得笨拙且易错。

我个人在处理遗留系统时,面对Calendar代码会格外小心。我的检查清单通常是:

  1. 月份和星期:检查所有setget中的月份数字,是否应该替换为常量。
  2. 对象克隆:检查是否有多个引用指向同一个Calendar实例,是否需要clone()
  3. 时区:检查格式化/解析时是否显式指定了时区,业务逻辑是否依赖于服务器默认时区。
  4. SimpleDateFormat:检查其使用范围,确保没有跨线程共享。
  5. 周计算:如果涉及WEEK_OF_YEAR,确认firstDayOfWeekminimalDaysInFirstWeek的设置是否符合业务需求。

最后,对于学习者,我的建议是:花足够的时间理解Calendar的原理和坑,是为了更好地告别它。当你深刻体会了它的种种不便,你才会真正欣赏java.timeAPI的优雅与严谨。把这篇文章当作一份“考古手册”和“避坑地图”,助你在面对历史代码时心中有数,在面试谈论日期时间时能道出其演进的历史必然性。而当你开始编写新的代码时,请径直走向java.time的世界。

← 返回列表