尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java时间API与包装类核心机制全解析:从Date到LocalDateTime

Java时间API与包装类核心机制全解析:从Date到LocalDateTime day20-API时间包装类练习——Java学习者必收的一堂实操课如果你正好学到Java基础的中段开始接触java.lang和java.util里那一堆现成的类大概率会有点懵时间到底用Date还是LocalDateTimeInteger和int什么时候能直接用什么时候又必须得转换这还不算完面试题里最经典的“为什么Integer a 100; Integer b 100; a b返回 true换成 200 就返回 false”也是从这里来的。这篇文章不只是把API文档里抄下来的方法列一遍而是把我自己在学习和实际写代码过程中踩过的坑、总结出来的判断标准、还有一套可以跟着敲的练习完整地分享出来。内容围绕day20-API时间包装类练习这个主题展开适合正在学 Java 基础、准备应对笔试面试、或者想搞清楚时间处理到底该怎么选的读者。读完以后你会对Date、Calendar、LocalDateTime、Integer这些高频类有一个真正能落地的认知以后再见到时间换算、包装类比较的代码心里会很有底。1. 为什么说API是Java开发者的第一生产力1.1 从需求到API面对问题先查Java SE文档做 Java 开发的人每天都会跟 API 打交道不管你是刚学循环和数组的小白还是已经写过几个项目的初级工程师遇到问题第一反应不应该是自己造轮子而是去 JDK 文档里找有没有现成的类和方法。学day20这一课本质上就是训练一种能力把“我需要一个当前时间”“我需要把一个字符串变成时间”“我需要把基本类型变成对象”这类需求快速映射到Java SE的标准类库上。我见过不少初学者明明SimpleDateFormat就能格式化时间非要自己写拼接逻辑最后不仅代码难看还容易出错。这就是对 API 不够熟。反过来如果你能养成“先查文档、再看用法、最后上手测试”的习惯学习效率会高很多。比如看到LocalDateTime.now()这个方法你得知道它返回的是什么对象然后接着去查LocalDateTime的实例方法有哪些这样很快就能串起一条链路。1.2 时间API的演进轨迹从Date到LocalDateTimeJava 的时间 API 其实是个典型的“迭代史”。早期版本里只有Date后来觉得它设计得不行又出了Calendar再后来 JSR-310 规范出现Java 8 才正式引入java.time包。很多人问为什么要换原因很简单老的Date类构造方法比如new Date(2025, 1, 1)里的月份是从 0 开始的而且修改时间、计算差值都非常别扭。Calendar虽然功能多了一点但月份依然是从 0 开始代码里动不动就要做1的修正非常容易出 bug。新的LocalDateTime、LocalDate、LocalTime这一组类设计就舒服多了。月份从 1 到 12构造函数直观而且区分了“带日期的时间”和“纯日期”“纯时间”三种场景。更重要的是它提供了plusDays、minusHours、between这类链式方法写起来和读起来都像自然语言。1.3 包装类存在的意义让基本类型也有对象身份Java 是面向对象的语言但int、double、boolean这些基本类型不是对象。集合框架List、Map只能存放对象泛型参数也不接受基本类型所以就必须有对应的包装类。int对应Integerdouble对应Doubleboolean对应Boolean这就是包装类最直接的存在价值。除了让基本类型“对象化”包装类还附带了一堆静态工具方法。比如Integer.parseInt(123)能把字符串转成数字Integer.valueOf(200)能拿到一个Integer实例。这些东西在实际开发里几乎天天用。所以说学包装类不只是为了应付面试更是为了真正理解 Java 的类型体系。2. 时间处理的实战拆解从传统API到新时间API2.1 Date和SimpleDateFormat的坑先上一段初学者最常写的代码Date now new Date(); System.out.println(now);如果放在早期 Java 里控制台输出的可能是Sat Jan 04 15:33:21 CST 2025这种格式不仅英文看不习惯而且默认格式并不能满足业务需求。于是大家会配合SimpleDateFormat来格式化Date now new Date(); SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); String timeStr sdf.format(now); System.out.println(timeStr);这段代码能用但有两个非常经典的坑。第一个坑SimpleDateFormat不是线程安全的多线程环境下共享同一个实例时format和parse方法内部会修改Calendar相关的成员变量导致结果错乱。第二个坑parse方法对字符串的容错性很差你如果用yyyy/MM/dd去解析2025-01-04直接就抛ParseException。更麻烦的是Date本身的可读性太差你看不到“取年份”这种直白的操作只能靠Calendar间接完成。所以现在写新代码我强烈建议直接用java.time包里的类。只有在维护老项目、对接老旧接口时才需要继续接触Date和SimpleDateFormat。2.2 LocalDateTime/LocalDate/LocalTime核心用法新时间 API 有一个核心概念用不同类型的类表达不同的时间维度。LocalDate today LocalDate.now(); // 日期不含时分秒 LocalTime nowTime LocalTime.now(); // 时间不含年月日 LocalDateTime now LocalDateTime.now(); // 日期时间这三者的关系非常清晰有点像一个物体的“日历页”“时钟”“日历配时钟”。日常业务中如果只需要记录生日、合同日期这类信息用LocalDate就够了如果只需要记录打卡时间比如每天几点上班用LocalTime如果是订单创建时间、日志时间戳这种完整时刻就用LocalDateTime。最常用的一组方法我整理在下面LocalDateTime now LocalDateTime.now(); // 获取字段 int year now.getYear(); int month now.getMonthValue(); // 注意不是 getMonthgetMonth 返回英文 Month 枚举 int day now.getDayOfMonth(); int hour now.getHour(); // 计算 LocalDateTime tomorrow now.plusDays(1); LocalDateTime twoHoursAgo now.minusHours(2); // 比较 boolean before now.isBefore(tomorrow);这些方法命名很直观基本不需要查文档就能记住。相比于Calendar里靠get(Calendar.YEAR)这种常量加索引的方式新 API 简直是降维打击。2.3 时间戳、时区与格式化这些细节必须掌握时间戳timestamp是一个绕不开的概念。它本质上是一个从1970-01-01T00:00:00ZUTC 时间开始计算的毫秒数在 Java 里用System.currentTimeMillis()获取。新的时间 API 也提供了互相转换的方法// LocalDateTime - 时间戳 LocalDateTime now LocalDateTime.now(); ZoneId zone ZoneId.systemDefault(); long timestamp now.atZone(zone).toInstant().toEpochMilli(); // 时间戳 - LocalDateTime LocalDateTime dateTime LocalDateTime.ofInstant( Instant.ofEpochMilli(timestamp), zone);这里最常出现的困惑是“我明明写了当前时间怎么存到数据库后差了 8 个小时”。原因就是时区。LocalDateTime本身不带时区信息当你用系统默认时区去atZone转换时它会假设这个时间属于当前时区。如果服务器设置的时区是 UTC而你的业务时间是北京时间那么你一转换存储或回显的时候自然就会错位。我的建议是在项目里统一使用Instant或者带时区的ZonedDateTime存储内部时间展示时再转成LocalDateTime并指定目标时区格式。格式化方面推荐使用DateTimeFormatter它是线程安全的可以定义为静态常量DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); String str LocalDateTime.now().format(formatter); LocalDateTime parsed LocalDateTime.parse(2025-01-04 12:00:00, formatter);注意parse的字符串格式必须严格匹配yyyy-MM-dd和yyyy/MM/dd不通用这个坑在后面练习里还会再踩一次。3. 包装类核心机制与应用场景3.1 自动装箱/拆箱的底层原理包装类最神奇的一点是你似乎可以直接在int和Integer之间做运算Integer num 10; // 自动装箱int - Integer int value num; // 自动拆箱Integer - int Integer sum num 5; // 先拆箱加法再装箱这个过程是编译器在背后帮你调用了valueOf和intValue方法。也就是说Integer num 10等价于Integer num Integer.valueOf(10)int value num等价于int value num.intValue()。理解这一点很重要因为很多隐蔽 bug 都出在“拆箱”上。比如Integer n null; int m n; // 运行时会抛出 NullPointerException因为拆箱要调用n.intValue()而n为 null调用方法当然会空指针。所以如果你不确定包装类是否为 null千万不要直接参与算术运算或赋值给基本类型一定要先判空。3.2 Integer缓存问题为什么100和200的结果不同这个堪称面试必考也是初学者最容易绕晕的地方。先看代码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方法内部有一个缓存机制当数值在-128到127之间时它直接返回缓存池里的同一个对象超过这个范围就new Integer(value)创建一个新对象。所以100落在缓存范围内a和b指向同一个对象比较地址自然是 true200超出范围c和d是两个不同对象比较地址就是 false。这只是Integer的特殊规则不要推断到所有包装类上。Long也有类似的缓存范围同样是-128到127Double和Float是浮点数没有缓存池。这里给你一个非常实用的结论判断两个包装对象的数值是否相等绝对不要用要用equals方法或者拆箱成基本类型后再比较。Integer x 200; Integer y 200; System.out.println(x.equals(y)); // true System.out.println(x.intValue() y.intValue()); // true3.3 包装类在集合与泛型中的应用集合里只能放对象所以你没法直接写Listint只能写ListInteger。泛型擦除之后集合底层存的都是Object当你向ListInteger添加int类型时编译器会自动装箱成Integer。这个场景最容易出现的问题是“拆箱异常”和“不小心把 null 存进去”。比如ListInteger list new ArrayList(); list.add(null); int first list.get(0); // 拆箱时 NullPointerException一定要养成习惯从集合里取元素之前先判断是否为 null。尤其是从数据库查出来的数据映射到实体类时基本类型字段通常会转成包装类这些字段可能是 null不要直接用号做运算。包装类在Map中也经常作为 value 使用比如统计词频MapString, Integer wordCount new HashMap(); wordCount.put(java, wordCount.getOrDefault(java, 0) 1);这里getOrDefault方法可以优雅地避免 null 判断是日常开发中很实用的小技巧。4. 练习设计用三个案例把今天的内容焊死4.1 练习1计算时间差并输出格式化的结果需求输入一个人的出生日期字符串格式为yyyy-MM-dd计算他出生到现在一共活了多少天并输出“你已经活了X天Y小时”。如果用老 API你需要先把字符串解析成Date然后算毫秒差再手动除 86400000 得到天数过程繁琐。用新 API 会清晰很多import java.time.LocalDate; import java.time.temporal.ChronoUnit; public class DayDiffDemo { public static void main(String[] args) { String birthStr 2000-01-01; LocalDate birth LocalDate.parse(birthStr); LocalDate today LocalDate.now(); long days ChronoUnit.DAYS.between(birth, today); long hours days * 24L; System.out.println(你已经活了 days 天 hours 小时); } }这里值得注意的一个细节是ChronoUnit.DAYS.between返回的是long因为天数和小时都可能超过int范围。我在实际写的时候会故意用long来接避免放大后溢出。如果只计算日期差值且不在意时分秒Period.between也可以但它返回的是“年/月/日”的拆分结果比如Period.of(25, 0, 3)表示 25 年 0 个月 3 天适合做生日倒计时这类业务。4.2 练习2字符串转日期与日期校验需求用户输入的日期可能是2025-1-4也可能是2025/01/04还可能是带时间2025-01-04 12:30:00。要求写一个方法能自动识别常见格式并转成LocalDateTime转换失败时给出友好提示。这个练习的核心是理解DateTimeFormatter的严格匹配。我自己的做法是定义一个格式数组public static LocalDateTime parseFlexible(String dateStr) { String[] patterns { yyyy-MM-dd, yyyy/MM/dd, yyyy-MM-dd HH:mm:ss, yyyy/MM/dd HH:mm:ss }; for (String pattern : patterns) { try { DateTimeFormatter formatter DateTimeFormatter.ofPattern(pattern); if (pattern.length() 10) { return LocalDate.parse(dateStr, formatter).atStartOfDay(); } return LocalDateTime.parse(dateStr, formatter); } catch (DateTimeParseException ignored) { // 继续尝试下一种格式 } } throw new IllegalArgumentException(无法解析的日期格式: dateStr); }有个小细节LocalDateTime.parse只能解析带时间的字符串所以对于纯日期格式需要先用LocalDate.parse再调用atStartOfDay()补上零点。这个练习做完你对“格式化”和“解析”两个方向的理解会非常深。4.3 练习3利用包装类完成班级成绩统计需求有一个ListDouble存放班级学生的成绩统计最高分、最低分、平均分并且计算 60 分以下的人数。这个练习专门用来训练包装类和自动装箱拆箱import java.util.ArrayList; import java.util.List; public class ScoreStat { public static void main(String[] args) { ListDouble scores new ArrayList(); scores.add(88.5); scores.add(42.0); scores.add(73.0); // ... 继续添加 double max Double.MIN_VALUE; double min Double.MAX_VALUE; double sum 0.0; int failCount 0; for (Double score : scores) { if (score null) { continue; } double s score; // 拆箱 if (s max) max s; if (s min) min s; sum s; if (s 60.0) failCount; } System.out.println(最高分 max , 最低分 min , 平均分 (sum / scores.size()) , 不及格人数 failCount); } }这里我特意模拟了score null的情况就是为了提醒你真实项目中从数据库或外部接口拿到的数据很可能有空值遍历集合时一定要判空。另外Double.MIN_VALUE其实是 Double 能表示的最小正数不是负无穷大。如果要用“初始最大值”来求最小值应该用Double.MAX_VALUE求最大值用Double.MIN_VALUE也不太对因为可能存在负数。最稳妥的做法是直接取集合第一个元素作为初始值避免边界问题。实践中我一般会用scores.get(0)来初始化然后从第二个元素开始遍历。5. 常见问题与排查技巧实录5.1 SimpleDateFormat线程安全问题我踩过的坑是在写定时任务时把一个SimpleDateFormat定义为静态成员多个线程同时调用format结果输出的时间偶尔会出现“错乱”的情况比如秒数不对、日期跳变。排查了很久才定位到问题。解决方法有三个第一每次使用都新建实例第二使用ThreadLocal让每个线程有自己的实例第三换成DateTimeFormatter它天然线程安全。我建议新代码直接用DateTimeFormatter旧代码改造成本高的话优先用ThreadLocal。private static final ThreadLocalSimpleDateFormat SDF ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss));5.2 NullPointerException包装类判空这个问题我在博客评论区和读者交流群里被问了不下十次。典型代码长这样Integer count service.getCount(); // 数据库查询结果 int total count 1; // 空指针根本原因就是自动拆箱。记住一个判断方法只要代码里同时出现包装类和一个能拆箱的表达式就要想一下这个包装类会不会是 null。排查时优先看报错行定位到拆箱调用点然后往上追数据来源。我的经验是在实体类设计阶段就明确规定“哪些字段允许为空”。比如订单金额这种必填字段DTO 里就可以用BigDecimal或Double但接口入参时先校验非空对于统计类字段宁可返回默认值 0也不要返回 null这样能省掉大量判空逻辑。5.3 时区坑服务器时间与本地时间相差8小时我刚开始用LocalDateTime.now()存数据库时发现通过 Java 查出来正常但数据库客户端直接看数据总是差了 8 小时。后来确认是数据库连接串里的serverTimezone没配置正确加上服务器时区设置也不统一导致的。推荐的做法是数据库连接串显式指定时区例如 MySQL 使用serverTimezoneAsia/Shanghai存取时间时统一使用Instant或者带时区的对象只有在展示给用户时才转换成本地时间。如果你只是本地学习最简单的验证方式是用下面的代码看当前时区System.out.println(ZoneId.systemDefault());如果输出不是Asia/Shanghai那你后续所有时间转换都可能存在偏移。尤其是用LocalDateTime.now()在不同时区的服务器上部署最终展示给用户的时间会完全乱掉。这个问题虽然不属于 JDK API 本身的语法但在实践中非常常见必须放在“常见问题”里重点讲。5.4 常用问题速查表现象原因解决方案Integer a Integer b时 true换成大数变 falseInteger.valueOf缓存范围是 -128 到 127比较数值用equals或拆箱后比较new Date(2025, 1, 1)得到 2025 年 2 月老 Date 构造月份从 0 开始老代码月份 1新代码使用LocalDate.of(2025, 1, 1)SimpleDateFormat.parse报错格式字符串与输入不匹配使用DateTimeFormatter保持严格匹配时间戳转日期后相差 8 小时服务器时区设置不对或连接串没指定时区统一使用Instant 指定ZoneId转换集合元素强转成 int 报空指针包装类为 null 自动拆箱遍历时判空或用getOrDefault等安全方法最后再说两个我实际用下来的心得第一个心得学习 API 不要死记硬背而是要在脑海里建一个“索引”。看到需求先归类它是“取时间”“算时间差”“格式化”“解析字符串”还是“类型转换”然后去对应类里找方法。Java 的命名非常规范now、of、parse、format、between这几个词出现频率极高记住这些“动词”大部分时间类方法就能猜个八九不离十。第二个心得包装类的坑多半出现在“边界情况”也就是 null、越界、缓存范围。面试题喜欢考是因为这些地方最体现对底层机制的理解。日常写代码时尽量用基本类型做运算用包装类做对象存储一旦发现后面跟着一个包装类立刻停下来思考是否该用equals。养成这种条件反射能帮你避开很大一批问题。这篇文章对应的练习我都实际敲过从Date到LocalDateTime的迁移、从int到Integer的比较每一步踩坑都值得自己再验证一遍。建议你也打开 IDE把 4.1到4.3的练习逐个跑一遍然后故意把代码改出错误来观察异常信息和输出结果。只有亲手敲过、调试过这些 API 才能真正长在你手里。
返回列表