news 2026/9/11 4:35:52

JDK8 时间 API 实战:从 Date 到 LocalDateTime 的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JDK8 时间 API 实战:从 Date 到 LocalDateTime 的完整指南

写 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 终于把这些全部改成了符合直觉的设计:月份一到十二就是112,年份直接用真实年份,星期用DayOfWeek枚举(从周一到周日),时间字段通过getYear()getMonthValue()getDayOfMonth()方法直接获取,这些方法签名就是文档,不需要再翻参考手册。

1.3SimpleDateFormat线程不安全:高并发下的定时炸弹

如果说前面的坑只是"难用",SimpleDateFormat的线程不安全问题就是真正的"生产事故引信"。SimpleDateFormat内部维护了一个Calendar实例用于日期计算,format()parse()过程中会修改Calendar的状态。多个线程共享同一个实例时,解析一半的Calendar字段被另一个线程改写,轻则格式化结果错乱,重则直接抛出NumberFormatException或者解析出完全错误的日期。

业界通行做法是每次使用都new SimpleDateFormat(),但这样在高并发下频繁创建对象,性能开销不小,而且一不小心忘了还会养成坏习惯。当然也可以用ThreadLocal包装,不过代码写起来就啰嗦了,尤其在 JDK 低版本时代这几乎是人手一份的工具代码。

JDK8 的DateTimeFormatter是不可变且线程安全的类,一个实例可以在任意多个线程间共享。设计上它也内置了大量预定义格式(ISO_LOCAL_DATEISO_LOCAL_DATE_TIMEISO_INSTANT等),绝大多数业务根本不需要自己写 pattern,直接用现成的格式器就行。这一点在实际生产环境里省了太多心。

理解了这三大痛点,你就明白 JDK8 时间 API 的设计哲学了:不可变对象保证线程安全,语义清晰的类划分保证可读性,一致的方法命名(ofnowplusminuswithto)保证可预测性。这六个字记在心里,用的时候基本不会迷路。

2. 核心类族:六个主力类和一个辅助类怎么选

java.time包里类不少,但实际工作中高频使用的其实就那么几个。我习惯把它们分成"时刻类""本地类""区间类"和"辅助类"四组来记忆,选型逻辑特别清晰。

语义典型场景示例值
Instant时间线上的绝对时刻(UTC 视角)日志时间戳、跨系统时间传递2024-12-25T06:30:00Z
LocalDate本地日期,无时区、无时间生日、账单日、报表日期2024-12-25
LocalTime本地时间,无时区、无日期上下班打卡时间、营业时间14:30:00
LocalDateTime本地日期+时间,无时区业务单据的时间,不跨时区场景2024-12-25T14:30:00
ZonedDateTime带完整时区规则的日期时间跨时区会议、航班到达时间2024-12-25T14:30:00+08:00[Asia/Shanghai]
OffsetDateTime带固定 UTC 偏移量的日期时间API 接口传输时间、数据库 TIMESTAMP2024-12-25T14:30:00+08:00
Duration秒/纳秒级的时长接口耗时统计PT2H30M
Period年/月/日级的周期计算年龄、计算合同剩余天数P1Y2M3D

2.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 与 ZonedDateTime

Instant是 JDK8 时间 API 里最容易被忽视却最关键的一个类。它记录的是从1970-01-01T00:00:00Z开始计算的秒和纳秒,简洁客观,不掺入任何时区规则。它适合作为系统内部传递时间的标准格式——比如后端接口的调用时间戳、日志审计的时间字段、缓存中保存的最后操作时间。

ZonedDateTime则在LocalDateTime的基础上增加了完整的时区规则,包括夏令时和时区偏移。举个例子,同样一句"圣诞节下午 14:30",在上海是2024-12-25T14:30+08:00,在纽约因为时区不同,对应的 UTC 时刻完全不同。ZonedDateTime能准确表达出"这个时刻在某个时区下看起来是什么样的",适合做跨时区的业务展示层。

客户端传来的时间如何转成本地时间?一般流程是:客户端传Instant或带偏移的时间串,后端转成ZonedDateTime,存储时再转成 UTC 的Instant;展示时再按当前用户的时区转成本地视图。这个过程一节再细说。

