免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Java日期时间处理:从Date到java.time的全面实战指南

Java日期时间处理:从Date到java.time的全面实战指南 如果你去翻现在主流Java岗位的面试题十套里面至少有八套会问到日期时间处理。再打开公司里还在跑的老项目大概率能看到java.util.Date和SimpleDateFormat的“祖传代码”。这套API在Java 8之前几乎是唯一选择但线程安全问题、API设计混乱、月份从0开始计数这些毛病坑了开发者十几年。直到java.time包随Java 8发布Java在日期时间处理上才算真正翻身。这篇文章会把java.time里的核心类、格式化解析、时间运算、时区处理、新旧API互转这些内容全部捋一遍同时穿插一些我在实际项目里踩过的坑和面试中高频出现的考点。内容偏实战代码都能直接抄不管你是刚学Java的新人还是准备跳槽的老手应该都能用得上。1. 为什么我建议你把java.time当作默认选择很多老项目还在用java.util.Date不是因为新API不好而是因为旧代码迁移成本高。但如果你在写新代码没有任何理由继续用旧API。java.time从设计上就解决了旧API最让人头疼的几个问题。1.1 旧API的三个“原罪”先说java.util.Date。这个类名字叫Date但实际它既包含日期也包含时间而且内部存的是一个从1970年1月1日0点起算的long型毫秒数。你打印一个Date对象看到的是Fri Jun 14 10:24:36 CST 2024这种格式又不是严格的ISO格式可读性很差。更难受的是Date是可变的。它提供了setTime()、setYear()这类方法意味着同一个Date对象在多个线程之间共享时可能出现一个线程在读、另一个线程在改的情况。虽然很多老代码把Date当不可变对象用没人去调setter但语言层面没有强制这就是隐患。然后是SimpleDateFormat。这个类的线程安全问题几乎是Java面试的必考题。它的format()和parse()方法内部会修改一个Calendar对象而这个对象是SimpleDateFormat的成员变量。多线程并发调用同一个SimpleDateFormat实例时会出现数据错乱甚至直接抛NumberFormatException。我见过生产环境里因为这个原因导致日期解析偶发报错排查了很久才定位到是SimpleDateFormat没有加锁。最后是设计上的混乱。Date的getMonth()返回值是0到110代表一月getDay()返回的是一周中的第几天而不是一个月中的第几天语义含糊。Calendar类虽然改善了命名但Calendar.MONTH依然从0开始。这种设计的坑在于你写new Date(2024, 6, 15)的时候很难直觉判断出到底代表几月。1.2 java.time到底改变了什么java.time的设计核心是三句话不可变、线程安全、语义清晰。所有核心类比如LocalDate、LocalTime、LocalDateTime、Instant、ZonedDateTime都是final类内部字段也是final的。每次做加减运算、修改字段都会返回一个新的对象原对象不受影响。这样天然线程安全不需要像旧API那样加锁或用ThreadLocal。语义清晰体现在类的拆分上。日期就是LocalDate时间就是LocalTime日期加时间就是LocalDateTime带时区的完整时间点就是ZonedDateTime机器时间戳就是Instant。类名直接告诉你这是什么不会出现用java.util.Date同时又需要考虑时区、年月日时分秒全揉在一起的情况。月份和星期的设计也回归正常Month.JANUARY对应1月DayOfWeek.MONDAY对应周一LocalDate.getMonthValue()直接返回1到12。这套设计学习和使用成本都低很多理解了核心思想之后基本不用翻文档就能猜到方法名。2. 核心类逐个拆解先弄懂这几个你就能干活了java.time包里类很多但实际日常开发中高频用到的就那么几个。我的建议是先熟练掌握LocalDate、LocalTime、LocalDateTime、Instant这四个再搞懂Period、Duration和时区相关类就足以覆盖绝大多数业务场景。2.1 LocalDate、LocalTime、LocalDateTime业务开发的主力LocalDate只表示日期年月日LocalTime只表示时间时分秒纳秒LocalDateTime是两者的组合。这三个类都不携带时区信息适合用来表达“某一天”“某个时间点”这类业务概念比如生日、上班打卡时间、订单创建时间在单时区系统里。创建方式非常直观// 获取当前日期 LocalDate today LocalDate.now(); // 指定日期参数顺序是年月日 LocalDate date LocalDate.of(2024, 6, 15); // 获取当前时间 LocalTime now LocalTime.now(); // 指定时间时分秒 LocalTime time LocalTime.of(14, 30, 0); // 当前日期时间 LocalDateTime dateTime LocalDateTime.now(); // 由日期和时间组合 LocalDateTime combine LocalDateTime.of(today, time);这里注意LocalDate.of()的参数校验做得很严格。你传LocalDate.of(2024, 2, 30)它会在运行期抛出DateTimeException告诉你Invalid date February 30。这个设计很好把错误尽早暴露出来而不是像旧API那样自己静默处理成3月1日之类的结果。从LocalDateTime里可以分别取出日期和时间部分LocalDate datePart dateTime.toLocalDate(); LocalTime timePart dateTime.toLocalTime();反过来也可以用atTime()把日期变成日期时间用atDate()把时间变成日期时间。2.2 InstantUTC时间戳给机器用的Instant表示的是UTC时区下的一个具体时间点内部存储是秒加纳秒。它的核心用途是记录时间戳、跨系统传递时间、做时间比较。比如你往数据库里存一条日志记录“这条日志是什么时候产生的”用Instant.now()最合适因为它不依赖当前系统的时区设置是一个绝对的、无歧义的时间点。Instant timestamp Instant.now(); // 从epoch秒创建 Instant fromEpochSecond Instant.ofEpochSecond(1_718_400_000L);Instant和LocalDateTime的换算需要指定时区。Instant本身没有年月日时分秒的概念只有“从1970年1月1日0点UTC起过了多少秒”的概念。所以你想把一个Instant展示成“2024-06-15 14:30:00”必须先告诉它这个时间是在哪个时区下展示。Instant instant Instant.now(); LocalDateTime dateTime LocalDateTime.ofInstant(instant, ZoneId.systemDefault());反过来把本地时间转成时间戳也是同理LocalDateTime localDateTime LocalDateTime.now(); Instant instant localDateTime.atZone(ZoneId.systemDefault()).toInstant();这两个转换是日常开发中极高频操作我建议直接背下来面试也经常考。2.3 Period和Duration两种“时间段”的区别Period表示年月日维度的区间比如“3年2个月1天”。Duration表示时分秒纳秒维度的区间比如“5小时30分钟”。两者都用在计算两个时间点之间的差值但适用场景不同Period适合处理日历日期Duration适合处理精确的时间长度。LocalDate startDate LocalDate.of(2024, 1, 1); LocalDate endDate LocalDate.of(2024, 6, 15); Period period Period.between(startDate, endDate); System.out.println(period.getMonths()); // 5 System.out.println(period.getDays()); // 14 // 注意Duration.between不能直接用在LocalDate上两者没有可比性 LocalDateTime startDateTime LocalDateTime.of(2024, 6, 15, 8, 0); LocalDateTime endDateTime LocalDateTime.of(2024, 6, 15, 14, 30); Duration duration Duration.between(startDateTime, endDateTime); System.out.println(duration.toHours()); // 6 System.out.println(duration.toMinutes()); // 390这里有个容易踩的坑Period.between()返回的“几个月零几天”是整体差值不是按每个月30天折算的。比如1月31日到2月28日Period结果是“0个月28天”但在某些业务场景里你可能期望它算出“1个月”的近似值。所以用Period做计算的时候先确认业务到底要日历语义还是精确天数语义。3. 格式化与解析DateTimeFormatter的正确用法日期时间处理中格式化和解析占了很大比重。旧API里的SimpleDateFormat线程不安全新API的DateTimeFormatter是完全线程安全的并且设计上更符合直觉。3.1 为什么DateTimeFormatter是线程安全的SimpleDateFormat内部有一个Calendar成员变量format()和parse()过程会修改Calendar的字段值。多线程同时调用时线程A写入的日历状态可能被线程B覆盖导致解析结果错乱。而DateTimeFormatter的设计是格式化的状态全部封装在不可变对象中每次调用format()或者parse()都会创建全新的上下文不会共享可变状态。所以在项目中你可以把DateTimeFormatter定义成static final常量全局共享不用像以前那样每次用SimpleDateFormat都要new一个新实例或者包一层ThreadLocal。public static final DateTimeFormatter DEFAULT_FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss);这个常量可以直接在多个线程里用性能也比每次创建新实例好很多。3.2 格式化LocalDateTime转字符串LocalDateTime now LocalDateTime.now(); DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); // 方式一对象调format String text1 now.format(formatter); // 方式二formatter调format效果一样 String text2 formatter.format(now);两种方式结果没有任何区别看团队代码规范保持统一就行。我个人的习惯是用formatter.format(now)因为读起来更像是“用这个格式器去格式化这个时间”语义更清晰。格式化里最常见的坑是yyyy和YYYY的差异。yyyy是日历年份YYYY是week-based-year也就是基于周计算的年份。在跨年那一周两种结果可能不一致。比如2021年12月31日是周五这一周横跨两年用YYYY-MM-dd格式化可能会得到2022-12-31导致数据记录错乱。这个坑在年底年初的报表需求里特别容易出现建议统一用yyyy。3.3 解析字符串转LocalDateTimeString text 2024-06-15 14:30:00; DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); LocalDateTime dateTime LocalDateTime.parse(text, formatter); LocalDate date LocalDate.parse(2024-06-15); // 默认ISO格式无需指定解析时要注意字符串格式必须和pattern完全匹配哪怕是末尾多一个空格都会抛DateTimeParseException。所以写解析逻辑之前先确认上游接口返回的格式到底长什么样。如果上游格式不固定比如有的是2024-6-15有的是2024/06/15那就要写多个formatter依次尝试或者用正则先做标准化。DateTimeFormatter还内置了一批ISO格式常量比如ISO_LOCAL_DATE对应yyyy-MM-ddISO_DATE_TIME对应2011-12-03T10:15:30。如果你没有特殊格式要求直接用这些内置常量是性能最好的选择。注意使用LocalDate.parse()时不传formatter默认用的是ISO_LOCAL_DATE也就是yyyy-MM-dd格式。传2024/06/15这种格式进去会直接抛异常。我一直建议大家parse的时候习惯性带上formatter宁可多写一行不要依赖默认格式。3.4 常用格式化模式速查模式含义输出示例yyyy四位年份2024yy两位年份24MM两位月份06M一位或两位月份6dd两位日期15HH24小时制小时14hh12小时制小时02mm分钟30ss秒00SSS毫秒123注意hh是12小时制配合a使用才完整比如hh:mm:ss a输出02:30:00 下午。如果你需要24小时制用HH这也是国内业务中最常用的。4. 时间运算与比较日期加减、区间计算一个不能少处理业务时时间运算是最常写的逻辑订单超时计算、活动倒计时、用户年龄计算、报表统计周期……java.time把常用的运算都封装好了用法很顺手。4.1 加减运算plus和minusLocalDate today LocalDate.now(); // 加一天 LocalDate tomorrow today.plusDays(1); // 减一个月 LocalDate lastMonth today.minusMonths(1); // 加一周 LocalDate nextWeek today.plusWeeks(1); // 加10年 LocalDate nextDecade today.plusYears(10);LocalDateTime和LocalTime也都有对应的方法可以加小时、分钟、秒、纳秒。每次运算都返回新对象所以想连续运算的时候可以链式调用LocalDateTime target LocalDateTime.now() .plusDays(7) .minusHours(3) .plusMinutes(30);这里要注意的是对LocalDateTime调用plusDays()可能会跨月甚至跨年API内部会正确处理进位。比如2024-06-30加1天结果是2024-07-01不用担心自己处理月底的逻辑。4.2 with系列把某个字段改成指定值除了加减有时候需要“把日期改成某个月的某一天”这时候用with系列方法。LocalDateTime dateTime LocalDateTime.now(); // 把小时改成8点 LocalDateTime at8 dateTime.withHour(8); // 把月份改成12月 LocalDateTime inDecember dateTime.withMonth(12); // 把日期改成当月的最后一天 LocalDate lastDay date.with(TemporalAdjusters.lastDayOfMonth()); // 把日期改成下一个周一 LocalDate nextMonday date.with(TemporalAdjusters.next(DayOfWeek.MONDAY));TemporalAdjusters这个工具类很实用里面封装了一堆常用的日期调整操作比如firstDayOfMonth()、lastDayOfMonth()、firstDayOfNextMonth()、nextOrSame()、previous()等。我之前做账单结算需求要算“每月最后一个工作日”“下个周一”用了TemporalAdjusters之后代码简单了很多也不容易写错边界逻辑。4.3 计算两个时间之间的差值这是业务开发的高频需求比如计算两个日期相隔多少天、两个时间相隔多少小时。核心工具是ChronoUnit枚举和Duration/Period。LocalDate start LocalDate.of(2024, 1, 1); LocalDate end LocalDate.of(2024, 6, 15); // 两个日期相差多少天 long days ChronoUnit.DAYS.between(start, end); // 166 // 两个日期相差多少周 long weeks ChronoUnit.WEEKS.between(start, end); // 23 LocalDateTime startTime LocalDateTime.of(2024, 6, 15, 8, 0); LocalDateTime endTime LocalDateTime.of(2024, 6, 15, 14, 30); // 两个时间相差多少小时 long hours ChronoUnit.HOURS.between(startTime, endTime); // 6 // 两个时间相差多少分钟 long minutes ChronoUnit.MINUTES.between(startTime, endTime); // 390ChronoUnit.between()返回的是long类型直接可以做后续数值比较。有个容易踩的坑ChronoUnit.DAYS.between()的语义是“两个日期间跨越了多少个24小时”对于LocalDate来说没问题但如果用LocalDateTime计算并且中间经历了夏令时切换结果可能和“日历天数”不一致。国内用东八区没有夏令时问题但如果你做的是海外业务这个点要特别小心。4.4 比较与排序LocalDate、LocalDateTime、Instant等类都实现了Comparable接口可以自然排序和直接比较LocalDate date1 LocalDate.of(2024, 6, 15); LocalDate date2 LocalDate.of(2024, 6, 16); boolean before date1.isBefore(date2); // true boolean after date1.isAfter(date2); // false boolean equal date1.isEqual(date2); // false // 按日期排序 ListLocalDate dates Arrays.asList(date2, date1); dates.sort(Comparator.naturalOrder());isBefore()、isAfter()、isEqual()比用compareTo()再判断大于小于读起来清晰很多而且语义明确后期维护代码的人不容易理解错。在流式处理中排序也可以直接用方法引用ListLocalDateTime times ...; ListLocalDateTime sorted times.stream() .sorted(Comparator.naturalOrder()) .collect(Collectors.toList());4.5 Period和Duration的选用原则再单独强调一下Period和Duration的适用边界Period用于以“年月日”为单位的日历时间差比如“这个任务从开始到现在过了3周21天”适合做日程、生日的场景。Duration用于以“时分秒”为单位的精确时间差比如“这次请求耗时2300毫秒”适合做计时、超时检测。两者的转换方式不同Duration可以用toDays()、toHours()、toMinutes()等直接转成对应的数值Period则不行因为“月份”在不同年份天数不一样没法统一换算成天数。如果业务上需要把“几个月几天”折算成总天数你得自己定规则比如按月均30天来算或者按实际日历逐月累加。5. 时区处理ZonedDateTime和ZoneId的实战用法时区是日期时间处理里最绕的一部分国内单时区环境下很多人会忽略但海外业务、跨时区接口对接、全球部署的应用都必须认真对待。java.time把时区封装成了ZoneId和ZoneOffset两个核心概念。5.1 ZoneId和ZoneOffset的区别ZoneId表示一个“时区”比如Asia/Shanghai、America/New_York它包含夏令时规则。ZoneOffset表示一个“固定偏移量”比如08:00就是一个简单的UTC偏移小时数不涉及夏令时。// 获取系统默认时区 ZoneId defaultZone ZoneId.systemDefault(); // 获取指定时区 ZoneId shanghai ZoneId.of(Asia/Shanghai); ZoneId newYork ZoneId.of(America/New_York); // 获取固定偏移 ZoneOffset offset8 ZoneOffset.ofHours(8); // 08:00实际开发中能用ZoneId表示的时区尽量用ZoneId因为它包含夏令时规则。如果用ZoneOffset来硬编码一个固定偏移当目标地区切换夏令时时你的时间计算就会差一个小时。我处理过北美客户的上线时间一开始用-05:00硬编码后来客户因为夏令时调整提前了一小时上线排查原因就是没有用ZoneId.of(America/New_York)处理。5.2 ZonedDateTime带时区的日期时间ZonedDateTime是在LocalDateTime基础上附加了ZoneId。需要表达“某个时区的某个时刻”时用它。LocalDateTime localDateTime LocalDateTime.of(2024, 6, 15, 10, 0); // 给本地时间加上时区 ZonedDateTime zoned localDateTime.atZone(ZoneId.of(Asia/Shanghai)); System.out.println(zoned); // 2024-06-15T10:0008:00[Asia/Shanghai] // 把上海时间转成纽约时间 ZonedDateTime newYorkTime zoned.withZoneSameInstant(ZoneId.of(America/New_York)); System.out.println(newYorkTime); // 2024-06-14T22:00-04:00[America/New_York]注意withZoneSameInstant()是把同一个时间点表示成另一个时区的时间所以小时数会变化。而withZoneSameLocal()是保持年月日时分秒不变、只改时区这是完全不同的语义。在接口对接、跨时区展示这类需求里绝大多数情况你要用的是withZoneSameInstant()。5.3 跨时区传递时间的正确姿势在两套系统之间传递时间最稳妥的做法是统一用Instant或者带时区的ISO字符串。因为Instant是一个绝对时间点不依赖接收方所处时区。推荐方案是接口出参和入参都使用ISO 8601格式的字符串并且带时区偏移比如2024-06-15T10:00:0008:00。接收方拿到之后先解析成OffsetDateTime或者ZonedDateTime再转成自己系统时区下的LocalDateTime做业务处理。// 接收上游时间2024-06-15T10:00:0008:00 OffsetDateTime offsetDateTime OffsetDateTime.parse(2024-06-15T10:00:0008:00); // 转成系统默认时区 ZonedDateTime zoned offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()); LocalDateTime local zoned.toLocalDateTime();这里用OffsetDateTime.parse()能直接解析带偏移的时间字符串非常方便。不要用LocalDateTime.parse()去解析这种带时区偏移的字符串它根本不认识08:00这种后缀会直接抛异常。踩坑心得任何时候从外部系统拿到带时区的时间字符串一定要先明确“这个时间是什么时区的”再转成你自己的时区。我遇到过上游接口文档写了“北京时间”实际返回来的是UTC时间的情况用错时区换算导致数据偏差了8个小时这种问题很难排查因为环境一换表现就不一样。5.4 夏令时的坑夏令时DST是全球很多国家会采用的制度每年会有一个时间点把时钟拨快1小时秋天再拨回来。在代码层面这会导致LocalDateTime到ZonedDateTime转换时出现“时间不存在”或“时间重复”的情况。比如美国纽约在2024年3月10日凌晨2点把时钟拨到3点那么2024-03-10 02:30:00这个本地时间在纽约时区是不存在的。如果业务逻辑里生成了这个时间atZone()不会报错但会静默调整结果可能不是你期望的时间。国内因为没实行夏令时所以很多开发同学没这个意识。一旦你接触海外业务用ZonedDateTime代替LocalDateTime存储和计算再配合ZoneId而不是固定偏移量能规避大部分问题。遇到某些极端的日历场景也可以考虑引入ThreeTen-Extra这类扩展库里面有更多处理特殊日历规则的API。6. 从旧API无缝迁移Date和LocalDateTime互转现实工作中的难点不是学新API怎么用而是新老代码共存。数据库中存的是java.util.Date接口返回的是java.time.LocalDateTime做转换时很多人会卡住。其实只要理解了中间桥梁是Instant一切就顺了。6.1 java.util.Date和LocalDateTime互转java.util.Date的底层就是一个Instant所以转换的核心是借助Instant。// Date转LocalDateTime Date oldDate new Date(); Instant instant oldDate.toInstant(); LocalDateTime localDateTime LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); // LocalDateTime转Date LocalDateTime dateTime LocalDateTime.now(); Instant instant2 dateTime.atZone(ZoneId.systemDefault()).toInstant(); Date newDate Date.from(instant2);为什么每次转换都要带ZoneId因为Date内部时间点是UTC没有时区概念而LocalDateTime也没有时区概念。你说“2024-06-15 14:30”这个时间在东京是14:30在纽约也是14:30但它们对应的绝对时间点完全不同。所以把LocalDateTime转换成Date绝对时间点时必须先明确这个本地时间是“哪个时区的本地时间”默认用系统当前时区。如果你的应用部署在服务器上并且服务器时区可能被运维修改那么转换结果就会跟着变。这也是为什么我建议在配置里显式指定默认时区比如启动参数加-Duser.timezoneAsia/Shanghai。6.2 java.sql.Date、java.sql.Timestamp和LocalDate互转JDBC操作里常见的是java.sql.Date和java.sql.Timestamp它们分别对应数据库的date和datetime类型。// java.sql.Date转LocalDate java.sql.Date sqlDate new java.sql.Date(System.currentTimeMillis()); LocalDate localDate sqlDate.toLocalDate(); // LocalDate转java.sql.Date LocalDate ld LocalDate.now(); java.sql.Date sqlDate2 java.sql.Date.valueOf(ld); // java.sql.Timestamp转LocalDateTime Timestamp timestamp new Timestamp(System.currentTimeMillis()); LocalDateTime ldt timestamp.toLocalDateTime(); // LocalDateTime转java.sql.Timestamp LocalDateTime ldt2 LocalDateTime.now(); Timestamp timestamp2 Timestamp.valueOf(ldt2);这里特别提醒java.sql.Date.valueOf(LocalDate)这类方法内部依赖JVM的默认时区来把“年月日”翻译成时间戳。如果你的应用需要支持多时区最好不要直接走valueOf而是先转成Instant再操作。不过绝大多数业务系统是单时区部署的用valueOf和toLocalDate这些便捷方法问题不大。6.3 MyBatis和Jackson的配置经验用MyBatis操作数据库时如果字段类型是datetimeJava实体用LocalDateTime大部分情况下TypeHandler已经内置支持不需要额外配置。但如果是数据库的timestamp类型并且你用了ZonedDateTime就要确认MyBatis的TypeHandler是否覆盖到了有些老版本的MyBatis不支持ZonedDateTime需要自己写TypeHandler。Jackson序列化LocalDateTime时默认输出是一个数组或者一个包含大量字段的JSON对象前端根本没法用。我一般会在配置里注册JavaTimeModule并且指定格式Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder - { builder.serializers(new LocalDateTimeSerializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializers(new LocalDateTimeDeserializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; }Spring Boot下用Jackson2ObjectMapperBuilderCustomizer很干净不需要去改全局ObjectMapper配置多个自定义序列化器互不干扰。6.4 数据库存储建议如果数据库是MySQL字段类型在date、datetime、timestamp之间选择时我的建议是只需要年月日就用date对应LocalDate。需要时分秒就用datetime对应LocalDateTime。需要跨时区或者需要自动更新就用timestamp但要注意timestamp范围是1970到2038年远期业务要慎重。尽量不要在数据库里存字符串格式的时间。字符串虽然看起来直观但没法用索引高效查询、没法做范围比较、大小写格式稍微不一致就会出错。日期时间就应该用日期时间类型让数据库帮你做校验和运算。7. 面试高频考点与常见问题速查结合我和面试官、候选人打交道的过程java.time在面试中出现的频率很高但大多数候选人的认知停留在“会用LocalDate.now()”的层面稍微深挖就答不上来了。下面把高频考点和常见坑一起列出来。7.1 高频考点这些八股题你会答吗1. SimpleDateFormat为什么线程不安全因为SimpleDateFormat继承了DateFormat内部持有一个可变的Calendar对象。format()和parse()通过calendar.setTime()等操作修改这个共享状态。多个线程同时调用同一个实例时线程A设置的日期被线程B覆盖导致解析结果异常。2. LocalDate、LocalTime、LocalDateTime、Instant的区别核心区别在于表示范围LocalDate只有日期、LocalTime只有时间、LocalDateTime是日期加时间三者都无时区概念Instant是UTC时间戳表示一个绝对的、无时区依赖的时间点。另外Instant只能表示时间点没有“2024年6月15日”这种日历概念它要转成日历时间必须配时区。3. 如何安全地在多线程环境中格式化日期用DateTimeFormatter它是线程安全的可以直接定义为static常量。如果还在用SimpleDateFormat要么每次使用都new新实例性能开销大要么用ThreadLocal包一层实现起来啰嗦要么加锁并发下性能差。最优雅的方案就是迁移到java.time。4. 如何计算两个日期间的天数ChronoUnit.DAYS.between(startDate, endDate);如果用Duration.between()要注意它和ChronoUnit.DAYS.between()在没有夏令时切换时结果一致但语义和对LocalDate的支持上有差异面试时把这个差异讲清楚是加分项。5. 如何处理时区才能避免夏令时问题用ZoneId而不是ZoneOffset硬编码用ZonedDateTime或OffsetDateTime传递带时区的时间接口层面统一用ISO 8601带偏移格式。避免自己手动计算时差让API根据ZoneId的规则处理夏令时。7.2 常见问题与排查技巧问题表现可能原因解决方案DateTimeParseException: Text 2024-06-15 14:30:00 could not be parsed字符串格式和pattern不匹配检查字符串里的分隔符、空格、24小时制还是12小时制格式化后年份变成2023或2025使用了YYYY而不是yyyy统一改成yyyyYYYY是week-based-yearLocalDateTime序列化成JSON变成数组缺少JavaTimeModule注册JavaTimeModule并配置格式转换后时间差8小时时区没有显式设置确认ZoneId服务器启动参数设置-Duser.timezoneLocalDate.parse(2024/06/15)抛异常默认是yyyy-MM-dd格式显式传入formatter或者先用replace()统一分隔符7.3 一个容易忽略的细节星期几的计算LocalDate.getDayOfWeek()返回的是一个DayOfWeek枚举从MONDAY到SUNDAY。国内业务经常要判断今天是周几可以用DayOfWeek dayOfWeek LocalDate.now().getDayOfWeek(); // 是否周末 boolean isWeekend dayOfWeek DayOfWeek.SATURDAY || dayOfWeek DayOfWeek.SUNDAY;但注意在计算“两个日期相差几个工作日”这种需求时DayOfWeek只是起点你还得自己排节假日。java.time没有内置节假日计算功能这类需求通常要引入公共节假日表或者第三方日历库。我在做排班系统时就是自己维护了一张节假日表结合TemporalAdjusters实现工作日计算。还有一个小细节LocalDate转java.util.Calendar时Calendar的月份还是从0开始的。如果你在项目里需要和Calendar交互每次拿到Calendar.MONTH都要记得1转成真实月份拿到Date再转LocalDate时反而不用操心这个问题。8. 一个完整实战示例把这些API串起来纸上谈兵多了容易飘我拿一个实际业务场景来把前面讲的内容串一遍做一个简单的“会员有效期管理”功能需要处理开通时间、到期时间、剩余天数、续费后的新到期时间。需求是用户开通会员记录开通时间。会员有效期按自然日计算1个月、3个月、6个月三档。需要展示“截至XX还有XX天”。续费时在旧到期时间基础上叠加时长。第一步定义开通时间和到期时间// 开通时间当前系统时间 LocalDateTime openedAt LocalDateTime.now(); // 到期时间当月最后一天23:59:59常见的按自然月计算方式 LocalDateTime expireAt openedAt .toLocalDate() .plusMonths(1) .with(TemporalAdjusters.lastDayOfMonth()) .atTime(LocalTime.MAX);这里用plusMonths(1)和lastDayOfMonth()组合实现了“开通后第1个月的最后一天到期”这个业务规则。第二步计算剩余天数用于展示“还剩XX天”long remainingDays ChronoUnit.DAYS.between(LocalDate.now(), expireAt.toLocalDate()); if (remainingDays 0) { remainingDays 0; }第三步判断会员是否在有效期内boolean isActive LocalDateTime.now().isBefore(expireAt);第四步续费在旧到期时间上叠加一个新的月数并且如果用户已经过期从当前时间重新计算LocalDateTime renewAt LocalDateTime.now(); if (isActive) { // 未过期从旧到期时间开始续 LocalDateTime base expireAt; LocalDateTime newExpireAt base .toLocalDate() .plusMonths(3) .with(TemporalAdjusters.lastDayOfMonth()) .atTime(LocalTime.MAX); } else { // 已过期从当前时间重新算 LocalDateTime newExpireAt renewAt .toLocalDate() .plusMonths(3) .with(TemporalAdjusters.lastDayOfMonth()) .atTime(LocalTime.MAX); }第五步数据库存取。如果用MyBatis实体类里用LocalDateTime数据库字段用datetimeTypeHandler自动处理// 实体字段 private LocalDateTime openedAt; private LocalDateTime expireAt;第六步接口出参格式化public static final DateTimeFormatter FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); // 返回给前端 String expireText expireAt.format(FORMATTER); // 2024-07-31 23:59:59这个例子用了LocalDateTime.now()、plusMonths()、TemporalAdjusters.lastDayOfMonth()、atTime()、ChronoUnit.DAYS.between()、isBefore()、DateTimeFormatter这些API正好覆盖了日常开发里80%的日期时间操作。理解了这套组合拳的用法遇到类似需求基本都能直接套。我个人在实际项目里的体会是java.time这套API最大的价值不是某个具体方法而是它把“不可变、线程安全、语义清晰”这三个设计原则贯彻到了每一个类的每一个方法里。你只要掌握了LocalDate的用法LocalTime、LocalDateTime、ZonedDateTime的用法基本就是依葫芦画瓢学习成本很低。最后再分享一个实用小技巧如果你在排查线上数据异常发现某个时间比预期早了或者晚了8个小时优先检查时区相关逻辑ZoneId.systemDefault()的结果是什么、数据库连接串是否指定了serverTimezone、容器环境变量是不是动了TZ。时间问题一旦出错往往不是代码写错而是环境的时区配置和代码的时区假设不一致。在应用启动时把默认时区显式设置固定下来再在日志里带上时区信息能省下不少排查时间。
返回列表