学Java学到第20天,正好卡在一个有意思的坎上:语法基本摸清了,类、对象、继承这些概念也都有了印象,但伸手写代码的时候,一碰到想查个时间、转个数字、处理个字符串,就傻眼了——明明感觉得有现成工具,就是不知道去哪儿找、怎么用。这个阶段的核心任务只有一个词:API。Java里的时间API和包装类,正是两个最典型的“高频API场景”。这篇就把day20的三块内容——时间API、包装类、配套练习——里面最容易踩的坑、最值得记住的细节,连同可复制的代码一起整理出来。适合正在学Java基础的人,也适合想快速复习这两个知识点的同学。
1. 这堂课的核心:先搞懂API的打开方式
1.1 API在Java学习里的准确定位
很多人第一次听到“API”是在各种接口文档、网页开放平台的场景里,总觉得是个很高大上的东西。其实在Java学习的语境下,API就是指JDK自带的那些已经写好了的类和方法。你不需要知道String底层怎么存字符,直接调length()、substring()就行了;不需要自己实现日期算法规避闰年问题,直接问LocalDate要答案就行。这套“别人打好包、你拿过来用”的能力,就是API的使用能力。
day20这个节点选得挺巧。前面十几天的内容基本把语法、面向对象、常用类过了一遍,学生往往处于“听得懂、写不出”的状态。写不出不是因为语法不会,而是不知道有哪些现成的东西可以用,或者知道有但不知道怎么调。所以这一课的隐藏任务其实是培养“检索能力”:遇到一个需求,能判断出该去哪个包、哪个类里找工具。
1.2 看API文档的三个落脚点
拿到一个不熟悉的类,我习惯先问三个问题:它是哪个包的?对象怎么来?核心方法有哪些?
先说包。Java的类库按功能分了包,java.lang是语言核心类,String、Math、包装类都在里面,不需要import;java.util是工具类的大本营;java.time是Java 8以后的时间API;java.io是输入输出。看到一个类名,先看包名,大概就能猜到它是干什么的。这个习惯能省很多时间。
再说对象怎么来。每个类创建对象的方式不太一样,常见的有这么几种:new出来的(比如Scanner)、静态工厂方法拿到的(比如LocalDate.now())、通过其他对象转换来的(比如从LocalDateTime取出LocalDate)。搞清楚入口,后面就好办了。最后是核心方法,不用记全,记住最常用的几个,剩下的让IDE自动补全提示。
1.3 别背方法,背“查法”
有学生问我,API方法这么多,考试记不住怎么办。我的回答是:本来就不该背。你只需要知道“有这么个东西”和“大概叫这个名字”,写代码的时候一敲.,IDEA会把候选方法列出来,配上参数说明,比死记硬背靠谱得多。真正需要花心思的是建立“需求→类→方法”的映射。比如需求是“解析用户输入的日期字符串”,脑子里能浮现出LocalDate.parse和DateTimeFormatter这两个名字,就足够了。
这个阶段做练习的目的也不是把API背熟,而是通过题目把这种映射练成肌肉记忆。下面两章详细拆时间API和包装类这两块硬骨头。
2. 时间API:新老交替中的那些坑
2.1 老API为什么让人头疼
如果你在网上搜“Java 获取当前时间”,会看到很多老教程在用java.util.Date和java.util.Calendar。这两个类在Java 8之前统治了十几年,但问题不少:Date的大多数方法比如getYear()、getMonth()早就标记为废弃,功能被拆到Calendar里,可Calendar的月份又是从0开始的——12月对应的居然是11,新手写Calendar.getInstance().get(Calendar.MONTH)拿到的月份总要+1;SimpleDateFormat做日期格式化倒是好用,但它不是线程安全的,多线程环境下同一个格式化对象会解析出错。这些坑叠加在一起,让“取个时间”这种基础操作变得很不省心。
Java 8开始引入的java.time包,思路非常清晰:核心类都设计成不可变的,类似String,每次修改返回一个新对象,天然线程安全;月份的语义恢复成1到12,符合直觉;把“日期”和“时间”拆开建模,各管各的。day20讲时间API,讲的就是这一套新体系。
2.2 LocalDate、LocalTime、LocalDateTime怎么用
先记住一句话:LocalDate只管年月日,LocalTime只管时分秒,LocalDateTime两个都要。
最常用的几个操作:
- 获取当前日期时间:LocalDate.now()、LocalTime.now()、LocalDateTime.now()
- 手动指定:LocalDate.of(2024, 12, 25)、LocalTime.of(14, 30, 0)
- 读取字段:getYear()、getMonthValue()、getDayOfMonth()、getHour()、getMinute()
- 修改字段:withYear(2025)、withDayOfMonth(1),注意返回的是新对象
- 加减:plusDays(7)、minusMonths(1)、plusHours(2),同样返回新对象
这几类操作有个共同点:原对象不会被改动。很多人在这里翻车,写了一行:
LocalDate date = LocalDate.now(); date.plusDays(10); System.out.println(date);结果打印出来还是今天。原因是plusDays返回了一个新日期,但你没有接收这个返回值。正确的写法是:
LocalDate date = LocalDate.now(); LocalDate newDate = date.plusDays(10);这一点从方法签名也能看出来,plusDays返回的是LocalDate而不是void,设计上就是在提示你“我是有返回值的,别把我丢了”。
2.3 时间戳与时区
除了给人看的年月日时分秒,程序里还经常需要“时间戳”,也就是从1970年1月1日0点(UTC)到某个时刻经过的毫秒数或纳秒数。java.time里对应的类是Instant,可以用Instant.now()拿当前时刻,用toEpochMilli()转成毫秒值,这就是平时常说的“当前时间戳”。如果你搜过“时间戳转时间”,应该见过那种在线转换工具,其实原理很简单:拿到毫秒值,换算成年月日时分秒而已。
从时间戳转回可读时间也常用:
Instant ts = Instant.ofEpochMilli(1700000000000L); ZonedDateTime zdt = ts.atZone(ZoneId.systemDefault()); System.out.println(zdt);这里必须指定时区,因为世界各地的“同一个时间戳”对应的墙上钟点不一样。在中国我们习惯UTC+8,所以打印出来的时间会比UTC时间多8小时。不注意时区的话,容易出现“时间戳转出来差了8个小时”的诡异问题。ZonedDateTime就是带时区的完整时间,ZoneId.systemDefault()会读取操作系统当前的时区设置,大多数情况下是对的。
实际项目中,我看到很多团队的习惯是:数据库存时间戳或UTC时间,程序里统一用LocalDateTime.now()显示本地时间,只有涉及跨时区业务才拿出ZonedDateTime。核心原则一条:同一个时间点,在不同时区下要显示成对应的不同钟点。
2.4 格式化与解析:DateTimeFormatter
老方案的SimpleDateFormat线程不安全,新方案里换成了DateTimeFormatter,线程安全,而且用法更直白。
格式化就是把时间对象变成字符串:
DateTimeFormatter f = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); String text = LocalDateTime.now().format(f);解析就是把字符串变回时间对象:
DateTimeFormatter f = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); LocalDateTime dt = LocalDateTime.parse("2024-08-15 09:30:00", f);两个细节值得单独说。
一是模式字母很容易写错。yyyy表示四位数年份,MM是大写表示月份,mm是小写表示分钟,HH表示24小时制,hh表示12小时制。如果你把“yyyy-MM-dd HH:mm:ss”里的月份MM写成mm,解析出来的结果就会出现用“分钟数”去当“月份数”的错乱。这类错误靠肉眼不好排查,调试的时候先检查pattern。
二是yyyy和uuuu的差别。yyyy代表year-of-era,uuuu是proleptic year,对公元纪年来说绝大多数情况下结果一样,但在一些特殊年份可能产生歧义。Java官方也建议格式化时用uuuu而不是yyyy。这个知识点大部分教材不讲,面试或者代码review时倒是偶尔会冒出来,知道一下没坏处。
另外提醒一句:LocalDate.parse("2024-01-01")这种写法,默认解析格式就是ISO标准的yyyy-MM-dd,所以不需要额外传formatter。但如果字符串是“2024/01/01”这种,就必须手动指定pattern了。
2.5 时间差计算:Duration与Period
算两个时间差,先分清对象类型。如果算的是时分秒这种精确的时间量,用Duration;如果算的是年月日这种日历上的差量,用Period。两者都是between方法。
日期之间的天数,更直接的还有ChronoUnit:
LocalDate start = LocalDate.of(2024, 1, 1); LocalDate end = LocalDate.of(2024, 2, 1); long days = ChronoUnit.DAYS.between(start, end); // 31ChronoUnit是个枚举,除了DAYS,还有HOURS、MINUTES、SECONDS、MONTHS等,配合between可以算几乎任何粒度的间隔,非常实用。
Duration示例:
LocalTime t1 = LocalTime.of(9, 15, 30); LocalTime t2 = LocalTime.of(10, 45, 12); Duration d = Duration.between(t1, t2); System.out.println(d.toMinutes()); // 89 System.out.println(d.toSeconds()); // 5382Period示例:
LocalDate birth = LocalDate.of(2000, 5, 10); LocalDate today = LocalDate.of(2024, 10, 1); Period p = Period.between(birth, today); System.out.println(p.getYears() + "岁" + p.getMonths() + "个月" + p.getDays() + "天");要特别留意Period.between的一个特性:它返回的是“年、月、日”三段式的差值,不保证总天数精确。比如2000年1月31日到2000年2月29日,Period给出的结果是1个月,而不是29天。如果你的业务需要精确到天,请用ChronoUnit.DAYS.between,不要拿Period的结果再去换算天数,否则很容易踩到“结果差了好几天”的坑。
3. 包装类:自动装箱拆箱背后的细节
3.1 包装类的存在意义
Java是面向对象语言,但int、double这些基本类型不是对象,没有方法,也不能放进List、Map这类泛型容器里。为了弥补这个割裂,Java为每个基本类型配了一个对应的包装类:int对应Integer,char对应Character,其余都是首字母大写,比如Long、Double、Boolean。有了这些包装类,“把数字当成对象处理”这件事才变成可能。比如List 是合法的,List 编译都过不了。
包装类另一个重要用途是把字符串转成数字。程序里从配置文件、请求参数、键盘输入拿到的都是字符串,要参与计算就得先转换:
int num = Integer.parseInt("42"); double rate = Double.parseDouble("3.14");这就是包装类在day20里绕不开的原因——练习也好,现实开发也好,字符串和基本类型之间的转换几乎无处不在。
3.2 装箱拆箱的底层逻辑
从Java 5开始,基本类型和包装类之间可以直接赋值,语法上很爽:
Integer num = 100; // 自动装箱:int -> Integer int n = num; // 自动拆箱:Integer -> int表面看这是编译器的魔法,实际上它帮我们插入了方法调用。装箱用的是valueOf:
Integer num = Integer.valueOf(100);拆箱用的是intValue:
int n = num.intValue();想验证也很容易,写个简单的类用javap -c反编译看看字节码,里面清清楚楚能看到invokestatic Integer.valueOf和invokevirtual Integer.intValue这两行调用。
了解了这层本质,很多看似诡异的现象就有了解释。比如包装类变量在没有赋值时是null,一旦拆箱就会抛NullPointerException:
Integer score = null; int s = score; // 空指针再比如循环里频繁做+1操作,每次都会产生一个新的Integer对象,虽然数字小,但量大了也会产生不必要的垃圾对象。优化手段是:如果只是单纯累加,用int比用Integer更合适。
3.3 Integer缓存:==和equals的经典坑
这是包装类最经典的考点,面试题里的老面孔。直接看代码:
Integer a = 100; Integer b = 100; System.out.println(a == b); // true Integer c = 200; Integer d = 200; System.out.println(c == d); // false同一个比较写法,结果却不一样,根子就在Integer.valueOf这个静态方法上。JDK实现里,valueOf会对-128到127这个范围内的整数做缓存,也就是说这个范围内的数值每次返回的都是同一个对象,==比较的就是对象引用,自然相等。超过127,每次都新建对象,引用不同,==就成了false。
那条缓存边界为什么是127?因为这个范围内的小整数使用频率最高,缓存它们能省下大量重复对象。其实Long也有同样的缓存范围,Character缓存的范围是0到127,Boolean就更直接了,一共俩对象。
实操建议很简单:包装类的值比较,统一用equals,别用==。除非你能确认两边都是同一个对象的引用,否则==就是给自己埋雷。另外注意,显式new出来的Integer不走缓存:
Integer x = new Integer(100); System.out.println(x == a); // false这个虽然平时大家不怎么写,但看到别人代码这么干的时候得能反应过来。
3.4 字符串与数字互转要点
转换工具集中在包装类上,记住几个典型入口就行。字符串转int用Integer.parseInt,转Integer对象用Integer.valueOf;字符串转double用Double.parseDouble;反过来,数字转字符串最简单的方法是拼空串:"" + 100,也可以用String.valueOf(100),后者更规范。
这里要区分parseInt和valueOf的差异:parseInt返回的是基本类型int,valueOf返回的是包装类Integer。大多数场景两者都能用,但如果结果要放进泛型容器,得用valueOf。
parseInt这类方法有个显著特点:解析不了就会抛NumberFormatException。比如Integer.parseInt("12abc"),或者传进来一个null,程序直接崩。真实业务里外部输入是不可信的,所以要么提前用正则过滤,要么用try-catch兜底。练习里的标准写法一般是这样:
String input = "42"; try { int num = Integer.parseInt(input); System.out.println("数字是:" + num); } catch (NumberFormatException e) { System.out.println("不是合法数字:" + input); }还有个细节值得提醒:用equals比较包装类时,Integer和Long比较也不会相等,因为类型都不同,这个在写通用代码的时候比较容易踩。
4. 配套练习:怎么练才不吃灰
4.1 练习一:生日有效性校验
题目背景是注册页面的生日输入框。用户输入“2008-02-30”这种不存在的日期,程序得能识别出来并给出提示。这个练习的意图很明确:考察LocalDate解析和异常处理的能力。
最简单的做法就是让LocalDate.parse去解析,遇到无法解析的日期,异常会告诉我们:
Scanner sc = new Scanner(System.in); System.out.print("请输入生日(yyyy-MM-dd):"); String input = sc.nextLine(); try { LocalDate birth = LocalDate.parse(input); System.out.println("生日合法:" + birth); } catch (DateTimeParseException e) { System.out.println("日期格式错误或日期不存在"); }DateTimeParseException是DateTimeException的子类,专门用来标识日期解析失败。这个练习写完可以扩展一步:判断年龄是否在合理范围内,比如小于120岁算合理,用Period.between(birth, LocalDate.now()).getYears()就能拿到年龄。这类“校验+异常兜底”的思路,在真实项目里非常常见,值得反复练。
4.2 练习二:计算存活天数和下次生日
这个题目综合性更强:算一个人从出生到现在活了多少天,再算距离下一个生日还有多少天。第一个问题的标准答案是ChronoUnit:
LocalDate birth = LocalDate.of(2000, 5, 10); LocalDate today = LocalDate.now(); long days = ChronoUnit.DAYS.between(birth, today); System.out.println("存活天数:" + days);第二个问题稍微绕一点。先构造今年的生日,如果今天的日期在这个生日之后,说明今年的已经过了,那就得看明年的:
LocalDate thisYearBirthday = birth.withYear(today.getYear()); LocalDate nextBirthday = thisYearBirthday; if (thisYearBirthday.isBefore(today) || thisYearBirthday.isEqual(today)) { nextBirthday = thisYearBirthday.plusYears(1); } long daysUntilBirthday = ChronoUnit.DAYS.between(today, nextBirthday);这个题目的价值在于逼你理清“今天的生日过没过”这个边界条件。如果写成只要isBefore就跳到明年,那刚好生日当天就会算成还差365天,错了。边界测试的习惯,从这个练习开始培养正好。
4.3 练习三:字符串拆分数值并统计
题目设定:给一个以逗号分隔的字符串,比如“12,45,abc,78”,要求提取其中的数字并计算总和、个数、平均值。这个练习考察的是包装类解析和脏数据处理。
参考实现:
String data = "12,45,abc,78"; String[] parts = data.split(","); int sum = 0; int count = 0; for (String part : parts) { try { sum += Integer.parseInt(part.trim()); count++; } catch (NumberFormatException e) { System.out.println("忽略非数字:" + part); } } double avg = count == 0 ? 0 : (double) sum / count; System.out.println("数字个数:" + count + ", 总和:" + sum + ", 平均:" + avg);这里有两个小细节。一是part.trim()很关键,输入里可能有空格,不trim的话parseInt照样抛异常;二是除法前把sum强转成double,否则两个int相除结果还是int,平均值的小数部分就丢了。这个练习做完可以对“输入不可信”有非常直观的体感,比单纯讲理论有用得多。
4.4 练习中的典型错误
整理一下我实际批改代码时见得比较多的错误:
| 错误现场 | 原因 | 解决方案 |
|---|---|---|
| 输出结果比预期多了8小时 | 时间戳直接格式化,没考虑时区 | 用atZone(ZoneId.systemDefault()) |
| 月份总是差1 | 还用Calendar的0~11月份习惯去套新API | LocalDate直接用1~12月份 |
| 格式化出来的秒数是0 | pattern里MM和mm写混 | MM是月,mm是分钟,HH是24小时制小时 |
| 日期parse报错 | 字符串格式和pattern不匹配 | 先确认字符串长什么样,再写pattern |
| Integer用==比较翻车 | 超过缓存范围128以上返回false | 包装类比较一律用equals |
| 字符串转数字崩溃 | 没处理NumberFormatException | 先try-catch或正则过滤 |
| 时间差算出来是负数 | between的参数顺序写反了 | 注意between是“开始,结束”,结束晚于开始 |
| plusDays之后没接返回值 | 忽略了不可变性的设计 | 修改结果一定要用变量接收 |
这张表基本覆盖了时间API和包装类最容易翻车的位置,写代码前想一遍,能省很多调试时间。
5. 高频问题速查与经验小结
5.1 五个高频问题与应对思路
问题一:LocalDateTime和java.util.Date互转。有些老项目数据库驱动还返回java.sql.Date、java.sql.Timestamp,需要桥接。时间戳方向的转换比较简单,Date.from(instant)可以把Instant转回Date;把java.sql.Timestamp转LocalDateTime更直接,调用timestamp.toLocalDateTime()就可以。
问题二:到底用LocalDateTime还是Instant。简单项目存本地时间就用LocalDateTime,不折腾;涉及跨时区或者需要精确记录某个瞬间,用Instant;需要把时刻展示成某地钟点的场景里,用ZonedDateTime。原则是能用简单方案就不用复杂方案。
问题三:Integer比较用==还是equals。一句话,包装类永远用equals。别依赖于缓存范围,那是实现细节,不是语言规范,下次JDK升级改了缓存边界,你的代码就翻车。
问题四:日期解析为什么尽量用try-catch而不是先写正则。因为日期合法性判断本身就复杂:2月天数、闰年、大小月,正则写起来很啰嗦而且容易漏。既然LocalDate.parse已经把校验逻辑写好了,我们直接复用就行,这是API存在的意义。
问题五:练习代码写完怎么自查。我的习惯是给每个方法写一个普通的main方法,手动输入正常值、边界值、异常值各测一遍。比如解析日期,试试“2024-02-29”这种闰年合法日期和“2023-02-29”这种非法日期,看看行为是否符合预期。边界值测试是代码质量的分水岭。
5.2 学API真正的收获
day20的API内容看起来是死知识,但背后是一种很重要的思维方式:不重复造轮子。JDK已经替我们处理好了时区、闰年、进制转换、缓存策略这些复杂细节,写业务代码的人只需要以正确的姿势调用它们。学这堂课最大的收获不是记住LocalDate有几个方法,而是真正建立“遇到时间问题先想java.time,遇到类型转换先想包装类”的直觉。有了这种直觉,后面学集合、学IO、学多线程,哪怕API再多再杂,你也不会慌——反正都是查文档、写小demo验证的手艺活。
我个人上课时经常跟学生说的一句话是:API是工具书,不是课本。别从头到尾读,用到什么查什么,查完立刻写代码验证。你花在反复查文档上的每一分钟,最后都会变成写代码时的底气。这一课如此,后面所有课程都如此。