2.3 区间类与辅助类:Duration、Period、MonthDay、YearMonth

DurationPeriod都表示一段时间,区别在于精度。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/isEqualLocalDateLocalTimeLocalDateTimeInstant都有的方法,语义清晰,不用再靠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.STRICT

DateTimeFormatter 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)

List<LocalDate> 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:00+08: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.timezone=Asia/Shanghai

如果代码层面想对"系统默认时区"完全可控,也可以统一走一个Clock

Clock clock = Clock.system(ZoneId.of("Asia/Shanghai")); LocalDate today = LocalDate.now(clock); LocalDateTime now = LocalDateTime.now(clock);

当然,Clock还有一个好处是在单元测试里可以注入固定时间,非常方便。

4.4 数据库时间字段怎么存最稳妥

数据库场景是我见过时间"八字不合"的重灾区。JDK8 时间 API 普及之后,JDBC 驱动也随之全面支持了LocalDateLocalDateTimeOffsetDateTime等类型:

  • MySQL 的DATE对应LocalDate
  • MySQL 的DATETIME对应LocalDateTime
  • MySQL 的TIMESTAMP对应InstantOffsetDateTime

注意TIMESTAMP类型在数据库中存储的是标准 UTC 时刻,查询展示时会按会话时区转换。如果 JDBC 连接串里没有正确配置 serverTimezone,就会出现写入或读取偏差。常见的连接串配置如下:

jdbc:mysql://localhost:3306/app?serverTimezone=Asia/Shanghai&useSSL=false

如果你的业务完全面向国内单一时区,LocalDateTime+DATETIME组合是最省心、可读性最好的方案;如果你的系统有多时区用户,建议用TIMESTAMP+Instant,同时约定所有接口请求和响应的 ISO 时间统一使用 UTC 标准格式。最怕的是团队内部没有约定,同一个字段一会儿传本地时间、一会儿传 UTC,排查问题的时候真的会怀疑人生。

5. 新旧 API 桥接:与 Date/Timestamp 互转的完整姿势

虽然新 API 很好用,但现实项目里一定存在历史代码,接口里还是java.util.Date,数据库驱动老版本可能只认java.sql.Timestamp。所以新旧互转是每个 Java 开发者躲不掉的手艺。好在官方提供了标准转换入口,不需要任何第三方库。

5.1java.util.DateLocalDateTime互转

记住一个万能的中间桥: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.Datejava.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-Plus,3.4.0 以上的版本已经内置了对LocalDate/LocalDateTime/Instant等类型的 TypeHandler 支持,Mapper 的 Java 实体里可以直接用这些类型,不用再手动写转换器。如果你还在用老版本框架,或者遇到类型映射报错,可以先看两点:

  • MyBatis 版本是否 ≥ 3.4.0
  • JDBC 驱动版本是否支持 JSR-310 类型(MySQL Connector/J 从 5.1.39 开始支持,推荐 8.0 系列)

如果是老项目无法升级,也可以在 XML 里手动指定:

<resultMap type="com.example.User"> <result column="create_time" property="createTime" typeHandler="org.apache.ibatis.type.LocalDateTimeTypeHandler"/> </resultMap>

不管哪种姿势,核心思路都是用框架自身的能力去映射java.time包的类型,而不是在业务代码里到处做手工转换。我之前接手过一个老项目,实体全部用String存时间、底层再自己解析,那酸爽,你估计也体会过。

6. 进阶玩法:TemporalAdjuster、周期报表与会话级格式化

基础用法掌握后,再分享几个能真正提升编码效率的进阶玩法,项目里用得上,面试讲出来也有亮点。

6.1 自定义 TemporalAdjuster 实现"下一个工作日"

系统内置的TemporalAdjusters满足不了所有业务。比如业务要算"下一个工作日",需要跳过周末和节假日。这种场景可以写一个自定义的TemporalAdjuster

