
写 Java 这么多年时间日期处理一直是我最怕遇到又绕不开的坑位。JDK8 全新时间 API也就是java.time包推出之后我才真正感觉时间计算这件事有了标准答案。不管你是刚学 Java 的初学者还是被java.util.Date折磨过的老手这篇文章我都会用实际代码和踩坑经验把这套 API 从类设计到手写工具类一次讲透看完可以直接搬进项目用。1. 旧 API 的三大痛点与 java.time 的设计哲学聊新 API 之前得先搞清楚 JDK8 为什么这么大动干戈地重新造一套时间类。毕竟java.util.Date用了这么多年大家都说它难用但你要问具体难在哪不少人其实说不清楚。我总结了三个最核心的痛点理解了这些后面新 API 的设计思路就会非常自然而然。1.1Date的语义混乱一个类什么都装早期的java.util.Date设计时犯了一个根本性错误它既表示日期又表示时间还想表示时间线上的瞬间结果哪一个都没做好。你拿到一个Date对象时它内部存的实际是从 1970-01-01 00:00:00 UTC 到某个时刻的毫秒数但打印出来又是本地时区的日期时间字符串。这就导致一个很尴尬的情况同样是new Date()不同时区的服务器上打印结果不一样但getTime()返回的毫秒数又是一样的。更糟的是Java 里没有单独的日期类来表示今天这种概念。你要想表达今天是 2024 年 12 月 25 日用Date存下来实际上存的是一个时刻后续取用的时候如果不小心跨了时区就会出现日期结果偏差一整天。想想银行账单、生日提醒、报表统计这种场景一个Date对象压根说不清楚它到底代表什么语义。到了 JDK8 的时间 API 里设计者直接把概念拆开了LocalDate只负责年月日LocalTime只负责时分秒纳秒LocalDateTime是两者的组合但依然不带时区Instant才是真正表示时间线上的绝对时刻。这种语义分离让代码阅读起来一目了然变量类型本身就回答了这里存的是什么这个灵魂问题。1.2 反人类的 0 基月份与 1900 年偏移如果你用Date写过一点代码一定见过这种场景// 创建一个 2024 年 12 月 25 日 Date date new Date(124, 11, 25);第一眼看过去124 是什么11 又是什么没错Date的年份参数从 1900 年开始计算所以 2024 年要写成 124月份是从 0 开始计数12 月竟然是 11。这种历史包袱导致无数off-by-one的 bug——你测试时写一个 12 月的需求单元测试碰巧过了结果一上线直接变成 1 月。Calendar类虽然把月份从 0 开始的问题保留了下来但 API 设计同样繁琐。要获取当前月份你得靠Calendar.getInstance().get(Calendar.MONTH) 1这种写法想加一天要用calendar.add(Calendar.DAY_OF_MONTH, 1)日常开发里百分之八十的时间都在跟这些魔法数字搏斗。JDK8 时间 API 终于把这些全部改成了符合直觉的设计月份一到十二就是1到12年份直接用真实年份星期用DayOfWeek枚举从周一到周日时间字段通过getYear()、getMonthValue()、getDayOfMonth()方法直接获取这些方法签名就是文档不需要再翻参考手册。1.3SimpleDateFormat线程不安全高并发下的定时炸弹如果说前面的坑只是难用SimpleDateFormat的线程不安全问题就是真正的生产事故引信。SimpleDateFormat内部维护了一个Calendar实例用于日期计算format()和parse()过程中会修改Calendar的状态。多个线程共享同一个实例时解析一半的Calendar字段被另一个线程改写轻则格式化结果错乱重则直接抛出NumberFormatException或者解析出完全错误的日期。业界通行做法是每次使用都new SimpleDateFormat()但这样在高并发下频繁创建对象性能开销不小而且一不小心忘了还会养成坏习惯。当然也可以用ThreadLocal包装不过代码写起来就啰嗦了尤其在 JDK 低版本时代这几乎是人手一份的工具代码。JDK8 的DateTimeFormatter是不可变且线程安全的类一个实例可以在任意多个线程间共享。设计上它也内置了大量预定义格式ISO_LOCAL_DATE、ISO_LOCAL_DATE_TIME、ISO_INSTANT等绝大多数业务根本不需要自己写 pattern直接用现成的格式器就行。这一点在实际生产环境里省了太多心。理解了这三大痛点你就明白 JDK8 时间 API 的设计哲学了不可变对象保证线程安全语义清晰的类划分保证可读性一致的方法命名of、now、plus、minus、with、to保证可预测性。这六个字记在心里用的时候基本不会迷路。2. 核心类族六个主力类和一个辅助类怎么选java.time包里类不少但实际工作中高频使用的其实就那么几个。我习惯把它们分成时刻类本地类区间类和辅助类四组来记忆选型逻辑特别清晰。类语义典型场景示例值Instant时间线上的绝对时刻UTC 视角日志时间戳、跨系统时间传递2024-12-25T06:30:00ZLocalDate本地日期无时区、无时间生日、账单日、报表日期2024-12-25LocalTime本地时间无时区、无日期上下班打卡时间、营业时间14:30:00LocalDateTime本地日期时间无时区业务单据的时间不跨时区场景2024-12-25T14:30:00ZonedDateTime带完整时区规则的日期时间跨时区会议、航班到达时间2024-12-25T14:30:0008:00[Asia/Shanghai]OffsetDateTime带固定 UTC 偏移量的日期时间API 接口传输时间、数据库 TIMESTAMP2024-12-25T14:30:0008:00Duration秒/纳秒级的时长接口耗时统计PT2H30MPeriod年/月/日级的周期计算年龄、计算合同剩余天数P1Y2M3D2.1 本地类三兄弟LocalDate、LocalTime、LocalDateTime这三个类是我日常开发中使用频率最高的。拿到需求第一件事就是问自己这个时间要不要带时区如果不带那用哪一层的本地类就够了。创建方式非常简单// 当前时间 LocalDate date LocalDate.now(); LocalTime time LocalTime.now(); LocalDateTime dateTime LocalDateTime.now(); // 指定时间 LocalDate christmas LocalDate.of(2024, 12, 25); LocalTime lunchTime LocalTime.of(12, 30, 0); LocalDateTime thisMoment LocalDateTime.of(2024, 12, 25, 14, 30, 0); // 从字符串解析ISO 格式 LocalDate parsedDate LocalDate.parse(2024-12-25); LocalDateTime parsedDateTime LocalDateTime.parse(2024-12-25T14:30:00);注意LocalDateTime.of()传参的默认顺序是年、月、日、时、分、秒、纳秒。这个顺序和yyyy-MM-dd HH:mm:ss的阅读习惯是一样的基本不会记错。LocalDate.parse()只能解析 ISO 格式的yyyy-MM-dd如果字符串是其他格式就得配合DateTimeFormatter使用后面专门讲。2.2 绝对时刻类Instant 与 ZonedDateTimeInstant是 JDK8 时间 API 里最容易被忽视却最关键的一个类。它记录的是从1970-01-01T00:00:00Z开始计算的秒和纳秒简洁客观不掺入任何时区规则。它适合作为系统内部传递时间的标准格式——比如后端接口的调用时间戳、日志审计的时间字段、缓存中保存的最后操作时间。ZonedDateTime则在LocalDateTime的基础上增加了完整的时区规则包括夏令时和时区偏移。举个例子同样一句圣诞节下午 14:30在上海是2024-12-25T14:3008:00在纽约因为时区不同对应的 UTC 时刻完全不同。ZonedDateTime能准确表达出这个时刻在某个时区下看起来是什么样的适合做跨时区的业务展示层。客户端传来的时间如何转成本地时间一般流程是客户端传Instant或带偏移的时间串后端转成ZonedDateTime存储时再转成 UTC 的Instant展示时再按当前用户的时区转成本地视图。这个过程一节再细说。2.3 区间类与辅助类Duration、Period、MonthDay、YearMonthDuration和Period都表示一段时间区别在于精度。Duration基于秒和纳秒适合衡量接口耗时、定时任务间隔Period基于年、月、日适合算年龄、算合同天数。写起来也直观Duration fiveMinutes Duration.ofMinutes(5); Period twoYearsThreeMonths Period.of(2, 3, 0); LocalDate birthday LocalDate.of(1995, 5, 20); LocalDate today LocalDate.now(); Period age Period.between(birthday, today); int years age.getYears(); // 按日历算的整岁数辅助类里MonthDay对生日会员日这种只看月和日不管年份的场景特别有用不然你每到 2 月 29 日就要处理一次闰年边界问题MonthDay birthday MonthDay.of(2, 29); MonthDay today MonthDay.from(LocalDate.now()); boolean isBirthday birthday.equals(today);YearMonth则常用于按月统计报表可以直接拿到某个月的天数YearMonth ym YearMonth.of(2024, 2); int days ym.lengthOfMonth(); // 29自动处理闰年选型时记住一句话类型越具体越不容易在后续逻辑里产生歧义。能用LocalDate就别用LocalDateTime能用Instant就别用ZonedDateTime。3. 高频日期操作示例与格式化避坑很多人看完 API 文档觉得类分得清清楚楚一出实际问题照样懵。原因很简单文档只讲方法不讲场景。这里我把业务开发中最常见的日期操作场景全部走一遍每个都写上完整示例和注意点。3.1 日期加减与字段调整怎么做java.time的不可变设计体现在所有修改方法都返回一个新对象原对象本身不会变。这跟String的行为一致写起来安全但要注意别把返回值扔了LocalDate today LocalDate.now(); // 加 3 天 LocalDate afterThreeDays today.plusDays(3); // 减 1 周 LocalDate lastWeek today.minusWeeks(1); // 加 2 个月自动处理月底边界1月31日 1个月 2月28/29日 LocalDate nextTwoMonths today.plusMonths(2); // 把日期调整到当月的第 1 天 LocalDate firstDayOfMonth today.withDayOfMonth(1); // 把日期调整到上一年的最后一天 LocalDate lastDayOfLastYear today.withDayOfYear(1).minusDays(1); // 链式操作获取本月最后一个星期日 LocalDate date today.with(TemporalAdjusters.lastInMonth(DayOfWeek.SUNDAY));这里有个非常实用的小知识plusMonths()遇到月底边界会自动修正。1 月 31 日加一个月并不会变成不存在的 2 月 31 日而是自动变成 2 月 28 日或 29 日。这个行为避免了太多手工判断边界的坑。3.2 日期比较别再getTime()比大小了旧时代比较两个Date基本靠比较毫秒数难看还存在时区干扰。新 API 直接提供了语义清晰的方法LocalDate today LocalDate.now(); LocalDate deadline LocalDate.of(2024, 12, 31); boolean expired today.isAfter(deadline); boolean before today.isBefore(deadline); boolean sameDay today.isEqual(deadline); // 如果要返回 -1 / 0 / 1 这种整数用 compareTo int result today.compareTo(deadline);isBefore/isAfter/isEqual是LocalDate、LocalTime、LocalDateTime、Instant都有的方法语义清晰不用再靠Calendar.before()那套了。3.3 格式化与解析DateTimeFormatter 的正确打开姿势DateTimeFormatter是线程安全的一个实例随便共享。我通常会把项目中用到的格式全部定义成静态常量public static final DateTimeFormatter DATE_TIME_PATTERN DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); public static final DateTimeFormatter DATE_PATTERN DateTimeFormatter.ofPattern(yyyy-MM-dd); // 格式化 LocalDateTime now LocalDateTime.now(); String text now.format(DATE_TIME_PATTERN); // 解析 LocalDateTime parsed LocalDateTime.parse(2024-12-25 14:30:00, DATE_TIME_PATTERN);一个容易踩的坑DateTimeFormatter默认的解析策略是ResolverStyle.SMART它对某些不合规的输入是宽容的。比如DateTimeFormatter.ofPattern(yyyy-MM-dd).parse(2024-1-5)在 SMART 模式下大概率能解析成功但如果你希望严格校验必须显式指定ResolverStyle.STRICTDateTimeFormatter strictFormatter DateTimeFormatter.ofPattern(yyyy-MM-dd).withResolverStyle(ResolverStyle.STRICT);另一个常见坑是 pattern 字母混用。yyyy是周年uuuu是纪元相关年份在绝大多数场景下两者结果一样但遇到公元前日期会有差异。还有HH是 24 小时制hh是 12 小时制有的同事写yyyy-MM-dd hh:mm:ss导致下午两点格式化出来显示成 02:00这个问题特别是报表场景非常顽固。有个口诀小时选大写 HH分钟永远大写 mm秒永远大写 ss月份MM日期dd。3.4 计算两时间点的差值差值计算分三种精度对应前面说的三个类// 1. 按秒/纳秒算适合接口耗时 LocalTime start LocalTime.now(); Thread.sleep(200); // 模拟耗时操作 LocalTime end LocalTime.now(); Duration duration Duration.between(start, end); long millis duration.toMillis(); // 2. 按年/月/日算适合生日、合同 LocalDate birthday LocalDate.of(1995, 5, 20); Period period Period.between(birthday, LocalDate.now()); System.out.printf(年龄 %d 岁 %d 个月 %d 天%n, period.getYears(), period.getMonths(), period.getDays()); // 3. 按指定单位算适合还有几天到期 LocalDate deadline LocalDate.of(2024, 12, 31); long daysLeft ChronoUnit.DAYS.between(LocalDate.now(), deadline);ChronoUnit是一个很强大的枚举支持的粒度从纳秒跨到年between()方法算一天以内的时间时会自动按时间线长度计算而不是日历天数逻辑非常严谨。3.5 拿到本周一昨天 0 点这类边界时间这些都是报表定时任务的高频需求。TemporalAdjusters里已经封装好了常用调整器LocalDate today LocalDate.now(); LocalDate thisMonday today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); LocalDate thisSunday today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); LocalDate firstDay today.with(TemporalAdjusters.firstDayOfMonth()); LocalDate lastDay today.with(TemporalAdjusters.lastDayOfMonth()); // 下周五 LocalDate nextFriday today.with(TemporalAdjusters.next(DayOfWeek.FRIDAY));注意next()和nextOrSame()的区别前者是严格往后找下一个如果今天就是周五next(FRIDAY)返回的是下周五后者则允许今天满足条件就直接返回今天。如果是生成一段时间内的所有日期最简单的做法是循环plusDays(1)ListLocalDate dateList new ArrayList(); LocalDate start LocalDate.of(2024, 12, 1); LocalDate end start.plusDays(6); for (LocalDate d start; !d.isAfter(end); d d.plusDays(1)) { dateList.add(d); }这段代码虽然简单但它是很多排班、图表补全、周期统计的核心逻辑值得记下来。4. 时区处理LocalDateTime 不是绝对时间时区问题绝对是日期 API 里含金量最高、同时也是坑最深的部分。很多刚接触新 API 的人容易有个误区觉得LocalDateTime.now()拿到的就是当前时间然后当参数传来传去最后做跨时区功能或者服务器部署到海外时就彻底凌乱了。4.1 Instant 与 LocalDateTime 的本质区别用一句话概括Instant是客观时间线上的某一个点LocalDateTime是墙上挂钟的读数。举个例子北京时间 2024 年 12 月 25 日 14:30 这一刻伦敦的挂钟显示的是 06:30纽约显示的是 01:30。这三个LocalDateTime读数不同但对应的Instant完全一致。所以你的系统用户只在同一个时区不需要考虑跨时区可以用LocalDateTime你的系统要对接多个时区的用户比如海外电商、跨国协作工具存储和传输就应该用Instant或带时区信息的时间展示时再转成本地时间如果你的服务器部署在海外但业务是面向国内用户的那么LocalDate.now()得到的结果很可能不是你想要的今天// 获取当前时刻的 Instant Instant now Instant.now(); // 把 Instant 转成某时区的 ZonedDateTime 进行展示 ZonedDateTime shanghaiTime now.atZone(ZoneId.of(Asia/Shanghai)); ZonedDateTime newYorkTime now.atZone(ZoneId.of(America/New_York)); System.out.println(shanghaiTime); // 2024-12-25T14:30:0008:00[Asia/Shanghai] System.out.println(newYorkTime); // 2024-12-25T01:30:00-05:00[America/New_York]4.2 ZonedDateTime 与 OffsetDateTime 怎么选这两个类长得像使用场景却完全不同。ZonedDateTime包含完整的时区规则ZoneId能自动处理夏令时。OffsetDateTime只包含固定的 UTC 偏移量不关心该时区未来是否会调整偏移。举个例子美国东部时间 2024 年 3 月 10 日凌晨 2 点进入夏令时时钟直接拨到 3 点。如果你用ZonedDateTime去计算那一天的凌晨 1 点加 2 小时它能正确得到 4 点因为 2 点不存在自动顺延到 3 点再加 1 小时是 4 点但如果你用OffsetDateTime做同样的计算它因为不掌握夏令时规则会得到一个表面上正确、实际上不存在的时间。因此我建议对外传输用OffsetDateTime固定偏移接口清晰内部计算和业务建模用ZonedDateTime掌握完整规则或者Instant纯时刻永不出错。如果项目一定要用LocalDateTime传输请确保契约里明确规定所有时间均为某固定时区的本地时间否则迟早出事故。4.3LocalDate.now()依赖于 JVM 默认时区这是个隐蔽陷阱LocalDate.now()实际调用了LocalDate.now(Clock.systemDefaultZone())读取的是 JVM 的系统默认时区。如果服务器时区没配置好或者 java 启动参数里没有显式指定时区就会出现服务器是上海时区但 JVM 默认时区是 UTC导致日期差一天的问题。我曾在实际项目里遇到过日志时间比数据库时间整整快了 8 小时排查了半天发现不是代码问题而是运维部署时没有统一设置 JVM 时区。生产环境启动命令里一定要加上-Duser.timezoneAsia/Shanghai如果代码层面想对系统默认时区完全可控也可以统一走一个ClockClock clock Clock.system(ZoneId.of(Asia/Shanghai)); LocalDate today LocalDate.now(clock); LocalDateTime now LocalDateTime.now(clock);当然Clock还有一个好处是在单元测试里可以注入固定时间非常方便。4.4 数据库时间字段怎么存最稳妥数据库场景是我见过时间八字不合的重灾区。JDK8 时间 API 普及之后JDBC 驱动也随之全面支持了LocalDate、LocalDateTime、OffsetDateTime等类型MySQL 的DATE对应LocalDateMySQL 的DATETIME对应LocalDateTimeMySQL 的TIMESTAMP对应Instant或OffsetDateTime注意TIMESTAMP类型在数据库中存储的是标准 UTC 时刻查询展示时会按会话时区转换。如果 JDBC 连接串里没有正确配置 serverTimezone就会出现写入或读取偏差。常见的连接串配置如下jdbc:mysql://localhost:3306/app?serverTimezoneAsia/ShanghaiuseSSLfalse如果你的业务完全面向国内单一时区LocalDateTimeDATETIME组合是最省心、可读性最好的方案如果你的系统有多时区用户建议用TIMESTAMPInstant同时约定所有接口请求和响应的 ISO 时间统一使用 UTC 标准格式。最怕的是团队内部没有约定同一个字段一会儿传本地时间、一会儿传 UTC排查问题的时候真的会怀疑人生。5. 新旧 API 桥接与 Date/Timestamp 互转的完整姿势虽然新 API 很好用但现实项目里一定存在历史代码接口里还是java.util.Date数据库驱动老版本可能只认java.sql.Timestamp。所以新旧互转是每个 Java 开发者躲不掉的手艺。好在官方提供了标准转换入口不需要任何第三方库。5.1java.util.Date与LocalDateTime互转记住一个万能的中间桥Instant。// Date - LocalDateTime Date legacyDate new Date(); LocalDateTime ldt legacyDate.toInstant() .atZone(ZoneId.systemDefault()) // 用系统默认时区解释 .toLocalDateTime(); // LocalDateTime - Date LocalDateTime now LocalDateTime.now(); Date date Date.from(now.atZone(ZoneId.systemDefault()).toInstant());关键点在于atZone()这步必须指定时区否则无法把一个单纯的本地时间读数对应到时间线上的绝对时刻。绝大多数业务使用系统默认时区是合理的但如果LocalDateTime本身是按 UTC 语义存的转换时就要写ZoneId.of(UTC)这里绝不能图省事直接抄默认写法。5.2java.sql.Date/java.sql.Timestamp转换JDBC 场景下更常用的是这两个类。java.sql.Date是java.util.Date的子类被迫继承了它的所有坑好在新 API 提供了valueOf()做桥接java.sql.Date sqlDate java.sql.Date.valueOf(ld); // LocalDate - java.sql.Date LocalDate ld sqlDate.toLocalDate(); // java.sql.Date - LocalDate java.sql.Timestamp ts java.sql.Timestamp.valueOf(ldt); // LocalDateTime - java.sql.Timestamp LocalDateTime ldt ts.toLocalDateTime(); // java.sql.Timestamp - LocalDateTime这套转换逻辑由于不走Instant完全不带时区概念所以语义上相当直接。但要注意如果你的数据库字段是TIMESTAMP并且连接串或服务器时区设置有问题Timestamp.valueOf()这步得到的结果可能和数据库中实际看到的时间不一致。遇到这种问题先检查连接串的serverTimezone参数。5.3 封装一个团队内通用的时间转换工具类每次写转换代码容易散我推荐团队把常用转换收敛到一个DateUtil工具类里但不要塞太多业务逻辑只做纯转换public final class DateUtil { private static final ZoneId DEFAULT_ZONE ZoneId.systemDefault(); public static LocalDateTime toLocalDateTime(Date date) { return date.toInstant().atZone(DEFAULT_ZONE).toLocalDateTime(); } public static LocalDate toLocalDate(Date date) { return toLocalDateTime(date).toLocalDate(); } public static Date toDate(LocalDateTime dateTime) { return Date.from(dateTime.atZone(DEFAULT_ZONE).toInstant()); } public static Date toDate(LocalDate date) { return Date.from(date.atStartOfDay(DEFAULT_ZONE).toInstant()); } public static Timestamp toTimestamp(LocalDateTime dateTime) { return Timestamp.valueOf(dateTime); } public static LocalDateTime toLocalDateTime(Timestamp timestamp) { return timestamp.toLocalDateTime(); } private DateUtil() {} }这样一封装业务代码根本不需要关心转换细节。不过工具类里的DEFAULT_ZONE要谨慎定义如果项目全部跑在同一时区用ZoneId.systemDefault()没问题如果服务面向多时区方法签名最好加上ZoneId参数避免默认时区带来的隐藏风险。5.4 MyBatis / MyBatis-Plus 对时间类型的支持如果你用 MyBatis 或 MyBatis-Plus3.4.0 以上的版本已经内置了对LocalDate/LocalDateTime/Instant等类型的 TypeHandler 支持Mapper 的 Java 实体里可以直接用这些类型不用再手动写转换器。如果你还在用老版本框架或者遇到类型映射报错可以先看两点MyBatis 版本是否 ≥ 3.4.0JDBC 驱动版本是否支持 JSR-310 类型MySQL Connector/J 从 5.1.39 开始支持推荐 8.0 系列如果是老项目无法升级也可以在 XML 里手动指定resultMap typecom.example.User result columncreate_time propertycreateTime typeHandlerorg.apache.ibatis.type.LocalDateTimeTypeHandler/ /resultMap不管哪种姿势核心思路都是用框架自身的能力去映射java.time包的类型而不是在业务代码里到处做手工转换。我之前接手过一个老项目实体全部用String存时间、底层再自己解析那酸爽你估计也体会过。6. 进阶玩法TemporalAdjuster、周期报表与会话级格式化基础用法掌握后再分享几个能真正提升编码效率的进阶玩法项目里用得上面试讲出来也有亮点。6.1 自定义 TemporalAdjuster 实现下一个工作日系统内置的TemporalAdjusters满足不了所有业务。比如业务要算下一个工作日需要跳过周末和节假日。这种场景可以写一个自定义的TemporalAdjusterpublic class NextWorkingDay implements TemporalAdjuster { private static final SetLocalDate HOLIDAYS Set.of( LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 10), LocalDate.of(2024, 10, 1) ); Override public Temporal adjustInto(Temporal temporal) { LocalDate date LocalDate.from(temporal); do { date date.plusDays(1); } while (isHoliday(date) || date.getDayOfWeek().getValue() 6); return temporal.with(date); } private boolean isHoliday(LocalDate date) { return HOLIDAYS.contains(date); } }使用方式非常优雅LocalDate nextWorking LocalDate.now().with(new NextWorkingDay());这种自定义调整器特别适合有复杂调休规则的企业办公系统而且因为实现的是标准接口可以和其他TemporalAdjusters随意组合使用。代码处理假期与周末的循环逻辑无需额外说明结合上面示例即可直接套用。6.2 生成报表周期按周、按月聚合报表开发中常见的需求是按周聚合或按月聚合。JDK8 虽然没有直接提供YearWeek类但借助WeekFields可以轻松完成周数的计算WeekFields weekFields WeekFields.of(Locale.getDefault()); LocalDate today LocalDate.now(); // ISO 体系下周一是一周的第一天第一周至少四天在本年内 int yearWeek today.get(weekFields.weekBasedYear()); int weekOfYear today.get(weekFields.weekOfWeekBasedYear()); // 按月聚合时取当月第一天和最后一天 YearMonth ym YearMonth.from(today); LocalDate monthStart ym.atDay(1); LocalDate monthEnd ym.atEndOfMonth();这里有个细节很多人会忽略WeekFields依赖Locale不同地区一周从哪天开始不一样美国算周日欧洲多为周一。如果你的系统面对的是固定地区用户建议用WeekFields.ISO显式指定别依赖默认Locale否则部署环境不同报表周数就会变。6.3 用 DateTimeFormatterBuilder 处理复杂的多格式入参接口接第三方数据的时候传入的时间格式可能五花八门常见的有yyyy-MM-dd HH:mm:ss、yyyy/MM/dd HH:mm、yyyy-MM-ddTHH:mm:ss.SSSZ等。写多个DateTimeFormatter然后逐个 try 解析也行但代码会比较笨拙。更稳妥的方式是用DateTimeFormatterBuilder自定义一个容错格式DateTimeFormatter flexibleFormatter new DateTimeFormatterBuilder() .appendPattern(yyyy) .optionalStart() .appendLiteral(-) .appendValue(ChronoField.MONTH_OF_YEAR, 1, 2, SignStyle.NOT_NEGATIVE) .appendLiteral(-) .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NOT_NEGATIVE) .optionalEnd() .optionalStart() .appendLiteral( ) .appendValue(ChronoField.HOUR_OF_DAY, 1, 2, SignStyle.NOT_NEGATIVE) .appendLiteral(:) .appendValue(ChronoField.MINUTE_OF_HOUR, 1, 2, SignStyle.NOT_NEGATIVE) .optionalEnd() .toFormatter();这个 builder 里面最有价值的是appendValue(field, minWidth, maxWidth, SignStyle)它可以控制解析的宽度范围让2024-1-5 和 2024-01-05 都能被正确解析同时仍然保持格式的约束力。相比直接用ofPattern(yyyy-MM-dd HH:mm)然后强制要求前端必须传两位月份这种鲁棒性在对接外部系统时太重要了。6.4 pattern 字母速查表最后放一个实用的速查表完整掌握最常见的模式字母含义。你要是经常忘建议收藏或抄到团队 wiki 里。格式场景按需选用多数日常业务配合LocalDateTime即可满足。字母含义示例输出yyyy四位年份2024MM两位月份01-1212dd两位日期25HH24 小时制小时14hh12 小时制小时02mm分钟30ss秒00SSS毫秒123EEE星期缩写取决于 Locale周三T字面量 TISO 标准时间分隔符TXXX时区偏移带冒号08:00Z时区偏移RFC 822 风格0800注意无论你想用yyyy还是uuuu都建议保持团队统一。我自己的习惯是非历史场景一律用yyyy可读性好几乎没有歧义。将来你若遇到要处理公元前日期的项目再考虑uuuu即可。7. 项目迁移建议与团队约定最后聊聊把项目从旧的Date/Calendar迁移到新 API 这件事。很多人问老项目还能不能动我的答案是必须动但要分步骤动不能一把梭。7.1 渐进式替换比一次性重构更稳把整个系统的所有Date变量全部替换成LocalDateTime这种大爆炸式重构在业务繁忙的项目里往往风险极高。更稳妥的迁移路线是第一步新代码统一使用新 API旧代码保持原样。在团队的代码规范里明确写死新增代码禁止使用java.util.Date声明变量。第二步在边缘模块先试点迁移通常是报表、定时任务这类不涉及核心交易链路的模块。迁移时优先改入参与出参都在代码内部流转的部分暂时不改对外接口的协议格式。第三步核心模块迁移前先确保团队对时区语义有统一认知。特别是哪些字段要存Instant、哪些字段只用LocalDateTime这个约定比迁移本身更重要。第四步处理器和 MyBatis 映射层最后统一替换。等实体类字段类型换成新 API再逐个调整 Mapper 的 TypeHandler最后全面回归。我见过一个比较稳的做法先写一个DateUtil工具类就是第 5 节那种把转换逻辑全部收敛起来然后在业务代码里用新 API 写新逻辑 DateUtil 做边界转换的方式逐步推进。整个过程大概两三个迭代就能完成而且因为每个改动点都经过单测不容易出现大面积返工。7.2 团队内部的时间语义约定时间最怕的不是 API 不好用而是团队里每个人对这个字段该用什么类型理解不一致。我建议在项目 wiki 里写这么一张表作为代码评审时的重要依据业务场景推荐类型存储方案用户生日、账单日、节假日LocalDateMySQL DATE带时间的工作记录、下单时间LocalDateTimeMySQL DATETIME日志、审计、跨系统时间戳Instant数字时间戳或 TIMESTAMP多时区用户可见的会议/日程ZonedDateTime或OffsetDateTime存 UTC接口传带偏移的 ISO 字符串这张表没有把java.util.Date列进来因为规则就一条没有特殊理由不要在新增代码里使用java.util.Date。Calendar就更不用说了活化石。7.3 面试考点JDK8 时间 API 怎么答这批知识点几乎稳坐面试题库从热词里也能看出大家都在关注。我梳理几个高频问题供你自查SimpleDateFormat为什么线程不安全因为它内部共享Calendar对象format和parse时会修改状态。所以要么每次新建要么用ThreadLocal要么用线程安全的DateTimeFormatter。LocalDateTime和Instant的区别LocalDateTime是本地挂钟读数没有时区信息Instant是时间线上的绝对时刻天然是 UTC。跨时区场景必须用Instant或ZonedDateTime。ZonedDateTime和OffsetDateTime的区别前者带完整时区规则能处理夏令时后者只带固定偏移量。做跨国业务、涉及夏令时场景选前者做 API 传输选后者。Period和Duration的区别Period是针对年月日的时间量Duration是秒/纳秒级的时间量。算年龄用Period算接口耗时用Duration。如何获得本月的最后一天LocalDate.now().with(TemporalAdjusters.lastDayOfMonth())。Date转LocalDateTime怎么做用date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()。这些题答得清楚基本可以证明你不仅会用 API还理解背后的设计逻辑。这和背诵八股文完全不同因为每个答案背后都有真实业务场景支撑。最后再分享一次我印象最深的实战教训。有一次线上报表任务突然数据对不上排查了很久最后发现是服务器处于 UTC 时区而LocalDate.now()在北京时间凌晨 0 点到 8 点之间会取到昨天。从那以后我要求团队里所有日期相关的代码都显式传入ZoneId或统一依赖同一个Clock绝不允许在核心逻辑里裸调now()。这种问题看代码很难发现因为它符合直觉、却不符合环境。如果你正在切到新 API 的路上希望这篇文章能帮你把类似的雷提前排掉。