
学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、包装类都在里面不需要importjava.util是工具类的大本营java.time是Java 8以后的时间APIjava.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)拿到的月份总要1SimpleDateFormat做日期格式化倒是好用但它不是线程安全的多线程环境下同一个格式化对象会解析出错。这些坑叠加在一起让“取个时间”这种基础操作变得很不省心。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);这里必须指定时区因为世界各地的“同一个时间戳”对应的墙上钟点不一样。在中国我们习惯UTC8所以打印出来的时间会比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-erauuuu是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方法。日期之间的天数更直接的还有ChronoUnitLocalDate 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对应Integerchar对应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表面看这是编译器的魔法实际上它帮我们插入了方法调用。装箱用的是valueOfInteger num Integer.valueOf(100);拆箱用的是intValueint n num.intValue();想验证也很容易写个简单的类用javap -c反编译看看字节码里面清清楚楚能看到invokestatic Integer.valueOf和invokevirtual Integer.intValue这两行调用。了解了这层本质很多看似诡异的现象就有了解释。比如包装类变量在没有赋值时是null一旦拆箱就会抛NullPointerExceptionInteger 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到127Boolean就更直接了一共俩对象。实操建议很简单包装类的值比较统一用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返回的是基本类型intvalueOf返回的是包装类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 练习二计算存活天数和下次生日这个题目综合性更强算一个人从出生到现在活了多少天再算距离下一个生日还有多少天。第一个问题的标准答案是ChronoUnitLocalDate 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月份习惯去套新APILocalDate直接用1~12月份格式化出来的秒数是0pattern里MM和mm写混MM是月mm是分钟HH是24小时制小时日期parse报错字符串格式和pattern不匹配先确认字符串长什么样再写patternInteger用比较翻车超过缓存范围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是工具书不是课本。别从头到尾读用到什么查什么查完立刻写代码验证。你花在反复查文档上的每一分钟最后都会变成写代码时的底气。这一课如此后面所有课程都如此。