public class NextWorkingDay implements TemporalAdjuster { private static final Set<LocalDate> 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-dd'T'HH: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四位年份2024
MM两位月份(01-12)12
dd两位日期25
HH24 小时制小时14
hh12 小时制小时02
mm分钟30
ss00
SSS毫秒123
EEE星期缩写,取决于 Locale周三
'T'字面量 T(ISO 标准时间分隔符)T
XXX时区偏移,带冒号+08:00
Z时区偏移,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
多时区用户可见的会议/日程ZonedDateTimeOffsetDateTime存 UTC,接口传带偏移的 ISO 字符串

这张表没有把java.util.Date列进来,因为规则就一条:没有特殊理由,不要在新增代码里使用java.util.DateCalendar就更不用说了,活化石。

7.3 面试考点:JDK8 时间 API 怎么答

这批知识点几乎稳坐面试题库,从热词里也能看出大家都在关注。我梳理几个高频问题,供你自查:

  • SimpleDateFormat为什么线程不安全?因为它内部共享Calendar对象,formatparse时会修改状态。所以要么每次新建,要么用ThreadLocal,要么用线程安全的DateTimeFormatter
  • LocalDateTimeInstant的区别?LocalDateTime是本地挂钟读数,没有时区信息;Instant是时间线上的绝对时刻,天然是 UTC。跨时区场景必须用InstantZonedDateTime
  • ZonedDateTimeOffsetDateTime的区别?前者带完整时区规则,能处理夏令时;后者只带固定偏移量。做跨国业务、涉及夏令时场景选前者,做 API 传输选后者。
  • PeriodDuration的区别?Period是针对年月日的时间量,Duration是秒/纳秒级的时间量。算年龄用Period,算接口耗时用Duration
  • 如何获得本月的最后一天?LocalDate.now().with(TemporalAdjusters.lastDayOfMonth())
  • DateLocalDateTime怎么做?date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()

这些题答得清楚,基本可以证明你不仅会用 API,还理解背后的设计逻辑。这和"背诵八股文"完全不同,因为每个答案背后都有真实业务场景支撑。

最后再分享一次我印象最深的实战教训。有一次线上报表任务突然数据对不上,排查了很久,最后发现是服务器处于 UTC 时区,而LocalDate.now()在北京时间凌晨 0 点到 8 点之间会取到"昨天"。从那以后,我要求团队里所有日期相关的代码都显式传入ZoneId或统一依赖同一个Clock,绝不允许在核心逻辑里裸调now()。这种问题看代码很难发现,因为它符合直觉、却不符合环境。如果你正在切到新 API 的路上,希望这篇文章能帮你把类似的雷提前排掉。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 4:34:15

MicroPython轻量日志模块uLogLite:极简设计实现日志级别与轮转

1. 写在前面&#xff1a;为什么我在 MicroPython 里弃用了标准 logging做了一段时间的 MicroPython 开发后&#xff0c;你会发现一个尴尬的事实&#xff1a;板子上跑着正经业务代码&#xff0c;结果日志系统反而是最先拖后腿的那个。官方标准库里的 logging 模块能用&#xff0…

作者头像 李华
网站建设 2026/9/11 4:33:41

含分布式电源的配电网优化:二阶锥松弛与YALMIP/MOSEK实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 4:25:26

飞行汽车技术发展现状与未来趋势

1. 飞行汽车技术发展现状与核心挑战飞行汽车作为融合地面行驶与空中飞行能力的下一代交通工具&#xff0c;正在从科幻概念走向工程现实。根据中国汽车工程学会最新研究&#xff0c;全球已有超过200家企业投入飞行汽车研发&#xff0c;其中既包含传统航空巨头波音、空客&#xf…

作者头像 李华
网站建设 2026/9/11 4:25:16

毕业论文写作全流程指南:从选题到答辩

1. 毕业论文写作全景指南刚拿到导师给的选题通知时&#xff0c;我和大多数同学一样两眼发懵。图书馆里那些砖头般的学术专著&#xff0c;知网上密密麻麻的文献&#xff0c;还有导师口中"要有创新性"的要求&#xff0c;都让人头皮发麻。直到经历了开题被毙、中期检查被…

作者头像 李华