告别 Date/SimpleDateFormat 的线程坑 — Instant/LocalDateTime/ZonedDateTime 三件套, UTC 存储 + 时区展示的黄金约定
java.time 的设计核心是把"机器时间"和"人类时间"拆开: Instant 是 UTC 时间轴上的绝对点, 给存储和计算用; LocalDateTime 是一张无时区挂历, 只该出现在"用户输入/展示"两端; 中间所有换算都靠 ZonedDateTime 挂上 ZoneId 完成。再配上"UTC 存储 + 出口带时区 + 前端按用户渲染"的黄金约定, 跨时区数据就不会漂。最后记住两件礼物: 全家不可变(DateTimeFormatter 终于能 static final 共享了), 以及 Clock 可注入 — 时间从"环境"变成了"输入", 测试从此可重复。
Instant i = Instant.parse("2026-09-26T10:00:00Z"); // 机器: UTC 绝对点 LocalDateTime ldt = LocalDateTime.of(2026, 9, 26, 18, 0); // 人类: 无时区挂历 ldt.atZone(ZoneId.of("Asia/Shanghai")).toInstant(); // → 同一时刻 10:00Z
Instant.now()、Instant.parse("2026-09-26T10:00:00Z")。时间戳字段(过期时间/创建时间)一律存它; JDBC 对应 TIMESTAMP 列(连接时区=UTC 时)。
Instant t = Instant.parse("2026-09-26T10:00:00Z"); t.getEpochSecond(); // → 1790416800 t.plus(Duration.ofSeconds(30)); // → 10:00:30Z, 计算全在 UTC
LocalDateTime ldt = LocalDateTime.of(2026, 9, 26, 18, 0); ldt.atZone(ZoneId.of("Asia/Shanghai")); // → 18:00+08 = 10:00Z ldt.atZone(ZoneOffset.UTC); // → 18:00Z, 完全另一时刻 // 关键: 同一串挂历, 配哪个时区就是哪个时刻
ZonedDateTime zdt = ldt.atZone(ZoneId.of("America/New_York")); // 规则库 OffsetDateTime odt = zdt.toOffsetDateTime(); // 只剩 -04:00 偏移 // API 出口用 ODT 序列化; 展示给人/调度用 ZDT
P1M 不是 30 天; 夏令时切换日是 23/25 小时, Duration.ofDays(1) 与 plusDays(1)(钟面语义)在那天不等。
LocalDate d = LocalDate.of(2026, 1, 31).plus(Period.ofMonths(1)); // → 2026-02-28 月度语义 P1M (≠ 固定 30 天) Duration.ofDays(1); // PT24h 绝对时长, 两者别混用
static final DateTimeFormatter F = ofPattern("uuuu-MM-dd HH:mm:ss") 全局共享。优先用预定义常量 ISO_LOCAL_DATE_TIME/RFC_1123_DATE_TIME, 零解析开销。
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss"); // 不可变 LocalDateTime.parse("2026-09-26 18:00:00", TS); // 多线程随便用
yyyy 年 vs YYYY 周年(跨年周可能差一年); HH 24 小时制 vs hh 12 小时制(丢了 a 就分不清昼夜); mm 分 vs MM 月; 字面量"年/日"要用单引号包裹否则按 pattern 字母解析抛异常。
LocalDate d = LocalDate.of(2025, 12, 29); // 周一, 属 2026 第 1 周 d.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); // → 2025-12-29 d.format(DateTimeFormatter.ofPattern("YYYY-MM-dd")); // → 2026-12-29 周年! LocalTime.of(15, 0).format(DateTimeFormatter.ofPattern("hh:mm")); // → 03:00
ZoneId.of("America/New_York"), 绝不用 EST/CST 缩写 — CST 同时是中美两个时区, 解析按方言走。
ZoneId.of("America/New_York"); // ✓ 全称, 带 TZDB 夏令时规则 // ZoneId.of("EST") → 无夏令时的固定偏移 (legacy 特例) // 老 API getTimeZone("CST") 按方言解析, 中美歧义
Clock.systemUTC() 生产用, Clock.fixed(...) 测试用; 过期判断/倒计时逻辑依赖注入的 Clock, 时间变成可钉死的输入。默认时区的暗依赖也一并消除。
class TokenService { private final Clock clock; // 生产 systemUTC(), 测试 fixed boolean expired(Token t) { return t.expireAt().isBefore(clock.instant()); } }
date.toInstant() / Date.from(i); Calendar 经 toInstant().atZone(zone); java.sql.Timestamp 在 JDBC 4.2 后可直接 get/setObject(OffsetDateTime)。
Instant i = oldDate.toInstant(); // Date → Instant Date back = Date.from(i); // Instant → Date ZonedDateTime z = cal.toInstant().atZone(ZoneId.systemDefault());
// 连接时区定死 UTC + TIMESTAMP 列 OffsetDateTime t = rs.getObject("created_at", OffsetDateTime.class); ps.setObject(1, OffsetDateTime.now(ZoneOffset.UTC)); // JDBC 4.2
truncatedTo(ChronoUnit.DAYS) 截到"当天 00:00"是以对象自己的时间线为准; Instant 的"天"在东八区对应本地 08:00 — 按天聚合先 atZone 到目标时区再截断。
Instant i = Instant.parse("2026-09-26T02:30:00Z"); i.truncatedTo(ChronoUnit.DAYS); // → 00:00Z = 东八区 08:00 i.atZone(ZoneId.of("Asia/Shanghai")).truncatedTo(ChronoUnit.DAYS); // 对: → 2026-09-26T00:00+08:00 本地零点
now() 必不相等; 同一请求里要"统一的当前时间"就取一次传参(或注入 Clock 后一次取值), 否则边界测试永远碰运气。
if (a.isBefore(LocalDateTime.now()) && LocalDateTime.now().isBefore(b)) {} // 错: 两个 now() 值不同, 边界随机红 LocalDateTime now = LocalDateTime.now(); // 对: 取一次传下去
三地部署后同一笔订单的创建时间差 8/13 小时, 报表对不上账; 按清单一次改造, 全链路统一 UTC:
// 改造清单 (新服务直接照抄): // 1) JDBC URL: jdbc:mysql://...?connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true // 2) 实体时间字段统一 Instant/OffsetDateTime, 列类型 TIMESTAMP // 3) API 出口 RFC3339 带偏移, 关掉时间戳数字序列化 @Bean Jackson2ObjectMapperBuilderCustomizer utcJackson() { return b -> b.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); } // 4) JVM 启动参数 -Duser.timezone=UTC, 把"默认时区"这个暗依赖也钉死 // 5) 存量表: 写双读旧的一次迁移窗口内换列, 别在线上双写超过一周
改造后三地报表同一时刻对齐, "时间差 8 小时"类工单清零。
全球用户都想在自己本地的早上 8 点收到券; 服务器只有一个 UTC 时钟 — 按时区分组调度:
Map<ZoneId, List<User> byZone = users.stream()
.collect(groupingBy(u -> ZoneId.of(u.getTimeZone()))); // "Asia/Shanghai"
for (var en : byZone.entrySet()) {
ZoneId zone = en.getKey();
ZonedDateTime next8 = LocalDate.now(zone).atTime(8, 0).atZone(zone); // 今天本地 08:00
if (!next8.isAfter(ZonedDateTime.now(zone))) next8 = next8.plusDays(1); // 过了排明天
scheduler.schedule(() -> sendBatch(en.getValue()),
Duration.between(Instant.now(), next8.toInstant()).toMillis(),
TimeUnit.MILLISECONDS); // 调度内部统一换算回 UTC Instant, 只在入口看钟面
}
导出接口每次请求都 ofPattern, 压测显示 12% CPU 花在"反复解析同一串 pattern":
// 差: 每次调用都解析 pattern + 新建 formatter String s = now().format(DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")); // 好: 不可变, 预编译一次全局共享 private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss"); // 更好: 预定义常量零解析, ISO 出口直接用 private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE_TIME; // JMH 量级: 每次构建 ~1μs → 静态复用 ~120ns; 日志百万次/天 ≈ 省下 15 分钟纯 CPU
"这次调用花了多少/超没超预算"用 Duration 表达, 天然可读可比较, 别再手写毫秒减法:
Instant t0 = clock.instant(); try { return callDownstream(req); } finally { Duration cost = Duration.between(t0, clock.instant()); metrics.observe(cost); // 直方图: P99 从这看 if (cost.compareTo(budget) > 0) // 预算超支即时告警 log.warn("slow call cost={} budget={}", cost, budget); } // 预算拆账: 总 P99 300ms = 网关 20 + 下游A 200 + 下游B 80, 全用 Duration 声明 private static final Duration BUDGET = Duration.ofMillis(200);
token 过期逻辑直接 now(), 测试要么 sleep 要么构造"刚好临界"的数据, 偶发红; 把时间变成输入:
class TokenService { private final Clock clock; // 构造器注入, 生产传 systemUTC() TokenService(Clock clock) { this.clock = clock; } boolean expired(Token t) { return t.expireAt().isBefore(clock.instant()); // "现在"是注入的 } } // 测试: 固定钟钉死当前时刻, 边界用例一次写全 Clock frozen = Clock.fixed(Instant.parse("2026-09-26T00:00:00Z"), ZoneOffset.UTC); var svc = new TokenService(frozen); assertTrue(svc.expired(new Token("2026-09-25T23:59:59Z"))); // 过期 1 秒 assertFalse(svc.expired(new Token("2026-09-26T00:00:01Z"))); // 还剩 1 秒
上海提议的会议时间, 纽约/伦敦同事各自钟面是几点, 是否落在各自工作时间 — 一次换算全知道:
ZoneId SH = ZoneId.of("Asia/Shanghai"), NY = ZoneId.of("America/New_York"), LO = ZoneId.of("Europe/London"); ZonedDateTime sh = LocalDate.of(2026, 9, 28).atTime(9, 0).atZone(SH); // 上海 09:00 ZonedDateTime ny = sh.withZoneSameInstant(NY); // 同一时刻的纽约钟面 21:00 前一天 ZonedDateTime lo = sh.withZoneSameInstant(LO); // 伦敦钟面 02:00 boolean ok = ny.getHour() >= 8 && ny.getHour() < 20 && lo.getHour() >= 8 && lo.getHour() < 20; // withZoneSameInstant: 时刻不动换钟面; withZoneSameLocal: 保钟面换时刻 — 用错就是事故
美国区活动按"自然天"计费, 春令时那天只有 23 小时, 手写 24*3600 的代码多收了一小时的钱:
ZoneId ny = ZoneId.of("America/New_York"); LocalDate dst = LocalDate.of(2026, 3, 8); // 春令时: 当天少 1 小时 ZonedDateTime a = dst.atStartOfDay(ny); ZonedDateTime b = dst.plusDays(1).atStartOfDay(ny); Duration dayLen = Duration.between(a, b); // PT23H — 不是 24 小时! // 计费/排班按 Duration 算自动正确; 按小时单价 × 24 就是多收钱 // 钟面语义: zdt.plusDays(1) 保持钟面 (02:30 的下一天还是 02:30, 不存在的时刻自动平移) // 秋令时 11-1: 01:30 出现两次, 存库必须带偏移/规则否则读不回唯一时刻
订单表按天分了 1024 张, 分片键取值时用服务器本地日期 — 8 小时差让 0 点后的单落错表, 迁移脚本全部对不上:
static final ZoneId SHARD_ZONE = ZoneOffset.UTC; // 写死并文档化, 全链路一致 static String tableFor(Instant t) { LocalDate day = t.atZone(SHARD_ZONE).toLocalDate(); // UTC 天, 与存储约定同源 return "order_" + day.format(DateTimeFormatter.BASIC_ISO_DATE); // order_20260926 } // 跨天查询: [t0, t1) 先换算出覆盖的 UTC 天集合, 再逐表 UNION — 别用本地天猜 // 想按"业务本地天"分片可以, 但 SHARD_ZONE 必须是常量并进设计文档, 不许读系统默认
会员账单"每月 15 日出账", 用 30 天近似的结果是半年后账单日漂到 17 号, 客服爆单:
LocalDate billStart = LocalDate.of(2026, 8, 15); LocalDate billEnd = billStart.plus(Period.ofMonths(1)); // 2026-09-15, 月度语义 Period cycle = Period.between(billStart, billEnd); // P1M: 28/29/30/31 天都对 // 生日提醒: birthday.withYear(today.getYear()) 再比较今天 // 2/29 生日在平年要自己定规则 (2/28 或 3/1), java.time 不会替你做业务决定 // 千万别用 Duration.ofDays(30) 凑月 — 两种"量"混用是本页头号坑
三十万行遗留代码不敢大动; 按"等价替换"清单机械迁移, 每条都有映射, 不改行为只换类型:
// Date → Instant: date.toInstant() / Date.from(instant) // Calendar → ZonedDateTime: cal.toInstant().atZone(ZoneId.systemDefault()) // SimpleDateFormat → DTF: ofPattern 预编译 static final 后共享 // cal.add(DAY_OF_MONTH, 1): 老日历 1/31+1 天静默滚到 3/3; zdt.plusDays(1) 直接抛异常 // — 迁移时把"依赖静默溢出"的代码暴露出来是收益不是麻烦 // new Date() → clock.instant(); System.currentTimeMillis() 计时用途可保留 // java.sql.Timestamp → rs.getObject(col, OffsetDateTime.class) (JDBC 4.2+) // 三行替换覆盖 90% 场景; 剩下的 (日历算法/夏令时逻辑) 先补测试再动
yyyy(或更严的 uuuu), YYYY 只在真的要 week-year 时出现, 且必须配 ww。
var d = LocalDate.of(2025, 12, 29); d.format(DateTimeFormatter.ofPattern("YYYY-MM-dd")); // 错: → 2026-12-29 d.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); // 对: → 2025-12-29
HH; 保留 hh 就必须格式里带 a。
var t = LocalTime.of(15, 0); t.format(DateTimeFormatter.ofPattern("hh:mm")); // 错: → "03:00" 无昼夜 t.format(DateTimeFormatter.ofPattern("HH:mm")); // 对: → "15:00" t.format(DateTimeFormatter.ofPattern("hh:mm a")); // 保留 hh 必须带 a
static final DateTimeFormatter(不可变); 老代码过渡用 ThreadLocal。
// 错: static SimpleDateFormat SDF = ...; 多线程共享 → 状态互踩 // 对: private static final DateTimeFormatter F = DateTimeFormatter.ofPattern("uuuu-MM-dd"); // 不可变
Instant/OffsetDateTime; LocalDateTime 只出现在输入/展示边界。
entity.setCreatedAt(LocalDateTime.now()); // 错: 各端解释漂 8 小时 entity.setCreatedAt(Instant.now()); // 对: UTC 绝对量存库 // LocalDateTime 只活在输入/展示边界
connectionTimeZone=UTC; 存量 DATETIME 在文档写死"本列=UTC"并统一映射。
// 错: jdbc:mysql://db/app 连接时区未定 → 同一行各机器读出不同钟面 // 对: ?connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true // 新表 TIMESTAMP; 存量 DATETIME 文档写死"本列=UTC"
// 错: ZDT 默认序列化只留 +08:00 → 读回 ODT, 夏令时规则丢 zdt.toOffsetDateTime(); // 对: 出口显式降级, 约定一目了然 // 内部传 ZDT 时确认序列化器保留 zone id
expireAtSec), 边界统一 toEpochMilli() 或统一秒, 别混。
instant.getEpochSecond(); // → 1790416800 秒 instant.toEpochMilli(); // → 1790416800000 毫秒 // 错: 秒传给要毫秒的下游 → 全落在 1970; 命名带单位 expireAtSec
with(TemporalAdjusters.lastDayOfMonth()), 显式表达意图。
Calendar c = new GregorianCalendar(2026, Calendar.JANUARY, 31); c.add(Calendar.MONTH, 1); // 错: 静默滚到 2026-03-03 LocalDate.of(2026, 1, 31).with(lastDayOfMonth()); // 对: 显式月末
Period/plusMonths; 跨夏令时的"加一天"用 ZDT 的 plusDays(钟面语义)。
Duration.ofDays(30); // 错: 凑"一个月", 半年后漂移 bill.plus(Period.ofMonths(1)); // 对: 挂历月度语义 // 跨夏令时"加一天": ZDT.plusDays(1) 钟面语义
a.isBefore(LocalDateTime.now()) 和三行后的第二个 now() 值不同, 边界用例随机红. 正解: 方法入口取一次 now 传下去, 或注入 Clock 单点取值。
if (a.isBefore(LocalDateTime.now()) && LocalDateTime.now().isBefore(b)) {} // 错: 两个 now() 值不同, 边界随机红 LocalDateTime now = LocalDateTime.now(); // 对: 入口取一次传下去
Clock, 测试 Clock.fixed; 顺带把"默认时区"暗依赖一起消除。
// 错: 逻辑里直接 now(), 测试只能 sleep/造临界数据 Clock frozen = Clock.fixed(Instant.parse("2026-09-26T00:00:00Z"), ZoneOffset.UTC); new TokenService(frozen); // 对: 时间变成钉死的输入
ZoneId.of("America/Chicago") 全称; 别接受缩写入参, 入口就转换并校验。
// 错: TimeZone.getTimeZone("CST") 三方言, 换环境结果变 ZoneId.of("America/Chicago"); // 对: Region/City 全称 // 入口校验并转换缩写, 不让缩写流进核心逻辑
atZone(userZone) 再 truncatedTo, 或直接用 toLocalDate() 分组。
Instant i = Instant.parse("2026-09-26T02:30:00Z"); i.truncatedTo(ChronoUnit.DAYS); // 错: 天界在本地 08:00 i.atZone(ZoneId.of("Asia/Shanghai")).truncatedTo(ChronoUnit.DAYS); // 对: → 2026-09-26T00:00+08:00 本地零点
ZoneOffset.UTC 或业务 ZoneId; 启动参数 -Duser.timezone=UTC 兜底。
ldt.atZone(ZoneId.systemDefault()); // 错: 暗依赖容器时区, 一换就跑偏 ldt.atZone(ZoneOffset.UTC); // 对: 显式时区 // 启动参数 -Duser.timezone=UTC 兜底
'年', 引号本身写两个转义。
DateTimeFormatter.ofPattern("yyyy年MM月"); // 错: Unknown pattern '年' DateTimeFormatter.ofPattern("yyyy'年'MM'月'"); // 对: 单引号包裹
ZonedDateTime a = ZonedDateTime.of(2026, 3, 8, 9, 0, 0, 0, NY); Duration.between(a, a.plusDays(1)); // → PT23H 春令时日 a.plus(Duration.ofHours(24)); // → 次日 10:00 钟面被推
// 错 (JDBC 4.2 前): rs.getObject(col, OffsetDateTime.class) → 异常 OffsetDateTime odt = rs.getTimestamp(col).toInstant() .atOffset(ZoneOffset.UTC); // 对: Timestamp 中转
// 错: jdbc:mysql://db/app?useSSL=false 时区未定, 写读差 N 小时 // 对: ?connectionTimeZone=UTC + 实体 Instant, 驱动替你换算 // 上线前一条哨兵数据核对往返
assertEquals(createdAt, readBack); // 错: 纳秒偶发不等 assertEquals(createdAt.truncatedTo(ChronoUnit.MILLIS), readBack.truncatedTo(ChronoUnit.MILLIS)); // 对
// 错: 提前算好未来一年排班缓存 ZDT, TZDB 更新后全错 // 对: 只存 Instant 或"挂历+ZoneId", 渲染时再换算 // 订阅 TZDB 变更公告, 重算任务可重跑