Java时间处理全攻略:从java.time核心API到Spring Boot序列化实战 说实话每次面试问候选人Java 里日期时间怎么处理十个人里有八个还在背SimpleDateFormat的线程安全问题剩下的两个能说出来LocalDateTime但一深入就露馅。Java 8 出来的java.time时间 API 其实这些年已经覆盖了绝大多数业务场景从基础的时间获取、格式化到跨时区换算、区间计算再到配合 Spring Boot 和 MyBatis 做序列化落地一套东西吃透基本能应对后端开发里九成以上的时间需求。这篇就按我自己的实战经验把java.time这套 API 的核心用法、踩坑细节和面试常考的点全部捋一遍。我会先讲清楚为什么 Java 要推倒重来做一套时间 API理解它的设计初衷用起来才不会错然后逐个拆解LocalDate、LocalDateTime、Instant、ZonedDateTime这些核心类的本质区别和使用场景接着覆盖格式化、日期运算、时区处理、区间计算这些日常业务刚需最后把新旧 API 互转、数据库交互、序列化配置这些真实项目里绕不开的坑都填平。如果你正在准备 Java 面试这篇文章基本可以当一份时间 API 八股文速查手册来用如果你是实际开发中遇到时间处理问题前面几章已经帮你节省了至少一晚上的搜索时间。下面进入正题。1. 为什么 Java 要推倒重来做一套时间 API1.1 旧时间 API 的四大历史包袱Java 8 之前时间处理主要靠java.util.Date、java.util.Calendar和java.text.SimpleDateFormat这套组合拳在当年确实解决了基本需求但放到现代工程里处处是坑。最大的问题就是可变性——Date对象本身就支持setTime()Calendar更是提供了各种set方法一旦对象被多个线程共享改来改去很难保证一致性。这不是理论上的担忧真实项目里为了格式化一个日期没加锁或者没在线程内创建新实例导致线上出现错乱时间的案例我见过不止一次。第二个问题是 API 设计反人类。月份从 0 开始是 Java 开发者心里永远的痛——Calendar.JANUARY是 012 月份反而是 11新手写日期初始化十有八九会差一个月。年份从 1900 开始偏移这个设定更离谱new Date(121, 0, 1)代表的是 2021 年 1 月 1 日这种隐含的历史设计完全没有可读性和安全性。第三个问题是SimpleDateFormat的线程安全问题。JDK 文档里明确写了它不是线程安全的format()和parse()内部会共享Calendar实例来操作字段并发场景下轻则格式化结果错乱重则直接抛NumberFormatException或者ArrayIndexOutOfBoundsException。很多老项目用ThreadLocal包一层来规避线程之间隔离后确实能解决但这本身就是一种为了弥补设计缺陷而做的妥协。第四个问题是时区处理混乱。Date本质上是一个时间戳毫秒数它不携带任何时区信息但是toString()却会输出 JVM 默认时区的本地时间导致开发者经常产生这个是带时区的时间的错觉。Calendar虽然支持时区设置但它内部那套复杂的状态管理机制配合DOW_IN_MONTH、WEEK_OF_MONTH这类抽象字段让时间计算变得极其晦涩难懂。1.2 java.time 包的设计哲学Java 8 引入的java.time是基于 JSR-310 规范实现的这套规范的核心设计者 Stephen Colebourne 正是 Joda-Time 的作者所以如果你以前用过 Joda-Time上手java.time会非常亲切。这套新 API 的设计哲学可以总结成三条。第一不可变性。LocalDate、LocalTime、LocalDateTime、Instant、ZonedDateTime这些类全部被设计成不可变对象所有运算方法比如plusDays、withMonth都会返回一个新对象原对象保持不变。这个特性天然线程安全随便共享实例完全不用加锁从根上解决了SimpleDateFormat那类问题。第二方法命名规范统一。of系列负责创建对象parse系列负责从字符串解析get系列负责获取字段plus/minus系列负责加减运算with系列负责替换字段isBefore/isAfter系列负责比较。这套命名规则一旦记住你不需要查文档也能猜出大部分方法的作用。第三严格区分面向人类的时间和面向机器的时间。LocalDate、LocalDateTime这类是给人看的描述的是某个时区下的日历时间Instant是给机器用的描述的是时间轴上绝对的一个点从 1970-01-01T00:00:00Z 开始的纳秒数。把这两种时间概念分开很多模糊不清的 bug 在编译期就能被察觉出来。2. 新时间 API 的核心类型全景拆解2.1 LocalDate、LocalTime、LocalDateTime不带时区的本地时间LocalDate只表示日期年、月、日LocalTime只表示时间时、分、秒、纳秒LocalDateTime是两者的组合表示日期和时间。要注意的是Local 这个词并不代表它是某个特定地区的时间正确的理解是没有时区信息的本地时间换句话说它就是一个字面意义上的日历时间。举个例子LocalDateTime.of(2024, 6, 1, 12, 0)表达的是2024 年 6 月 1 日中午 12 点这个日历概念它不关心这一刻是北京时间的 12 点还是东京时间的 12 点。这种类型最适合用在不需要跨时区的业务场景里比如系统内部记录的创建时间、会议开始时间、活动截止时间等。如果你在做国际化系统需要处理不同地区用户看到的时间那LocalDateTime单独使用就很危险必须配合时区信息也就是下面的ZonedDateTime来使用。在处理这类时间对象时我特别提醒一点不要在内存中长期持有今天的值然后反复使用因为一旦跨天这个值就过时了。很多定时任务 bug 就出在这里——启动时取了一次LocalDate.now()之后一直用这个值判断今天结果零点过后日志里全是错误。2.2 Instant时间轴上的绝对坐标Instant是一个极其纯粹的概念从1970-01-01T00:00:00Z开始计算精确到纳秒。它不包含任何时区和日历信息它就是一个绝对时间点。你可以把它理解为时间轴上的一个坐标类似地球上经纬度坐标跟国家城市的关系——经纬度是客观的至于它落在哪个国家的领土上是另一层逻辑。开发中Instant最常见的来源有两个Instant.now()用来获取当前时刻Date.toInstant()用来把老 API 的Date对象转换成新 API 的类型。系统之间做接口对接、记录日志时间戳、生成全局唯一 ID 时参考时间点这类场景用Instant最合适因为它不带有任何本地化的干扰。特别说一下Instant的精度是纳秒级别但实际打印的时候如果纳秒部分是 0toString()会只显示秒如果纳秒部分不是 0则会输出 9 位数字。这就导致了一个常见的困惑为什么两个Instant.toString()一个输出2024-06-01T12:00:00Z另一个输出2024-06-01T12:00:00.000000123Z这就是精度差异的表现。写日志或者存数据库时要注意统一精度避免下游解析器处理不了纳秒导致报错。2.3 ZonedDateTime、OffsetDateTime带时区的一揽子方案ZonedDateTime在LocalDateTime的基础上加入了一个ZoneId表示某个时区下的某个日历时间比如2024-06-01T12:0008:00[Asia/Shanghai]。它适合做跨时区业务的展示和计算比如国际会议系统里同一个会议时间需要分别在纽约、伦敦、东京的参会者视角下展示。OffsetDateTime与ZonedDateTime类似但它只保留与 UTC 的偏移量比如08:00不保留具体的时区规则。区别在哪里偏移量只是数学计算而时区规则包含了夏令时等历史政策变更。比如Europe/Berlin这个时区夏季偏移是02:00冬季是01:00如果你用OffsetDateTime存了一个夏季的02:00到冬季你想知道它对应的真实时刻光靠偏移量是不可能自动转换的。所以如果业务需要处理夏令时和时区切换优先使用ZonedDateTime。数据库存储方面我个人的建议是如果需要存带时区的绝对时间点优先考虑Instant或OffsetDateTime这样语义清晰不会因为数据库会话时区不同而出问题。3. 日期创建与格式化实操3.1 从今天、指定值、字符串三种方式创建时间对象最常用的创建方式就是拿当前时间LocalDate.now()、LocalTime.now()、LocalDateTime.now()JVM 会读取系统默认时区来返回当前值。这里有一个隐藏细节now()是有重载方法的LocalDate.now(ZoneId.of(Asia/Shanghai))可以指定时区获取当前日期。如果你的服务部署在多个地域但业务上需要以某个固定时区的日期为准比如用户所在国家的当地时间就一定要显式传ZoneId不传的话结果会随着服务器所在时区漂移。指定值创建用of系列这个非常直觉。LocalDate.of(2024, Month.JUNE, 1)、LocalDateTime.of(2024, 6, 1, 10, 30, 0)都属于这一类。注意月份参数既可以用数字1 到 12跟Calendar的 0 基月份彻底告别了也可以用Month枚举推荐后者的可读性更好。字符串解析是实际开发里踩坑最多的环节。LocalDate.parse(2024-06-01)能直接解析 ISO 格式但如果你传入2024/06/01或者2024-6-1它就会抛DateTimeParseException。这时候需要显式指定格式化器LocalDate.parse(2024/06/01, DateTimeFormatter.ofPattern(yyyy/MM/dd))。同理LocalDateTime.parse(2024-06-01T10:30:00)能解析带T的 ISO 格式但2024-06-01 10:30:00这种最常见的人类可读格式必须配合DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)来解析。3.2 DateTimeFormatter 的线程安全与模式对照实战DateTimeFormatter是SimpleDateFormat的替代者它是线程安全的可以定义成静态常量全局复用。这是新时间 API 在并发场景下最爽的改进之一。我一般在项目里会建一个常量类统一放格式化器public final class DatePatterns { public static final DateTimeFormatter YYYY_MM_DD DateTimeFormatter.ofPattern(yyyy-MM-dd); public static final DateTimeFormatter YYYY_MM_DD_HH_MM_SS DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); public static final DateTimeFormatter ISO_OFFSET_DATE_TIME DateTimeFormatter.ofPattern(yyyy-MM-ddTHH:mm:ssXXX); private DatePatterns() {} }这样全局复用不需要像老代码那样到处new SimpleDateFormat。不过格式化模式里的字母含义很多人只记住了yyyy和MM到uuuu和YYYY就傻眼了。这里有几个高频坑yyyy是年year-of-erauuuu是年proleptic-year。公元后年份它俩输出一样但涉及公元前年份时yyyy会显示成0000或负数而uuuu更符合计算语义。官方推荐格式化年份用uuuu但习惯上大家还是写yyyy现代项目问题不大面试时能说清区别就是加分项。YYYY大写是周基准年它跟ww周数配合使用的。如果你用YYYY-MM-dd这种格式在跨年那几天会出现诡异的结果——比如 2024 年 12 月 30 日可能被格式化成2025-12-30因为这一周按 ISO 规则已经属于 2025 年的第 1 周了。这是每年元旦前后必现的线上事故强烈建议永远不要用YYYY-MM-dd这种组合。HH是 0-23 小时制hh是 1-12 小时制。如果用了hh但没带a上午/下午标记那下午 1 点会被格式化输出成01解析反过来的话12:30用hh:mm解析会报错。这都是非常容易忽略的细节。下面这张表是日常开发最常用的模式对照可以存一下用途模式示例输出日期yyyy-MM-dd2024-06-01日期时间yyyy-MM-dd HH:mm:ss2024-06-01 10:30:00带毫秒yyyy-MM-dd HH:mm:ss.SSS2024-06-01 10:30:00.123ISO 日期时间yyyy-MM-ddTHH:mm:ss2024-06-01T10:30:00带时区偏移yyyy-MM-ddTHH:mm:ssXXX2024-06-01T10:30:0008:00中文场景yyyy年MM月dd日2024年06月01日3.3 异常处理与容错设计DateTimeParseException是解析时间字符串最常见的一场通常原因是输入格式和模式不匹配。我的建议是封装一个解析工具方法统一处理格式兼容先试 ISO 格式再试自定义格式解析失败时抛业务异常而不是让底层异常直接冒出去这样接口层能给出友好的错误信息。public static LocalDateTime parseFlexible(String text) { if (text null || text.trim().isEmpty()) { throw new IllegalArgumentException(时间字符串不能为空); } String trimmed text.trim(); try { return LocalDateTime.parse(trimmed); } catch (DateTimeParseException ignored) { } try { return LocalDateTime.parse(trimmed, DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)); } catch (DateTimeParseException ignored) { } throw new IllegalArgumentException(无法解析时间字符串: text); }这类兜底逻辑在对接第三方接口时特别有用因为上游返回的时间格式可能看心情我们不能因为一个字段格式不对就把整条数据处理流程全部打断。4. 时间运算与区间计算业务场景核心实战4.1 日期加减plus 与 minus 的底层逻辑LocalDate、LocalDateTime、Instant等类都提供了plusDays、plusWeeks、plusMonths、plusYears、minusDays等一系列方法用法非常直接。比如计算30 天后LocalDate deadline LocalDate.now().plusDays(30);计算下个月的第一天LocalDate firstDayOfNextMonth LocalDate.now() .plusMonths(1) .withDayOfMonth(1);plusMonths和minusMonths在处理月末日期时有一个特别细节的规则比如1 月 31 日加一个月结果不是2 月 31 日这个日期不存在而是会返回2 月 29 日或者 28 日取决于闰年。java.time的处理方式是月末收缩——把结果自动对齐到目标月份的最后一天。这个行为跟很多人的直觉不一样但它保证了你不会拿到一个非法的日期。如果你希望1 月 31 日加一个月到 2 月最后一天再继续加一个月到 3 月最后一天需要自己写规则因为 API 自带的语义是从 1 月 31 日加一个月 2 月 28/29 日从 2 月 28 日加一个月 3 月 28 日一旦收缩过后续的月份日就不会回到 31 了。with系列方法用于替换字段比如withYear(2025)、withMonth(6)、withDayOfMonth(1)。它跟plus的区别在于plus是偏移with是设定。比如设置成本月最后一天用with(TemporalAdjusters.lastDayOfMonth())这个TemporalAdjusters工具类提供了大量现成的调整器firstDayOfMonth()、firstDayOfNextMonth()、next(DayOfWeek.MONDAY)、lastDayOfMonth()等等算报表周期时几乎必备。4.2 两个时间点相差多少Duration、Period、ChronoUnit 选型计算两个时间点相差多久是另一个高频场景。这里要根据两个时间类型来选择工具Duration用于两个Instant、LocalTime、LocalDateTime之间的差值精度到纳秒输出格式类似PT24H24 小时。Period用于两个LocalDate之间的差值按年、月、日计输出格式类似P2Y3M4D2 年 3 个月 4 天。ChronoUnit是一个更通用的枚举可以直接计算两个时间点在指定单位上的差值。具体到业务代码比如计算活动剩余秒数long secondsRemaining Duration.between(LocalDateTime.now(), activityEndTime).getSeconds();计算两个订单创建时间相差了多少天long daysBetween ChronoUnit.DAYS.between(order1.getCreateTime(), order2.getCreateTime());计算年龄按年long age ChronoUnit.YEARS.between(birthday, LocalDate.now());这里有一个非常容易踩的坑Duration.between的语义是第二个参数减去第一个参数所以如果写成Duration.between(now, endTime)而endTime在now之前结果会是负数。判断方向之前一定要想清楚或者用Math.abs兜底。ChronoUnit还提供了between之外的功能比如ChronoUnit.DAYS.addTo(date)、ChronoUnit.MONTHS.between(...)它在处理整月份跨度时的规则跟Period略有差异。面试里经常问Period 和 Duration 有什么区别、ChronoUnit.between 和 Duration.between 有什么区别答案的核心就是单位粒度不同Duration面向秒/纳秒的精确时段Period面向年/月/日的人为日历差异ChronoUnit是通用桥梁。4.3 日期范围循环与统计场景业务里经常有遍历每天的统计数据、生成一段时间内的所有日期列表这类需求用TemporalAdjusters和while循环可以轻松实现LocalDate start LocalDate.of(2024, 6, 1); LocalDate end start.plusDays(30); ListLocalDate dateList new ArrayList(); for (LocalDate d start; !d.isAfter(end); d d.plusDays(1)) { dateList.add(d); }这个循环写法的关键在于!d.isAfter(end)而不是d.isBefore(end)否则会漏掉最后一天。类似的判断某个日期是否在某个区间内用!d.isBefore(start) !d.isAfter(end)或者更优雅地使用已有的start.compareTo(d) 0 end.compareTo(d) 0。周维度统计也常用TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)来定位本周一然后用plusDays(6)拿到本周日。这样生成周一到周日的报表区间非常稳定不会因为语言环境的星期起始日不同而出错。5. 时区处理从本地时间到绝对时刻的正确姿势5.1 ZoneId 与 ZoneOffset时区不是简单的偏移量ZoneId表述的是完整的时区规则比如Asia/Shanghai、America/New_York它包含了该地区所有历史偏移变化和夏令时切换规则。ZoneOffset则只是一个固定的 UTC 偏移量比如08:00、-05:00不包含任何历史规则。在代码里获取指定时区的当前时间ZonedDateTime.now(ZoneId.of(Asia/Shanghai));获取某个Instant在指定时区下的本地时间表示Instant timestamp Instant.now(); ZonedDateTime shanghaiTime timestamp.atZone(ZoneId.of(Asia/Shanghai)); LocalDateTime shanghaiLocal shanghaiTime.toLocalDateTime();反方向操作把一个本地时间转换成某个时区的绝对时刻LocalDateTime localTime LocalDateTime.of(2024, 6, 1, 10, 30); ZonedDateTime zoned localTime.atZone(ZoneId.of(Asia/Shanghai)); Instant instant zoned.toInstant();这个atZone是最容易写错的一环。很多人把LocalDateTime直接当成带时区的时间来用没有意识到它只是一个日历字面值。比如你小程序上存了一个2024-06-01 15:00:00表示用户填写的预约时间这个值到底应该解释成用户的本地时间、服务器时间、还是 UTC 时间完全取决于业务约定。如果约定是用户本地时间那就需要知道用户所在的ZoneId然后atZone才能转成真正的绝对时刻。5.2 跨时区换算与夏令时陷阱跨时区换算最常见的需求就是把一个时区的某一时刻转换成另一个时区的本地时间展示。标准做法是三步走先按原时区转成ZonedDateTime取toInstant()得到绝对点再atZone到目标时区。ZonedDateTime newYorkTime ZonedDateTime.of(2024, 6, 1, 10, 0, 0, 0, ZoneId.of(America/New_York)); ZonedDateTime shanghaiTime newYorkTime.withZoneSameInstant(ZoneId.of(Asia/Shanghai)); System.out.println(shanghaiTime); // 2024-06-01T22:0008:00[Asia/Shanghai]夏令时是这个环节里最容易让人崩溃的部分。比如America/New_York在春季进入夏令时的时候2024-03-10 02:30这个本地时间实际上是不存在的因为时钟在凌晨 2 点直接跳到了 3 点。如果你用ZonedDateTime.of去构造一个不存在的本地时间API 不会报错而是会自动做偏移调整得到2024-03-10T03:30-04:00。反过来秋季回拨的时候2024-11-03 01:30会重复出现两次ZonedDateTime会偏向使用第一个偏移。这类问题在真实业务中往往表现为用户在某天看到一个会议的本地时间变成奇怪值或者定时任务在夏令时切换那天多发/少发一次。解决方案有几个层级能避开就避开跨时区业务尽量统一用 UTC 存储和传输必须展示本地时间的话用ZonedDateTime而不是手动拼偏移数据库里存Instant或timestamp with time zone类型。5.3 统一用 UTC 存储和传输的工程实践我在设计接口文档和数据库表结构的时候强制规定所有传输给外部系统的时间字段用Instant或OffsetDateTime的 ISO-8601 格式比如2024-06-01T10:30:00Z所有数据库里的时间字段统一存 UTC如果是datetime类型则约好存 UTC如果是timestamp with time zone类型则无需担心只在用户界面层Controller 返回给前端、模板渲染进行时区转换转成用户本地时区。这套约定对比到处都用当前系统时区的最大优势是可复现、可排查。线上出问题的时候你看到一个日志时间戳是2024-06-01T10:30:00Z你不需要知道当时服务器在哪个时区就能准确判断发生了什么。而如果一个日志打印的是2024-06-01 18:30:00你反而要猜这是北京时间的 18:30 还是 UTC 的 18:30排查成本直接翻倍。Java 代码层面从LocalDateTime转 UTC 的InstantInstant utcTime localDateTime.atZone(ZoneId.systemDefault()).toInstant();从Instant转前端需要的本地时间字符串String displayTime instant.atZone(ZoneId.of(Asia/Shanghai)) .format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss));6. 项目迁移与序列化落地避坑指南6.1 老项目 Date、Calendar 与新 API 的互转很多存量系统的数据库字段还是java.util.Date或者第三方 SDK 的接口还在返回Date。如果不做一次性全面替换就需要掌握新旧 API 的互相转换。核心桥梁是Instant// Date 转新 API Date oldDate new Date(); Instant instant oldDate.toInstant(); LocalDateTime localDateTime instant.atZone(ZoneId.systemDefault()).toLocalDateTime(); LocalDate localDate instant.atZone(ZoneId.systemDefault()).toLocalDate(); // 新 API 转 Date LocalDateTime ldt LocalDateTime.now(); Instant inst ldt.atZone(ZoneId.systemDefault()).toInstant(); Date newDate Date.from(inst);这里最偷懒的做法是写一个DateUtils工具类把Date - LocalDateTime、LocalDateTime - Date、LocalDate - Date这几组方法封装好然后全局引用。注意LocalDate转Date时由于LocalDate没有时间信息默认要选一个起始时间点比如当天零点Date date Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());如果不加atStartOfDay会直接编译报错这也是一个常见的指引性问题。6.2 Spring Boot Jackson 的 LocalDateTime 序列化与反序列化Spring Boot 项目里LocalDateTime作为接口返回值和请求入参时如果没有额外配置Jackson 默认输出的是数组形式比如[2024, 6, 1, 10, 30, 0]这对前端极其不友好。需要引入jackson-datatype-jsr310模块并在ObjectMapper上注册JavaTimeModule。Spring Boot 的spring-boot-starter-web已经默认包含了这个依赖但默认格式还需要手动配置。我一般在application.yml里统一配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 serialization: write-dates-as-timestamps: false如果需要对不同字段使用不同格式比如某个字段需要带毫秒另一个不需要就用JsonFormat注解JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime createTime;这里有个容易忽略的点JsonFormat注解对LocalDateTime生效但设置timezone只对Date类型有效对LocalDateTime其实是没用的——因为LocalDateTime本身不携带时区信息序列化只是按字符串模式输出。如果想让接口返回带时区的字符串干脆把字段类型定义成OffsetDateTime或Instant再配合JsonFormat输出语义上更明确。6.3 MyBatis 与 JDBC 对时间类型的适配MyBatis 3.4.5 以上版本开始支持 JSR-310 时间类型的基本读写JDBC 4.2 规范也定义了setObject/getObject对LocalDate、LocalTime、LocalDateTime、Instant等的支持。所以实际操作中只要数据库表字段类型匹配一般不需要额外 TypeHandler。一个常见的坑是数据库字段是timestamp带时区但 Java 实体用LocalDateTime驱动在读取时会把数据库的timestamp按 JVM 默认时区转成一个本地时间。这样如果你明确知道自己存的是 UTC但 JVM 默认时区是Asia/Shanghai查询出来就会有 8 小时的偏差。解决办法是保持存储类型与 Java 类型语义一致——绝对时刻timestamp with time zone用Instant或OffsetDateTime对应日历时间datetime用LocalDateTime对应。如果已经踩了坑可以通过 JDBC 连接串参数强制指定会话时区但这是治标不治本。MyBatis 的 XML 里如果写了动态 SQL 需要比较时间也注意参数类型。很多时候错误在编译期根本看不出来LocalDateTime传给 JDBC 后变成varchar比较导致索引失效。建议在 Mapper 里用#{createTime,jdbcTypeTIMESTAMP}显式指定 JDBC 类型能有效规避这类问题。7. 面试高频考点与新手必看避坑清单7.1 老生常谈的线程安全SimpleDateFormat vs DateTimeFormatter面试官最喜欢问的一个问题链就是SimpleDateFormat为什么线程不安全怎么解决DateTimeFormatter有什么不同回答这个问题的关键是要说到本质——SimpleDateFormat内部持有一个Calendar实例format和parse操作会修改这个共享的Calendar状态并发时互相踩踏而DateTimeFormatter是无状态的、不可变的操作的是TemporalAccessor每次调用都生成新的解析上下文所以线程安全。解决方案的演进路径也可以提一下早期用synchronized加锁性能差、ThreadLocal隔离每线程一个实例、每次 new性能浪费到后来换成DateTimeFormatter静态常量线程安全且高效。这条路径能展示你对并发问题的理解深度。7.2 三个最容易被问倒的冷门考点第一个是LocalDate.now()和Instant.now()的关系。很多人以为Instant.now()比LocalDate.now()更准其实它们都调用系统时钟精度可能一样。真正的区别是时间基线的选择Instant是绝对时刻LocalDate依赖时区才能定义今天。如果你在伦敦和北京各部署一台服务器同一个时刻调用LocalDate.now()两人得到的结果可能不同在跨日边界的时候但Instant.now()一定相同。第二个是LocalDateTime到底能不能表示带时区的时间。不能它只是一个日历字面值。你看到2024-06-01T10:30这个字符串如果不知道时区它无法对应到时间轴上的一个确定点。ZonedDateTime和OffsetDateTime才是带时区信息的类型。第三个是yyyy与uuuu的微妙差异。绝大多数业务代码用yyyy完全没问题但写 JDK 内部工具、天文历法计算或者用 ISO-8601 标准时uuuu更合适。面试能主动说出这个区别说明你确实读过官方文档不是只会背示例代码。7.3 实战踩坑记录与排查思路我整理了几个真实项目里遇到的高频问题方便你快速对照排查。问题现象可能原因排查方向接口返回的LocalDateTime是数组没有注册JavaTimeModule或未禁用 timestamp 输出检查 Jackson 配置确认write-dates-as-timestamps为 false跨年日期格式化错误模式用了大写YYYY而非yyyy全局搜索YYYY替换成yyyy数据库时间差了 8 小时时区不一致 /LocalDateTime与timestamp with time zone语义不匹配确认数据库会话时区、JDBC URL 参数、实体字段类型解析时间字符串抛DateTimeParseException输入格式与模式不匹配打印输入值比对模式加上兜底解析函数月末加一个月后日期不对plusMonths的月末收缩规则确认业务预期必要时自定义对齐逻辑Duration.between得到负数参数顺序反了检查是结束减开始还是开始减结束Period.between和ChronoUnit.DAYS.between结果不一致Period按日历月计算ChronoUnit.DAYS按总天数计算明确需求是日历月差异还是精确天数差异这些坑我几乎都亲手踩过。尤其是跨年YYYY那个我印象最深——有一次报表模块在 12 月 31 号跑批某几行数据的业务日期凭空多了整整一年排查了一个多小时最后发现是格式化模式匹配错了。从那之后我给自己立了个规矩格式化字符串里一律不用大写YYYY除非是在处理 ISO 周日期。这个习惯值得你直接抄过去。7.4 几个可以直接抄的常用工具方法最后分享几个我沉淀下来的时间工具方法基本都是每换一个项目就会复制过去的。没有特别的魔法就是踩坑之后总结出的稳定模式。/** * 获取当前时间字符串默认格式 yyyy-MM-dd HH:mm:ss */ public static String nowText() { return LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)); } /** * 获取指定时间所在周的周一周一作为一周开始 */ public static LocalDate getMonday(LocalDate date) { return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); } /** * 计算两个日期相差多少天忽略时分秒 */ public static long daysBetween(LocalDate start, LocalDate end) { return ChronoUnit.DAYS.between(start, end); } /** * 时间戳毫秒转 LocalDateTime按系统默认时区 */ public static LocalDateTime fromEpochMilli(long epochMilli) { return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMilli), ZoneId.systemDefault()); } /** * LocalDateTime 转时间戳毫秒按系统默认时区 */ public static long toEpochMilli(LocalDateTime dateTime) { return dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); }注意getMonday里用了previousOrSame如果传入的日期本身就是周一它直接返回这个日期如果传入的是周日它会回退六天到上周一。这和with(DayOfWeek.MONDAY)的行为略有区别后者是对齐到当前周的周一在周起始日是周日的地区两者结果可能不同用的时候要想清楚。这套 API 还有很多更高级的玩法比如YearMonth做月份维度统计、MonthDay处理生日、TemporalQuery自定义查询、DateTimeFormatterBuilder构建超复杂格式日常项目里用得相对少一些但面试如果聊到你熟悉 java.time 的哪些特性能多提几个冷门类会让面试官觉得你的知识面够广。不过说到底Java 时间 API 的核心价值不是让你背 API 列表而是让你在时间处理上写出不容易出错的代码。把上面这些主力类和注意事项吃透你的日常开发效率和线上问题的发生概率都会有非常明显的改善。