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的值是0,Calendar.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 (这一周是今年的第几周)这里有几个关键点:
DAY_OF_WEEK:星期几。Calendar.SUNDAY = 1,Calendar.MONDAY = 2, ...,Calendar.SATURDAY = 7。这个设计也和很多人的直觉(周一为1)不符。WEEK_OF_YEAR和WEEK_OF_MONTH:这两个字段的计算依赖于Calendar的“一周起始日”和“最小天数在首周”的设置(通过setFirstDayOfWeek和setMinimalDaysInFirstWeek控制)。不同区域对“第一周”的定义可能不同(例如,是包含1月1日的那周,还是第一个完整的周?),这会导致跨年跨周的周数计算出现不一致,是另一个著名的坑。HOURvsHOUR_OF_DAY:HOUR是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);更强大的功能是日期计算,主要靠add和roll方法。
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):在指定字段上“滚动”,但不会影响更大的字段。这是add和roll最大的区别。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中著名的“线程杀手”。常见的解决方案有:
- 每次使用都创建新实例(性能差)。
- 使用ThreadLocal为每个线程分配一个独立的实例(推荐)。
- 使用第三方库如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/Date | java.time(Java 8+) |
|---|---|---|
| 核心类 | Date,Calendar,GregorianCalendar | Instant,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(线程安全) |
| 时区处理 | 繁琐,通过TimeZone和Calendar结合 | 清晰,ZonedDateTime,withZoneSameInstant |
| 周期计算 | 非常困难 | 简单,Period,Duration类 |
迁移建议:
- 新项目:毫不犹豫地使用
java.time包。它是现代Java日期时间处理的唯一选择。 - 老项目维护:
- 如果只是小范围修改,继续用
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代码会格外小心。我的检查清单通常是:
- 月份和星期:检查所有
set和get中的月份数字,是否应该替换为常量。 - 对象克隆:检查是否有多个引用指向同一个
Calendar实例,是否需要clone()。 - 时区:检查格式化/解析时是否显式指定了时区,业务逻辑是否依赖于服务器默认时区。
- SimpleDateFormat:检查其使用范围,确保没有跨线程共享。
- 周计算:如果涉及
WEEK_OF_YEAR,确认firstDayOfWeek和minimalDaysInFirstWeek的设置是否符合业务需求。
最后,对于学习者,我的建议是:花足够的时间理解Calendar的原理和坑,是为了更好地告别它。当你深刻体会了它的种种不便,你才会真正欣赏java.timeAPI的优雅与严谨。把这篇文章当作一份“考古手册”和“避坑地图”,助你在面对历史代码时心中有数,在面试谈论日期时间时能道出其演进的历史必然性。而当你开始编写新的代码时,请径直走向java.time的世界。