ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java Calendar类实战:字段操作、日期计算与避坑指南

Java Calendar类实战:字段操作、日期计算与避坑指南 1. 为什么Calendar类至今仍绕不开如果你维护过几个上了年头的老项目或者接手过银行、制造、政务领域的系统一定不会对Calendar陌生。前两年我重构一个内部工单系统的日期模块时翻开代码整整齐齐一排Calendar.getInstance()配合SimpleDateFormat的组合恍惚间像回到了 2010 年。虽然 JDK 8 早就把java.time带进了标准库但现实就是存量代码里 Calendar 的占比依然极高很多低版本 Android 环境、旧版框架、内部公共组件都还在跟它深度绑定。这篇文章聊的是Calendar类的真实用法不是把官方文档抄一遍。我会按照实际项目中“真正用得上”的顺序来拆怎么合理初始化、怎么读写字段、怎么做日期偏移和区间计算、怎么规避多线程和时区上的暗坑最后用一个具体的小场景串起来。适合正在维护老项目的同学也适合被Date/Calendar混合代码搞得头疼的初学者。为什么选了Calendar而不是直接劝你换成LocalDate答案很实际你不能披着“重构”的旗号把项目里几千处日期逻辑一夜之间都改掉更多时候你得能看懂、能修、能安全地在旧代码上做增量。这也是这篇文章最有价值的地方——不是让你爱上 Calendar而是让你在必须跟它打交道时不踩没必要的坑。2. 内容整体设计与思路拆解2.1 Calendar 的设计逻辑一切为了“字段”和“计算”Calendar的抽象程度比Date高一个层级。Date本质上只是一个时间点存着毫秒值想要拆成年月日你得自己换算Calendar则直接提供了YEAR、MONTH、DAY_OF_MONTH、HOUR_OF_DAY等一组字段常量你既可以取出来也可以改回去还能基于这些字段做add和roll操作。这套字段设计的初衷是让日期处理更接近人的思考方式。比如“今天加一个月”“这个月最后一天”“这周周一”这类表达用字段思维写起来非常自然。问题在于它的 API 使用体验并不好月份从 0 开始、常量命名冗长、类型不安全、解析容易受 Locale 影响这些毛病后面我一个个讲。2.2 方案选型什么时候该用 Calendar什么时候别用做技术选型时我一般按下面这个表来判断场景推荐方案原因老项目维护、增量开发Calendar/Date和其他模块风格一致避免混用导致状态混乱新项目 JDK 8java.time不可变、线程安全、API 清晰Android API 26 以下Calendar或ThreeTenABP系统内置支持不完整Calendar 是保守选择频繁进行日期加减、周期计算Calendar.add或java.timeCalendar 的 add 能自动处理进位比手写毫秒强得多只有时间戳格式化需求InstantDateTimeFormatter简洁且无副作用核心观点不是所有老技术都该被无脑推翻也不是所有新代码都该抱着老 API 不放。在一个遗留系统里新增代码用java.time然后在一个角落把它转回Calendar给老组件用这完全是合理操作。2.3 理解“可变对象”这个根因Calendar是可变的这一点是它和java.time最大的哲学分歧。一个Calendar实例从创建开始你可以在任意时刻修改它的字段值所有持有同一引用的代码都会感知到变化。这个特性在单线程里用着方便但在复杂业务中极易引发隐蔽 bug。我习惯把 Calendar 比作一块黑板谁都可以在上面写写的人多了后面去读的人看到的未必是你写的内容。这篇文章之后的线程安全部分都是从这个根因上长出来的。3. 核心细节解析与实操要点3.1 初始化别再到处 getInstance() 了Calendar.getInstance()返回的是当前默认时区和 Locale 的 Calendar 实例实际类型通常是GregorianCalendar。这是最常用的方式但有个地方要留意——如果你在代码里多处调用每次都拿到一个新的可变对象互相之间没有关联。如果需要统一基准时间最好一次性实例化并复用注意线程安全而不是每次现取。// 典型用法 Calendar cal Calendar.getInstance(); System.out.println(cal.getTime());如果你明确知道系统要处理的是特定时区的时间务必指定时区避免测试机和生产机结果不一致// 指定时区避免环境差异 TimeZone timeZone TimeZone.getTimeZone(Asia/Shanghai); Calendar cal Calendar.getInstance(timeZone);指定 Locale 也一样重要尤其是牵涉到星期、月份名称国际化时Calendar cal Calendar.getInstance(TimeZone.getTimeZone(America/New_York), Locale.US);实操心得统一封装一个DateUtils类把Calendar.getInstance()收敛到一个工厂方法里后续想统一调整时区策略时会非常省事。3.2 字段读取先摆正月份偏移的心里预期读取日期时间字段是 Calendar 的基础操作但初学者几乎都会被MONTH从 0 开始这个问题坑一次。Calendar cal Calendar.getInstance(); int year cal.get(Calendar.YEAR); int month cal.get(Calendar.MONTH); // 0 表示 1 月 int day cal.get(Calendar.DAY_OF_MONTH); int hour cal.get(Calendar.HOUR_OF_DAY); // 24小时制 int minute cal.get(Calendar.MINUTE); int week cal.get(Calendar.DAY_OF_WEEK); // 1 是周日需要注意的字段语义MONTH取值 0-11显示给用户时记得 1。DAY_OF_WEEK取值 1-7且 1 是周日不是周一。排序习惯与国内“周一是一周第一天”不同做周历应用时要格外小心。HOUR_OF_DAY0-23对应 24 小时制此外还有一个HOUR是 12 小时制的需要配合AM_PM使用实际开发别用它容易出错。WEEK_OF_YEAR和WEEK_OF_MONTH受setFirstDayOfWeek()和setMinimalDaysInFirstWeek()影响不同国家地区的星期起始日不一样跨区域报表场景慎用。如果你只是想要一个格式化好的字符串别手搓字段拼接直接用SimpleDateFormat更稳。3.3 设置与修改set 的三个典型用途set()允许你精确设定日历字段的值它的表现比想象中灵活但“允许越界”这个特性需要在实践中注意。Calendar cal Calendar.getInstance(); // 单独设置某个字段 cal.set(Calendar.YEAR, 2025); cal.set(Calendar.MONTH, Calendar.DECEMBER); // 用常量可读性更好 cal.set(Calendar.DAY_OF_MONTH, 25); // 一次性设置年月日 cal.set(2025, Calendar.DECEMBER, 25); // 含时分秒毫秒的完整设置 cal.set(2025, Calendar.DECEMBER, 25, 10, 30, 0); cal.set(Calendar.MILLISECOND, 0);注意set()的语义是“把字段调整到你指定的值”如果出现不合法日期比如 2 月 30 日Calendar 不会立刻抛异常而是等到你调用getTime()时再触发字段标准化。这种“延迟计算”设计经常导致你看到的结果和自己预期的完全不同。实操建议凡是设置年月日调用完立刻getTime()触发一次计算尽早暴露异常日期问题。别把脏数据在内存里放太久。3.4 日期计算add 与 roll 的区别add(int field, int amount)是 Calendar 比较出彩的一个能力。它不只是简单给某个字段加数字还会自动处理进位比如 1 月 31 日加一个月结果不是 2 月 31 日而是 2 月 28 日或 29 日Calendar 会自动把超出当月天数上限的部分向后续月份顺延或截断。Calendar cal Calendar.getInstance(); cal.set(2025, Calendar.JANUARY, 31); cal.add(Calendar.MONTH, 1); System.out.println(cal.getTime()); // 2025-02-28这条规则非常像 Excel 里的日期算法符合真实日历语义。roll(int field, int amount)与add不同它只在一个字段内部循环不向上进位。比如某个月是 31 天把DAY_OF_MONTHroll 30 天结果仍然停留在本月内。实际开发中roll的使用频率远低于add除非你要做“保持年月不变只循环日”的特殊场景否则不要用 roll容易把人绕晕。3.5 日期比较与时间差计算Calendar 之间的比较有三种常见姿势直接before()/after()/equals()转成Date再调用Date.before()/after()转成毫秒做数值比较Calendar start Calendar.getInstance(); start.set(2025, Calendar.JUNE, 1); Calendar end Calendar.getInstance(); end.set(2025, Calendar.JUNE, 30); if (start.before(end)) { // start 早于 end } long diffMillis end.getTimeInMillis() - start.getTimeInMillis(); long diffDays diffMillis / (1000 * 60 * 60 * 24);坑点直接拿毫秒差除以一天的毫秒数在涉及夏令时切换地区会得到错误结果。夏令时切换当天只有 23 小时或 25 小时正确的跨天计算可以用Calendar逐字段比较或者把两个日期先全部归一到当天 0 点再相减。我在国内环境下开发不用太担心夏令时但如果你做的应用有海外用户就必须认真对待。比较好的办法是用ChronoUnit.DAYS.between清晰可靠老代码里如果必须用 Calendar至少先做一次“按天取整”// 规避夏令时对计算天数的影响 Calendar start Calendar.getInstance(); start.set(2025, Calendar.JUNE, 1, 0, 0, 0); start.set(Calendar.MILLISECOND, 0); Calendar end Calendar.getInstance(); end.set(2025, Calendar.JUNE, 30, 0, 0, 0); end.set(Calendar.MILLISECOND, 0); long diffDays (end.getTimeInMillis() - start.getTimeInMillis()) / (24 * 60 * 60 * 1000);3.6 格式化输出与解析Calendar 经常和SimpleDateFormat搭配使用方向有两个Calendar cal Calendar.getInstance(); SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); String text sdf.format(cal.getTime()); // Calendar - String Date date sdf.parse(2025-12-25 10:30:00); Calendar parsed Calendar.getInstance(); parsed.setTime(date); // String - CalendarSimpleDateFormat有个老问题它不是线程安全的在多线程环境下共享同一个实例会导致解析结果错乱甚至抛异常。老项目里常见的做法是每次用的时候新建实例虽然性能差点但安全追求性能就上一个ThreadLocalSimpleDateFormat这个方案网上很多我就不贴模板代码了。4. 实操过程与核心环节实现4.1 完整案例计算两个日期间的工作日天数结合 Calendar 特性做一个小而完整的场景计算 2025 年 6 月第一个工作日到当天的工作日天数要求跳过周六日。这个案例能覆盖初始化、字段读取、add 步进、日期比较等核心知识点。public static void main(String[] args) { // 起点2025-06-01 Calendar start Calendar.getInstance(); start.clear(); start.set(2025, Calendar.JUNE, 1); // 终点今天 Calendar end Calendar.getInstance(); end.clear(); end.set(2025, Calendar.JUNE, 20); int workdays 0; Calendar cursor (Calendar) start.clone(); while (!cursor.after(end)) { int dayOfWeek cursor.get(Calendar.DAY_OF_WEEK); if (dayOfWeek ! Calendar.SUNDAY dayOfWeek ! Calendar.SATURDAY) { workdays; } cursor.add(Calendar.DAY_OF_MONTH, 1); } System.out.println(工作日天数: workdays); }这里我特意用了clear()清空字段防止 JVM 默认时间残留导致“日期比预期多出半天”的情况这个细节是很多人踩过的坑。如果要计算自然天数差可以用前面提到的时间戳归零法也可以直接用end.getTimeInMillis() - start.getTimeInMillis()除以 86400000只要确认不涉及夏令时即可。4.2 更复杂的时间段处理跨月生成每周任务做一个“每周一、周四生成提醒任务”的模拟逻辑很贴合日历应用的实际场景。Calendar 在遍历周期事件时非常顺手Calendar start Calendar.getInstance(); start.set(2025, Calendar.JUNE, 1); Calendar end Calendar.getInstance(); end.set(2025, Calendar.JUNE, 30); ListDate remindDays new ArrayList(); Calendar cursor (Calendar) start.clone(); while (!cursor.after(end)) { int dayOfWeek cursor.get(Calendar.DAY_OF_WEEK); if (dayOfWeek Calendar.MONDAY || dayOfWeek Calendar.THURSDAY) { remindDays.add(cursor.getTime()); } cursor.add(Calendar.DAY_OF_MONTH, 1); }这种写法一眼就能看懂后续如果要改成“每月 15 号”或“每个工作日”只需要改判断条件。使用clone()生成游标而不是直接操作start可以避免修改原始边界日期——这是处理区间遍历时特别推荐的习惯。4.3 开源日历项目中的 Calendar 使用思路搜“fossify calendar”的热搜时很多人在关注开源日历应用如何实现日期展示和事件调度。实际上这类项目处理的核心就是周起始日、月视图拼接、事件日期比较这三件事。以“生成月视图格子”为例常规做法是先拿到当月 1 号是星期几再根据第一列是周日还是周一决定前面补多少个空白格子。Calendar 的get(Calendar.DAY_OF_WEEK)在这里派上用场Calendar cal Calendar.getInstance(); cal.set(2025, Calendar.JUNE, 1); int firstDayOfWeek cal.get(Calendar.DAY_OF_WEEK); // 假设第一列是周日Java 的 DAY_OF_WEEK 中周日1所以前面不需要补位 // 如果第一列是周一前面需要补 (firstDayOfWeek - 1) % 7 个空格注意这里国内用户习惯“周一是一周第一天”所以判断时要根据产品策略调整。fossify calendar 这类开源项目一般会提供“周起始日”设置项本质上就是改变Calendar.setFirstDayOfWeek()和前端网格起始列。理解了 Calendar 的字段语义这些功能实现起来非常顺畅。4.4 处理时区和夏令时的实际代码时区问题是最容易“本地测试没事线上就错”的典型场景。比如你要生成美国东部时区每天早上 8 点的定时任务描述直接把本地时间塞进去就错了TimeZone eastern TimeZone.getTimeZone(America/New_York); Calendar cal Calendar.getInstance(eastern); cal.set(Calendar.HOUR_OF_DAY, 8); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0); // 输出时用目标时区的 DateFormat SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); sdf.setTimeZone(eastern); System.out.println(sdf.format(cal.getTime()));这里有个很容易忽略的细节Calendar.getInstance(eastern)创建后getTime()返回的Date其实是一个瞬间本身不包含时区信息只是“8 点这个瞬间”。你要把它显示为“美东时间的 8 点”必须把SimpleDateFormat的时区也设成eastern否则格式化时会自动转成系统默认时区看起来就是凌晨或者深夜导致排查时一头雾水。夏令时切换日更特殊如果当天凌晨 2 点时钟拨到 3 点那么 2:30 这个本地时间实际不存在。Calendar 不会直接告诉你“这个时间不存在”而是可能将结果调整为 3:30 或者 1:30具体取决于 JDK 实现。面向海外用户的应用一定要对用户输入的本地时间在目标时区做一次合法性校验否则日程表上会出现诡异的时间。5. 常见问题与排查技巧实录5.1 月份偏移0 到底代表几月这是出现频率最高的“弱智问题”连工作多年的同事也会偶尔看走眼。Calendar.JANUARY的值是 0不是 1。凡是往set()里直接传数字的代码都要在注释里写清楚。// 错误示例以为 6 是 6 月 cal.set(2025, 6, 1); // 实际是 2025-07-01 // 正确示例用常量 cal.set(2025, Calendar.JUNE, 1);建议在团队里定一个“日历字段常量必须用Calendar.XXX引用”的规范排查时间问题时能省下大量无效沟通。曾经有个项目就因为报表模块里把5当作 5 月导致每月的数据区间整体错位从 4 月月报变成了 6 月月报客户问了半天才定位到这一行。5.2 getTime 和 getTimeInMillis 的时间不一致由于Calendar是可变对象你对它调了set之后getTime()会触发字段到毫秒的换算过程中可能经过“日历字段标准化”。但在极端情况下比如同时设了HOUR和AM_PM稍有不慎会出现getTime()与你期望的getTimeInMillis()不一致。原因通常是AM_PM和HOUR组合互相覆盖。实战建议除非做 UI 需求否则永远用HOUR_OF_DAY别用HOUR AM_PM。这两组混用是名副其实的 bug 制造机。5.3 线程安全的坑Calendar 不是线程安全的。解决方式有几种需要的时候用ThreadLocalCalendar每个线程持有独立副本不想引入 ThreadLocal 就用局部变量用完丢在 Spring 这类容器中避免把 Calendar 注册成单例 Bean 直接注入到各处使用。最推荐的是局部变量方案。Calendar 的创建成本不算高比起并发修改带来的诡异 bug多创建几次对象完全可以接受。我见过一个线上事故一个定时任务框架里共享了一个Calendar实例任务触发时多个线程同时调用add(Calendar.DAY_OF_MONTH, 1)结果部分任务计算出完全错误的执行时间日志里时间戳前后颠倒折腾了一整晚才发现是共享可变对象的问题。这个教训非常深刻。5.4 性能方面的误区Calendar 的性能确实不如 java.time但在绝大多数业务系统里这点耗时可以忽略不计。真正要优化的是“无意义的重复创建”——比如循环 1 万次处理日期每次都在循环体内getInstance()就会造成可感知的浪费。可以提到循环体外用clone()生成游标既保留了独立副本又降低了创建开销。Calendar base Calendar.getInstance(); for (int i 0; i 10000; i) { Calendar cur (Calendar) base.clone(); cur.add(Calendar.DAY_OF_MONTH, i); // 使用 cur }但注意clone()返回的是浅拷贝对于 Calendar 这种内部字段基本都是基本类型和数组的对象浅拷贝足够千万别以为 clone 出来的对象和原对象共享时间字段——Calendar 的 clone 是字段拷贝不会共享。5.5 边界时间23:59:59 的连环坑很多业务要求“截止到今天结束”新手容易这么写cal.set(Calendar.HOUR_OF_DAY, 23); cal.set(Calendar.MINUTE, 59); cal.set(Calendar.SECOND, 59); cal.set(Calendar.MILLISECOND, 999);问题在于有些底层 store 只精确到秒999 毫秒被丢掉导致区间的右边界变成了 23:59:59 而不是 23:59:59.999如果查询是就有可能漏掉最后 1 秒内产生的数据。正确做法是查询区间右边界用“次日 00:00:00.000”并配比较彻底避开毫秒和秒的精度问题。这个技巧在写报表和统计查询时非常关键也是我们在实际运维中反复踩过的坑。5.6 常用排查步骤速查表症状排查思路日期差 1 天检查是否跨时区看TimeZone.getDefault()是否被改变月份差 1 个月检查是否直接用数字 set MONTH没减 1星期排列不对检查DAY_OF_WEEK起始为周日以及setFirstDayOfWeek的设置时间多 8 小时检查SimpleDateFormat时区是否和目标时区一致格式化忽对忽错检查是否有多个线程共享SimpleDateFormat执行时间乱跳检查是否有Calendar实例被多个线程共享6. 一点个人经验我当年第一次在报表模块里被Calendar的月份偏移坑了一上午之后养成了一个“土习惯”所有涉及 Calendar 的工具方法都要配一个单元测试专门验证月份、星期、时区这三个高风险点。后来这套测试甚至成了我重构日期的安全网没有它我根本不敢动老代码。如果你正在维护一个拧巴的老项目可以试试给 Calendar 相关方法加一层薄薄的包装输出永远是Date或者标准格式化字符串入口处统一收编所有set调用。这治标不治本但能显著降低日常开发的“日期恐惧症”。至于长远方案当然是逐步把核心业务迁移到java.time不过那是另一个很长的故事了。祝你和 Calendar 相处愉快也希望这篇文章能帮你少翻几次源码。
返回列表