做Java开发这些年,要说哪个类最让人“又爱又恨”,java.util.Calendar绝对排得上号。这节内容在课程里排到2.20,看着像是个入门章节,但真到实战里,Calendar类的用法能玩出不少花活——月份从0开始、时区偏移、夏令时、线程安全,哪一个拎出来都够新手喝一壶的。今天我就把这几年来用Calendar踩过的坑、总结出来的经验一次性倒出来,从最基础的拿实例开始,到日期运算、格式化、时区处理,再结合fossify calendar这类开源日历应用里的真实场景,尽量把这块讲透。不管是刚学Java的初学者,还是写了好几年业务代码想回头补基础的老手,这篇都值得花几分钟过一遍。
1. 为什么都在劝退Date,却绕不开Calendar
1.1 Date那点糟心事
在Java 8的java.time包出现之前,java.util.Date是所有日期操作的起点。但这家伙的设计放到今天看,确实一言难尽。年份从1900年开始算,月份从0开始,所有字段都是可变的,而且它对时区几乎没有真正的感知能力——Date内部存的只是一个long类型的毫秒时间戳,打印出来默认用本地时区,但你想拿它做“给某个日期加3天”这种操作,得先转成Calendar或者自己写毫秒换算。
我自己早期写代码就干过这种事:算某天之后7天是哪天,直接用date.getTime() + 7 * 24 * 60 * 60 * 1000,写成new Date(old.getTime() + 7L * 24 * 60 * 60 * 1000)。看着没问题,但一旦遇到夏令时切换,一天不是24小时,这种写法就直接翻车。而且这种魔法数字堆在代码里,过两个月自己回来看都脑袋疼。
1.2 Calendar的设计思路
Calendar这个抽象类就是冲着解决上面这些问题来的。它把“时间”拆成一组字段:年、月、日、时、分、秒、星期、年内第几周、周内第几天等等,然后提供统一的读写和运算接口。你不再需要自己去处理毫秒换算,add、roll这些方法会帮你处理好进位、跨月、跨年,甚至闰年。
有人可能会问:既然Java 8都推出java.time了,为什么还要学Calendar?两个原因。第一,大量存量项目、Android生态、老框架里还在用,你接手代码不可能让老板先把所有日期代码重写一遍。第二,理解Calendar的设计思路,能帮你更好地理解后来java.time为什么那么设计——它是踩在Calendar的坑上重建的。所以我倾向于把Calendar当作“必学的历史课+实战技能”,而不是“过时的老古董”。
注意:
Calendar是个抽象类,你不能直接new Calendar(),得通过工厂方法取实例。这点和java.time里的LocalDate.now()思路一脉相承。
2. Calendar类的基础用法:取实例、读写字段、避开常量陷阱
2.1 获取Calendar实例的正确姿势
最常见的写法是Calendar.getInstance(),这个方法内部会根据默认时区和语言环境返回一个GregorianCalendar实例。你也可以指定时区和Locale:
// 默认时区、默认Locale Calendar cal = Calendar.getInstance(); // 指定时区 Calendar calWithZone = Calendar.getInstance(TimeZone.getTimeZone("Asia/Shanghai")); // 同时指定时区和Locale Calendar calWithAll = Calendar.getInstance( TimeZone.getTimeZone("America/New_York"), Locale.US );这里要提醒一句:getInstance()拿到的Calendar,它的“当前时间”是初始化那一刻的时间。如果你在一个方法里连续取两次实例,中间隔了几毫秒,两个实例的时间戳可能是不一样的。这在某些毫秒级敏感的逻辑里会造成隐蔽的问题,后面排查章节我会细说。
2.2 get、set与字段常量
拿到实例之后,读写字段靠get(int field)和set(int field, int value)。field是Calendar类里定义的一堆常量,最常用的有这么几个:
| 常量 | 含义 | 取值范围 |
|---|---|---|
Calendar.YEAR | 年份 | 正负整数 |
Calendar.MONTH | 月份 | 0~11,0代表1月 |
Calendar.DAY_OF_MONTH | 日(月内) | 1~31 |
Calendar.DAY_OF_WEEK | 星期几 | 1~7,1是周日 |
Calendar.HOUR_OF_DAY | 24小时制小时 | 0~23 |
Calendar.HOUR | 12小时制小时 | 0~11 |
Calendar.MINUTE | 分钟 | 0~59 |
Calendar.SECOND | 秒 | 0~59 |
Calendar.MILLISECOND | 毫秒 | 0~999 |
Calendar.WEEK_OF_YEAR | 年内第几周 | 1~53 |
Calendar.DAY_OF_YEAR | 年内第几天 | 1~366 |
一个常被忽略的点:HOUR和HOUR_OF_DAY的区别。如果你用HOUR,必须配合AM_PM字段一起设置,否则上午下午会乱套。我建议一律用HOUR_OF_DAY,省心。写段示例:
Calendar cal = Calendar.getInstance(); cal.set(Calendar.YEAR, 2025); cal.set(Calendar.MONTH, Calendar.FEBRUARY); // 注意:2月对应MONTH=1 cal.set(Calendar.DAY_OF_MONTH, 20); cal.set(Calendar.HOUR_OF_DAY, 14); cal.set(Calendar.MINUTE, 30); cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0); int year = cal.get(Calendar.YEAR); // 2025 int month = cal.get(Calendar.MONTH); // 1 int day = cal.get(Calendar.DAY_OF_MONTH); // 20 int hour = cal.get(Calendar.HOUR_OF_DAY); // 142.3 月份从0开始的百年大坑
Calendar.MONTH从0开始,这是Java日期API里最著名的坑,没有之一。Calendar.JANUARY的值是0,Calendar.DECEMBER的值是11。很多人写代码时习惯性把用户输入的“2月”直接塞进去,结果发现日期变成了3月,排查半天找不到原因。
我自己的习惯是:凡是涉及月份的常量,一律用Calendar.JANUARY、Calendar.FEBRUARY这种语义化常量,不要用魔法数字。这样代码至少能自解释:
// 错误示范:用户输入2月,直接set MONTH = 2 cal.set(Calendar.MONTH, 2); // 实际是3月 // 正确示范 cal.set(Calendar.MONTH, Calendar.FEBRUARY); // 实际是2月另外,DAY_OF_WEEK也有自己的规则:Calendar.SUNDAY是1,Calendar.SATURDAY是7,跟我们习惯的“周一是一周第一天”完全相反。如果你的业务里要判断“今天是周几”,记得先想清楚返回值到底对应星期几,别想当然。
2.4 设置字段时的一个隐藏陷阱:clear与set的纠缠
Calendar的set方法是“延迟生效”的,它不会立刻重算内部的时间戳,要等到你调用get、getTime、getTimeInMillis这些方法时才会统一计算。这本身没什么问题,但如果你先set了一部分字段,又用new GregorianCalendar(2025, 0, 1)这种构造方式创建对象,两者的字段初始化状态不一样。
更关键的是,如果你想设置“只改日期,不改时间”,最好先用clear()把所有字段清掉再set,否则Calendar里可能残留着HOUR_OF_DAY、MINUTE等字段的旧值,导致输出结果带了莫名其妙的时间。举个实际例子:
Calendar cal = Calendar.getInstance(); cal.set(2025, Calendar.FEBRUARY, 20); // 这里get出来的时间,时分秒是本机当前时间! // 因为set(int, int, int)只是设置了年月日,时分秒还是原来的这一点在生成“某天的0点0分0秒”这种场景下特别容易踩雷。正确的做法是:
Calendar cal = Calendar.getInstance(); cal.clear(); // 全部字段清零 cal.set(2025, Calendar.FEBRUARY, 20); // 或者 cal.set(2025, Calendar.FEBRUARY, 20, 0, 0, 0); cal.set(Calendar.MILLISECOND, 0);还有个小技巧:setLenient(false)可以让Calendar进入严格模式,遇到不合理的字段组合(比如2月30日)直接抛异常,而不是自动给你进位到3月2日。这在做表单校验的时候特别有用。
3. 日期运算与比较:add、roll、before、after的实战差异
3.1 add和roll的区别,用一次就忘不掉
做日期运算,Calendar提供了两个核心方法:add(int field, int amount)和roll(int field, int amount)。这两个方法表面看都是“给某个字段加一个数”,但行为差异很大。
add是“完整进位”运算:给月份加1,如果跨年了,年份也会跟着变;给日期加30天,月份、年份会自动调整。比如2025年1月31号加1个月,结果是2月28号(平年),而不是3月3号,因为add会做溢出调整。
roll则是“只动当前字段,不进位”:给1月31号加1个月,结果还是1月31号?不对,月份会变成2月,但日子保持在31号,最终得到2月31号——Calendar会把“不存在的2月31号”解析成3月3号。这看起来和前一句矛盾?其实不然,roll不管其他字段的死活,它就是把MONTH字段从0加到1,至于DAY_OF_MONTH=31这个组合合法不合法,它不管。所以实战中我几乎不用roll,它的语义太容易引发误解,add才是符合日常逻辑的选择。
Calendar cal = Calendar.getInstance(); cal.set(2025, Calendar.JANUARY, 31); cal.add(Calendar.MONTH, 1); System.out.println(cal.getTime()); // 2025年2月28日(自动调整到月末) cal = Calendar.getInstance(); cal.set(2025, Calendar.JANUARY, 31); cal.roll(Calendar.MONTH, 1); System.out.println(cal.getTime()); // 注意:不同JDK版本输出可能不一致,可能变成3月3日所以在“下个月这一天”“三个月后的今天”这类业务里,请坚定地使用add。
3.2 before、after、equals与日期区间判断
Calendar实现了Comparable接口,同时也有before(Date)、after(Date)、equals(Object)这些方法。判断两个时间点谁先谁后,直接用:
Calendar start = Calendar.getInstance(); start.set(2025, Calendar.FEBRUARY, 1, 0, 0, 0); Calendar end = Calendar.getInstance(); end.set(2025, Calendar.FEBRUARY, 28, 23, 59, 59); Calendar now = Calendar.getInstance(); if (now.after(start) && now.before(end)) { // 落在2月区间内 }这里有个细节:before和after比较的是毫秒时间戳层面,跟时区无关,因为时间戳是绝对时间。但如果你要比较“两个Calendar代表的是不是同一个日历日期”,不能直接用equals,因为它比较的是所有字段值,两个实例只要时分秒不同就不相等。正确做法是分别取YEAR、MONTH、DAY_OF_MONTH三个字段都相等才算同一天,或者用一个工具方法把两边都归一到当天0点再比较。
3.3 计算两个日期相差多少天:别死磕getTimeInMillis
老手拿到“两个日期相差几天”这种需求,第一反应多半是:
long diff = cal2.getTimeInMillis() - cal1.getTimeInMillis(); long days = diff / (24 * 60 * 60 * 1000);这写法在绝大多数场景能用,但有俩问题。一是没考虑夏令时:某些时区在夏令时切换日一天只有23小时或25小时,除以固定24小时会少算或多算一天。二是如果你要的是“自然日差值”——比如2月28号23点到3月1号凌晨1点,按毫秒算是差2小时,但按“自然日”用户会觉得这是2天后——那就不能用毫秒差。
处理“自然日差值”更稳的做法是取DAY_OF_YEAR(注意跨年场景),或者先把两个Calendar都归一到当天0点,再算毫秒差:
Calendar c1 = Calendar.getInstance(); c1.set(2025, Calendar.FEBRUARY, 28, 0, 0, 0); c1.set(Calendar.MILLISECOND, 0); Calendar c2 = Calendar.getInstance(); c2.set(2025, Calendar.MARCH, 1, 0, 0, 0); c2.set(Calendar.MILLISECOND, 0); long diffDays = (c2.getTimeInMillis() - c1.getTimeInMillis()) / (24 * 60 * 60 * 1000); // 结果为1跨年场景下DAY_OF_YEAR会从365回落到1,所以最稳的方案还是“归一化到0点再算毫秒差”,或者直接上java.time的LocalDate.toEpochDay()。我后面会再提一句两者如何配合。
4. 格式化与解析:Calendar和SimpleDateFormat的配合实战
4.1 从Calendar到字符串
Calendar本身没有format方法,你得先通过getTime()拿到Date,再交给SimpleDateFormat去格式化。这是最标准的一条链路:
Calendar cal = Calendar.getInstance(); cal.set(2025, Calendar.FEBRUARY, 20, 14, 30, 0); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String formatted = sdf.format(cal.getTime()); // 输出:2025-02-20 14:30:00常用的格式模板我整理了一个速查表:
| 模板 | 结果示例 | 说明 |
|---|---|---|
yyyy-MM-dd | 2025-02-20 | 标准日期 |
yyyy-MM-dd HH:mm:ss | 2025-02-20 14:30:00 | 标准时间 |
yyyy/MM/dd | 2025/02/20 | 斜杠分隔 |
yyyy年M月d日 | 2025年2月20日 | 中文习惯,M不用MM也没关系 |
HH:mm | 14:30 | 24小时制时分 |
hh:mm a | 02:30 下午 | 12小时制,a是上午下午标记 |
yyyy-MM-dd HH:mm:ss.SSS | 2025-02-20 14:30:00.123 | 带毫秒 |
注意想清楚一个问题:SimpleDateFormat里的yyyy和YYYY不一样。小写yyyy是“日历年份”,大写YYYY是“周年份”(Week Year),跨年那一周两者可能不同。比如2025年1月1日如果落在2024年的最后一周,YYYY格式化出来可能是2024。我见过有人用YYYY-MM-dd格式化导致元旦日期年份显示异常的Bug,排查半天,其实就是大小写写错了。这是非常经典的一个暗坑。
4.2 从字符串到Calendar
反向解析,先parse成Date,再setTime进Calendar:
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Date date = sdf.parse("2025-02-20 14:30:00"); Calendar cal = Calendar.getInstance(); cal.setTime(date);这里有几个解析相关的坑值得单独说:
第一,SimpleDateFormat默认是宽松模式(lenient),解析2025-02-30不会报错,而是会给你进位成2025-03-02。如果你要严格校验用户输入,必须调用sdf.setLenient(false)。否则你做的日期校验形同虚设。
第二,如果解析的字符串里只有日期没有时间,Date里时分秒会被置为0,这通常符合预期。但如果字符串里带时区信息,比如2025-02-20T14:30:00+08:00,SimpleDateFormat默认不会自动处理,你需要把时区也写进模板:yyyy-MM-dd'T'HH:mm:ssZ。老实说,处理ISO 8601格式我更建议直接用java.time的OffsetDateTime.parse,那就是另一个话题了。
第三,SimpleDateFormat是线程不安全的。多个线程共享同一个SimpleDateFormat实例做格式化,会出现数据错乱甚至ArrayIndexOutOfBoundsException。项目里要么每次new一个,要么用ThreadLocal包一层,要么直接切到java.time。这一点在后面的排查章节我再展开。
4.3 结合fossify calendar看真实场景的格式化需求
讲到格式化,正好说说fossify calendar这类开源日历应用。它是一款开源的Android日历App,界面简洁,没有广告,日期逻辑完全跑在Java/Android框架上。日历应用最核心的界面元素之一,就是事件列表里的时间显示。同一个事件,在列表页显示“2月20日 14:30”,在详情页显示“2025年2月20日 星期四 14:30”,在月视图的小格子里可能只显示“14:30”,这些都是典型的格式化场景。
如果让我来设计这部分逻辑,我会把格式化模板集中放在一个工具类里,按显示粒度选模板,而不是在页面里到处写SimpleDateFormat。比如:
public class DateFormatUtil { public static String formatDate(Calendar cal) { return new SimpleDateFormat("yyyy年M月d日").format(cal.getTime()); } public static String formatTime(Calendar cal) { return new SimpleDateFormat("HH:mm").format(cal.getTime()); } public static String formatDateTime(Calendar cal) { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(cal.getTime()); } }这样做的另一个好处是集中管理模板,以后要换格式显示风格,只改一个地方。fossify calendar的源码里也是类似思路,把日期格式化和字符串资源分开管理,方便做多语言适配。
5. 时区、语言环境与夏令时:Calendar的国际视野
5.1 显式设置时区,别让代码“看心情”
Calendar默认使用JVM的默认时区,这个值会受到操作系统时区设置影响。在服务器部署到不同区域、或者用户手机设置不同时区时,你要是没显式指定时区,同样的代码在不同环境下跑出来的结果可能完全不一样。
指定时区有两种方式:创建实例时指定,或者创建后setTimeZone:
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("Asia/Shanghai")); // 或者 Calendar cal2 = Calendar.getInstance(); cal2.setTimeZone(TimeZone.getTimeZone("Europe/London"));判断一个时间点的“本地表示”属于哪一天,完全取决于你用的时区。同一个getTimeInMillis(),在Asia/Shanghai看是2025年2月20日的下午,在America/Los_Angeles看可能就是2025年2月20日的凌晨,日期字段都可能不一样。所以如果业务里涉及到“按用户所在时区显示日期”,一定要保证每个Calendar实例都绑定了正确的时区,千万别混用。
5.2 Locale影响一周从哪天开始、月份怎么显示
很多人忽略Locale对Calendar的影响。Calendar.getFirstDayOfWeek()在不同Locale下返回值不同:美国习惯周日是一周第一天,中国和大部分欧洲国家习惯周一是第一天。这个值直接影响到WEEK_OF_YEAR的计算结果。
如果你要做“本周”“本月”这种视图,fossify calendar这类日历App里最典型的功能,就必须明确一周从哪天开始。Android上系统的CalendarContract会提供用户的周起始偏好,但你在Java代码里用Calendar算周区间时,还是要自己确认:
Calendar cal = Calendar.getInstance(Locale.CHINA); int firstDay = cal.getFirstDayOfWeek(); // 周一,值为Calendar.MONDAY=2如果应用要做国际化,比如同一个App在中文环境和英文环境下都要正确显示“周视图”,建议获取周起始日时用Calendar.getInstance(Locale.getDefault()).getFirstDayOfWeek(),而不是硬编码Calendar.MONDAY。
5.3 夏令时:那个“消失的一小时”
夏令时可能是Calendar用法里最容易被忽略的坑。在实行夏令时的时区(比如欧洲大部分地区、北美部分地区),春季某天凌晨2点会直接跳到3点,那一天只有23个小时;秋季某天凌晨3点会回拨到2点,那一天有25个小时。
如果你用add(Calendar.DAY_OF_MONTH, 1)给某个日期加一天,Calendar会正确处理这种异常时长,因为add是按“日历字段”运算,不是按固定的24小时。但如果你用getTimeInMillis()加24 * 60 * 60 * 1000,就会踩中夏令时,导致结果偏移一小时。所以,涉及跨天运算绝对优先使用add,这是我在生产环境用血泪换来的教训。
另外提醒一句:中国从1991年后就不再实行夏令时了,所以国内很多开发者对这个问题没感觉。但一旦你的应用要服务海外用户,或者你在处理存储在欧洲、北美服务器上的数据,夏令时问题就会冒出来。
6. 开源日历应用里的Calendar实战:以fossify calendar为例
6.1 事件重复规则:add就是为这个设计的
fossify calendar是当前很受关注的一个开源日历项目,它是Simple Calendar的社区分支,主打离线、隐私、无广告。calendar类应用里最有代表性的复杂逻辑就是“重复事件”:每天重复、每周重复、每月重复、每年重复。
这些重复规则用Calendar的add方法实现非常顺手。比如“每周一重复”的事件,下一个实例就是:
Calendar next = Calendar.getInstance(); next.set(2025, Calendar.FEBRUARY, 3, 9, 0, 0); // 周一上午9点 if (next.get(Calendar.DAY_OF_WEEK) != Calendar.MONDAY) { // 找到本周一 next.add(Calendar.DAY_OF_MONTH, (Calendar.MONDAY - next.get(Calendar.DAY_OF_WEEK) + 7) % 7); } // 下一个事件 next.add(Calendar.DAY_OF_MONTH, 7);“每月最后一个工作日”这种复杂规则,就需要组合getActualMaximum和add了。getActualMaximum(Calendar.DAY_OF_MONTH)能拿到当月的实际最大天数,这在处理2月、大小月的时候特别省心:
Calendar cal = Calendar.getInstance(); cal.set(2025, Calendar.FEBRUARY, 1); int lastDay = cal.getActualMaximum(Calendar.DAY_OF_MONTH); // 28(平年2月)这个getActualMaximum在fossify calendar这类日历应用里几乎是天天用,因为月视图需要知道“这个月画几个格子”“最后一天是几号”。如果用固定数组或者自己写闰年判断,很容易出边界Bug,而Calendar已经帮你把这些都处理好了。
6.2 月视图与周视图的区间计算
日历应用最基础的UI是月视图:一个网格,每行7个格子,对应一周七天。要渲染这个视图,你得知道三个数据:本月1号是星期几(决定第一行从第几列开始)、本月有多少天、要不要补上个月和下个月的“填充日期”。
用Calendar可以这样算:
Calendar cal = Calendar.getInstance(); cal.set(Calendar.DAY_OF_MONTH, 1); // 调到本月1号 int firstDayOfWeek = cal.get(Calendar.DAY_OF_WEEK); // 返回1~7,需要结合getFirstDayOfWeek()转换成列下标 int daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH); // 月视图第一个格子的日期 cal.add(Calendar.DAY_OF_MONTH, -(firstDayOfWeek - cal.getFirstDayOfWeek() + 7) % 7);周视图就更直接了:先归一到本周的第一天,然后连续加7次DAY_OF_MONTH, 1,就能生成这一周全部7天。这些都是我在实际做日历类项目时反复用到的代码模式,fossify calendar这样的成熟开源项目里也有类似的实现思路。
6.3 全天事件的边界处理
全天事件(All-day Event)是日历应用另一个容易出Bug的点。全天事件的语义是“某一天的一整天”,它不绑定具体时分,通常存储时取当天零点。但如果跨时区使用,某个用户在东八区创建了一个2月20日的全天事件,另一个用户在纽约看,这个事件应该显示为2月19日还是2月20日?不同产品定义不一样。
用Calendar处理全天事件时,我建议在存储层面统一用UTC或固定一个业务时区,计算日边界时显式指定时区:
Calendar utcCal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); utcCal.set(2025, Calendar.FEBRUARY, 20, 0, 0, 0); utcCal.set(Calendar.MILLISECOND, 0); long eventStartUtcMillis = utcCal.getTimeInMillis();然后在展示层再转成用户本地时区。这个“存储用固定时区,展示用本地时区”的原则,能帮你避开绝大多数全天事件的时区坑。
7. 常见问题与排查技巧实录
7.1 月份总是少一个月:先查MONTH
这是Calendar最经典的Bug,症状是“我明明输入的2月,存进去变成1月”。原因大概率是你在set月份时直接用了用户输入的数字,没有做减1处理。反过来,从Calendar取月份展示给用户时,也要记得加1。这里我提供一个自查清单:
- 所有set MONTH的地方,确认源数据的月份基数是不是0
- 所有get MONTH后拼字符串的地方,确认有没有加1
- 如果用了
Calendar.JANUARY这类常量,确认常量值和你预期的月份对应
7.2 线程安全:Calendar和SimpleDateFormat都不能共享
Calendar不是线程安全的,多个线程同时修改同一个实例,字段值会互相覆盖,甚至出现中间态。SimpleDateFormat的线程不安全问题更严重,它内部有个Calendar实例在做解析和格式化,多线程共享时会报出诡异异常。
我的建议是三条:
- 每个线程自己的局部变量,随便用Calendar
- 如果非要全局共享,用
ThreadLocal<Calendar>或ThreadLocal<SimpleDateFormat>包装 - 最省心的方案:新代码直接上
java.time,它的核心类都是不可变、线程安全的
private static final ThreadLocal<SimpleDateFormat> SDF = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); public static String formatDate(Date date) { return SDF.get().format(date); }7.3 性能:别在循环里反复创建Calendar
Calendar的getInstance()内部会加载时区数据、初始化一堆字段,不是个廉价操作。如果你在循环里对几千条记录逐一创建Calendar再格式化,性能会肉眼可见地变差。我自己实测过,循环10万次Calendar.getInstance()和10万次LocalDate.now()对比,前者要慢一个数量级。
优化思路有两个方向。一是循环外复用一个SimpleDateFormat,结合ThreadLocal保证线程安全;二是批量场景直接用java.time,性能更好、代码也更简洁。Calendar适合做“偶发、单点”的日期操作,海量日期计算还是交给新API。
7.4 新旧API怎么选:我的判断标准
看到这里你可能会问:既然java.time这么好,我是不是彻底抛弃Calendar?我的判断标准是这样:
- 维护存量代码:必须读懂、能改Calendar,别一上来就重写
- 新项目新代码:默认用
java.time,除非团队约定或框架要求 - 涉及Android的旧API Level:如果minSdkVersion低于26,那还是得依赖Calendar或者引入脱糖(desugaring),这是Android项目的现实约束
在迁移过程中,两者可以混用。比如你拿到一个旧系统返回的Date,在代码里转成LocalDateTime再处理:
Date oldDate = getOldDate(); LocalDateTime ldt = oldDate.toInstant() .atZone(ZoneId.systemDefault()) .toLocalDateTime();反过来,某些老接口只认Date,你也可以从java.time转回去。过渡期这样做,既不用推翻旧代码,也能逐步享受新API的好处。
写在最后:关于Calendar,我的几点体会
做了这么多年Java,我越来越觉得Calendar这类“老API”真正考验人的不是语法,而是边界意识。月份从0开始、周从周日开始、时区不显式设置就会悄悄变化、夏令时会让一天不是24小时——每一条都是文档里有、但新手不会注意的细节。fossify calendar这类开源项目之所以能做好,不是因为用了什么高深技术,而是把这类边界处理得滴水不漏。
如果你现在正在学这一节,我的建议是别只看文档,动手写几个小例子:算一下你出生那天是星期几、算一下这个月最后一天是几号、把一个带时区的字符串解析成Calendar再格式化回去。这几个练习做完,Calendar的基本用法基本就烂熟于心了。后面遇到更复杂的日期需求,再逐步往java.time迁移,你会发现很多设计都是相通的。日历这块内容不难,难的是细心,共勉。