云计算百科
云计算领域专业知识百科平台

Java 时区与夏令时实测:630 万分钟扫描下的 238 天偏移错钟、60 个重叠分钟两代 API 差 1 小时与 getTransitions 停在 2008 年

后端这篇只讲一件事:一年只发生两次的夏令时切换,会把 Java 后端里四类极常见的写法——定时排期、时长统计、幂等键、时区配置——在什么条件下悄悄做错,以及怎么用不到十行代码在上线前把它们测出来。

适合谁读:写过「每天 02:30 跑一次」这类排期、给 Agent 工具调用做过去重、或者同一套服务里既按 UTC 又按当地时区出报表的同学。不需要提前了解 tzdb,只需要用过 LocalDateTime。

能收获什么:一张「六种校验写法分别能挡住哪一半问题」的实测矩阵、四条可以直接抄进代码库的判据写法、一份在当前目录用两条命令就能复跑且三次输出逐字节一致的实验。

时间锚点:据网易新闻 2026-11-02 与腾讯新闻 2026-11-01 的报道,美国夏令时在当地时间 2026 年 11 月 1 日凌晨 2 点结束,时钟从 02:00 拨回 01:00,这一天多出 1 小时;按现行联邦规则,夏令时每年 3 月第二个周日开始、11 月第一个周日结束,下一次开始是 2027 年 3 月 14 日;《阳光保护法案》2026 年 7 月已在众议院以 308 票对 117 票通过,但卡在参议院,所以 2026 年 11 月照常调钟。欧洲大部分地区则在 2026 年 10 月 25 日结束夏令时。以上为媒体报道,本文不讨论立法走向。

报道里的立法文本我没法在本机核对,但 JDK 自带一份 tzdb,那才是我代码真正依赖的东西。于是我把这几个日期当输入,在本机做了 6,307,200 次逐分钟判定(12 个时区 × 一年 525,600 分钟),顺带把排期、解析、校验、格式化四类写法全部跑了一遍。结论是:我的 tzdb 实测数据与报道的两个日期完全吻合,而代码里那些「看起来没问题」的写法,在切换日确实会错,且错得静悄悄。

一、实测环境与口径:哪些数字会随环境变

先把口径钉死,否则下面所有表格都没有意义。下表全部是本机实采,不是从任何旧文章复制的。

项本机实采值
java -version 17.0.18 2026-01-20 LTS(build 17.0.18+8-LTS-264)
javac -version 17.0.18
java.vm.name Java HotSpot(TM) 64-Bit Server VM
os.name / os.version / os.arch Windows 11 / 10.0 / amd64
file.encoding / native.encoding GBK / GBK(所以实验程序一律只输出 ASCII,避免解码问题)
availableProcessors 16
ZoneId.getAvailableZoneIds().size() 604
随机 seed 20261102(只用于抽样打印哪一条样本,不影响任何统计数字)
实验材料 全部由本程序在当前目录生成,未改动 JDK 安装目录与任何系统配置

关于可复现性,需要区分三类数字:

  • 规则类(切换时刻、空洞分钟数、重叠分钟数、漏跑槽数、错钟天数):只取决于 JDK 内置 tzdb,与 CPU、内存、负载无关,换机器也一样。
  • 构造类(360 次请求压成 240 个键这类):完全由步数和公式决定,是确定性的。
  • 计时类:本文刻意为之,一个都没有。程序末尾打印的各阶段耗时只用于说明跑得快,不参与任何结论。

我的复现判定标准写在第十二节,共四条;三次复跑除实时钟那一行外,190 行输出逐字节一致。本机只装了一套 JDK 17.0.18,因此全文不做跨版本对拍,也不声称 JDK 8/11/21 会得到同样的数字。

二、630 万分钟扫描:全年只有 9 个区调钟,切换点并不都在凌晨 2 点

我没有直接相信「美国凌晨 2 点调钟」这个常识,而是用两种互相独立的方法对拍:一种把 ZoneRules.getValidOffsets(LocalDateTime) 在一年里逐分钟问一遍(返回空列表即空洞、返回两个偏移即重叠),另一种从 getTransition() 拿权威切换记录。两种方法对 12 个时区给出完全一致的结论。

时区空洞分钟重叠分钟空洞落在本地重叠落在本地切换时长下一次切换(实测)
America/New_York 60 60 02:00..02:59 01:00..01:59 ±3600s 2027-03-14T02:00 -05:00>-04:00
America/Chicago 60 60 02:00..02:59 01:00..01:59 ±3600s 2027-03-14T02:00 -06:00>-05:00
America/Denver 60 60 02:00..02:59 01:00..01:59 ±3600s 2027-03-14T02:00
America/Los_Angeles 60 60 02:00..02:59 01:00..01:59 ±3600s 2027-03-14T02:00
America/Havana 60 60 00:00..00:59 00:00..00:59 ±3600s 2027-03-14T00:00
Europe/London 60 60 01:00..01:59 01:00..01:59 ±3600s 2027-03-28T01:00 Z>+01:00
Europe/Berlin 60 60 02:00..02:59 02:00..02:59 ±3600s 2027-03-28T02:00
Australia/Lord_Howe 30 30 02:00..02:29 01:30..01:59 ±1800s 2027-04-04T02:00 +11:00>+10:30
Pacific/Chatham 60 60 02:45..03:44 02:45..03:44 ±3600s 2027-04-04T03:45
America/Phoenix 0 0 – – 0 nextTransition 返回 null
Europe/Moscow 0 0 – – 0 nextTransition 返回 null
Asia/Shanghai 0 0 – – 0 nextTransition 返回 null
合计 510 510 扫描总次数 6,307,200(12 × 525,600)

三个和直觉不符的点,都是实测出来的:

第一,切换点不在凌晨 2 点的区真实存在。古巴哈瓦那的空洞和重叠都落在 00:00..00:59,也就是说「每天 00:00 跑日结」这条排期在哈瓦那会正好撞在空洞上;查塔姆群岛因为用的是 +12:45 这种 45 分钟制偏移,切换窗口是 02:45..03:44。只按「凌晨 2 点」写特判的代码,在这些区一律失效。

第二,霍厄岛的切换只有 30 分钟。Australia/Lord_Howe 全年空洞 30 分钟、重叠 30 分钟。这条直接否决了「按 1 小时粒度做保护」的写法——它连一个整点都不覆盖。

第三,不调钟的区不是「没有历史」。Asia/Shanghai 的 tzdb 里有 29 条切换记录,最后一条落在 1991 年,之后 nextTransition() 返回 null;Phoenix 的 11 条记录停在 1967 年。这从机器侧印证了报道里「中国 1986 至 1991 年实行夏时制、1992 年起暂停」的说法,但更重要的是:判断一个区未来会不会调钟,要看 nextTransition() 是不是 null,而不是看它有没有历史。

三、查未来调钟不能枚举 getTransitions():它在纽约只走到 2008 年

第二节的第一版代码里,我用的是一种看起来很自然的写法:把 rules.getTransitions() 全量取出来,过滤出落在 2026 年的切换。结果12 个时区全部返回 0 条,而逐分钟扫描明明发现了 510 个空洞分钟。两个数字相互矛盾,说明错在实验台,而不是 JDK 忽然不发夏令时了。

时区getTransitions() 条数最后一条落在2026 年内命中分钟扫描发现空洞
America/New_York 177 2008 0 60 分钟
America/Chicago 177 2008 0 60 分钟
America/Denver 99 2008 0 60 分钟
America/Los_Angeles 127 2008 0 60 分钟
America/Havana 110 2014 0 60 分钟
Europe/London 161 1997 0 60 分钟
Europe/Berlin 63 1997 0 60 分钟
Australia/Lord_Howe 59 2009 0 30 分钟
Pacific/Chatham 73 2009 0 60 分钟
America/Phoenix 11 1967 0 0 分钟
Europe/Moscow 76 2014 0 0 分钟
Asia/Shanghai 29 1991 0 0 分钟

换用 getTransition(LocalDateTime)(对单个本地时间问“你落在哪次切换上”)与 nextTransition(Instant) 后,两个切换当场就出来了:New_York 命中 2026-03-08T02:00 GAP -05:00>-04:00 与 2026-11-01T02:00 OVL -04:00>-05:00,与新闻报道完全一致。

结论不是「getTransitions() 有 bug」,而是它的语义跟我假设的不一样:它给的是已经固定下来的切换表,后面的重复规则不会预先展开。表格最后一列停在 1997、2008、2009、2014 各不相同,完全取决于该地最后一次“非规则化”调整是哪一年。因此:

  • 要“未来某天会不会调钟”,用 nextTransition() / previousTransition();
  • 要“这个本地时间是不是异常”,用 getValidOffsets(ldt).size();
  • 要“今年全年有几个异常小时”,用逐分钟或逐槽扫描,别拿历史表凑答案。

这个坑的实际危害在于:如果你用枚举历史表的方式生成「全年需要特殊处理的时刻清单」,在美国四个主要时区上会得到一张空清单,然后这些漏到线上的切换会在生产环境首次运行到 11 月 1 日时才暴露。

四、把偏移写死成 UTC-5,2024 到 2030 每年稳定错 238 天

另一个常见写法是“美国东部不就是 UTC-5 吗”,直接用 ZoneOffset.ofHours(-5) 或 GMT-5 而不用 ZoneId。我把两种写法对同一个本地时间(每天 09:30)转出的瞬间逐个对比,共跑 7 年。

年份该年天数写死 -05:00 与真实区不一致的天数累计错钟小时实测 DST 天数写死 -04:00 不一致天数
2024(闰) 366 238 238 238 128
2025 365 238 238 238 127
2026 365 238 238 238 127
2027 365 238 238 238 127
2028(闰) 366 238 238 238 128
2029 365 238 238 238 127
2030 365 238 238 238 127

两个值得记住的观察:错误天数与闰年无关(2024、2028 多出的那一天并不在切换窗口里,所以一直是 238);以及错误率高达 65%(238/365),不是“一年就错两天”那么温和——真正只有两天的是一天 23/25 小时那种口径错(见第六节),而固定偏移是 238 天全部偏 1 小时。

最后一列是另一个角度的实测:把“永久夏令时法案如果生效”简化成“全年写死 -04:00”,那么错的天数变成 127(闰年 128)。也就是说:换制度不会把这类代码变正确,只会把错的日子换一批。真正的修法是用 ZoneId,而不是调那个数字。

在切换当天现场看更直观(New_York,固定 -05:00 对比真实区):

本地时间该时刻合法偏移写死 -05:00 得到的瞬间真实区得到的瞬间
11-01 00:30 [-04:00] 05:30Z 04:30Z(差 1 小时)
11-01 01:30 [-04:00, -05:00] 06:30Z 05:30Z(差 1 小时)
11-01 02:30 [-05:00] 07:30Z 07:30Z(对)
11-01 03:30 [-05:00] 08:30Z 08:30Z(对)

回拨日凌晨 00:30 这个看似“安安全全不存在歧义”的时刻,因为当时还在夏令时,写死 -05:00 同样错 1 小时。重叠小时里只有后半截能“碰巧对”,前半截必错。

五、同一个时间字符串,两代 JDK API 给出差 1 小时的两个瞬间

这是本次实验里我最不愿意看到、但确实跑出来的结果:SimpleDateFormat 和 java.time 对重叠小时内的解析,系统性地差整 1 小时。我把 2026 年纽约全部 60 个空洞分钟和 60 个重叠分钟逐分钟拿去问两个 API。

样本本地时间SimpleDateFormat(NY)解析java.time atZone 解析是否一致
2026-03-08 02:30:00(空洞) 1772955000000(= 03:30 EDT) 2026-03-08T07:30:00Z 一致
2026-11-01 01:30:00(重叠) 1793514600000(= 06:30Z,-05:00) 2026-11-01T05:30:00Z(-04:00) 差 60 分钟
2026-11-01 00:30:00(正常) 1793507400000 2026-11-01T04:30:00Z 一致
2026-06-15 12:00:00(正常) 1781539200000 2026-06-15T16:00:00Z 一致

把 120 个分钟全部跑完,结论非常干净:

时区重叠分钟数两 API 一致两 API 分歧第一个分歧样本差值
America/New_York 60 0 60(100%) 2026-11-01 01:00:00 60 分钟
America/Havana 60 0 60(100%) 2026-11-01 00:00:00 60 分钟
Europe/Berlin 60 0 60(100%) 2026-10-25 02:00:00 60 分钟
Australia/Lord_Howe 30 0 30(100%) 2026-04-05 01:30:00 30 分钟
Pacific/Chatham 60 0 60(100%) 2026-04-05 02:45:00 60 分钟
空洞分钟(同样 60 个):两个 API 一致 60 / 分歧 0——两者都把不存在的本地时间往后搬 1 小时。

String s = "2026-11-01 01:30:00"; // 重叠小时内,合法但歧义
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
sdf.setTimeZone(TimeZone.getTimeZone("America/New_York"));
long oldMs = sdf.parse(s).getTime(); // 1793514600000 -> 01:30-05:00(标准时)

LocalDateTime ldt = LocalDateTime.parse(s.replace(' ', 'T'));
long newMs = ldt.atZone(ZoneId.of("America/New_York")).toInstant().toEpochMilli(); // 1793511000000 -> 01:30-04:00(夏令时)

System.out.println((newMs – oldMs) / 60000); // -60,整整一小时
System.out.println(ZoneId.of("America/New_York").getRules().getValidOffsets(ldt)); // [-04:00, -05:00]

为了确认这不是谁算错了,我又用 GregorianCalendar 反向验证:同一组年月日时分,DST_OFFSET=0 得到的毫秒数 1793514600000 与 SimpleDateFormat 完全相等,DST_OFFSET=3600000 得到的 1793511000000 与 java.time 完全相等。两套实现各自都是自洽的,只是选了两个都合法的偏移里的不同那一个。

更麻烦的坑在于回归测试:SimpleDateFormat 自己的 format/parse 回环在这 120 个分钟上几乎完美(96 个样本中只有 2 个空洞分钟回环后变成 03:00/03:30,重叠小时全部自洽)。也就是说,只测单一 API 的单元测试根本发现不了这个差异,它只在“旧代码与新代码共存”或“跨服务传字符串”时才会响。典型事故路径:上游用 java.time 产生字符串,下游用旧映射层解析,重叠小时内同一份日志里两个时间戳对不上。

六、排期实测:按 24 小时步进一年漂 238 天,按墙钟排期漏 4 个槽

6.1 两种步进方式:一种保本地时刻,一种保时长

把同一条“每天 00:00”的排期用两种方法跑完整 2026 年:

步进方式触发次数落在本地 00:00偏离首次偏离日最后一次偏离日
instant.plus(Duration.ofHours(24)) 365 127 238(65.2%) 2026-03-09 2026-11-01
zonedDateTime.plusDays(1) 365 365 0 – –

固定 24 小时步进全年只有 127 次在午夜,另外 238 次全部跑到本地 01:00(直方图实测:{00:00=127, 01:00=238})。这不是 1 小时的随机抖动,而是从 3 月 9 日到 11 月 1 日连续 238 天稳定偏 1 小时:业务上表现为“夜间批次跟上游日切错开 1 小时”,而且只在夏令时期间出现,监控上极难归因。

反过来,plusDays(1) 保住了本地时刻,但时长不再恒定:实测相邻两次触发中有 2 次不是 86,400 秒,分别是 2026-03-08 -> 03-09 = 82,800s(23 小时)与 2026-11-01 -> 11-02 = 90,000s(25 小时)。SLA 计时、计费周期、签名有效期这类“时长敏感”逻辑如果基于墙钟推算,就必须接受这两个数字。

6.2 墙钟排期自己也会丢任务

以 15 分钟一个槽位、全年 35,040 个槽位计数,空洞槽会被静默后搬,重叠槽只会被执行一次:

时区颗粒度名义槽数实际不重复瞬间数被合并掉的槽存在但排不出来的瞬间
America/New_York 1 分钟 525,600 525,540 60 60
America/New_York 5 分钟 105,120 105,108 12 12
America/New_York 15 分钟 35,040 35,036 4 4
America/New_York 整点 8,760 8,759 1 1
Australia/Lord_Howe 整点 8,760 8,760 0 0
Australia/Lord_Howe 15 分钟 35,040 35,038 2 2
Asia/Shanghai 任意 – – 0 0

这张表里最有价值的是两个边界:其一,受影响的槽位数与颗粒度成正比,粒度越细,一年里丢的越多(1 分钟粒度下丢 60 个);其二,霍厄岛在整点颗粒度下完全没事(0/0),但 15 分钟粒度下丢 2 个,因为它的切换只跳 30 分钟,不跨过任何整点。如果你的保护策略是“只在整点做特判断”,在这个区会 100% 漏掉。

另一个容易误判的地方:2026-11-01T01:30 这个本地时间在真实世界里发生两次(1793511000000 与 1793514600000 两个瞬间),但按墙钟排期只会得到 365 个不重复瞬间中的 1 个(实测 distinctInstants=365、distinctWallSlots=365、ambiguousDays=1)。“本地时间看起来每天只跑一次”并不等于“只应该跑一次”,需要跟业务确认的是:重叠小时内到底该跑 1 次还是 2 次。

6.3 时长统计口径:两种错误刚好抵消

把“一天多少小时”用本地时间相减与用瞬间相减对拍:

时区不一致的天数具体天全年 naive 小时全年真实小时年度总偏差
America/New_York 2 03-08 naive=24/real=23;11-01 naive=24/real=25 8,736 8,736 0
Australia/Lord_Howe 1 10-04 naive=24/real=23(23.5h 被 toHours 截断) 8,736 8,735 +1

全年总秒数实测 31,536,000,与 365×86400 完全相等——因为 3 月少掉的 1 小时在 11 月又还回来了。如果你依赖“全年总时长对不对”做健康检查,夏令时错误在这项校验上是隐形的;同理,23 小时日与 25 小时日在“小时数相减”口径下会完全抵消,尽管它们各自都错了。

另外,“一天 24 小时”也不是恒真:纽约等 9 个区里,每年固定有 1 天 82,800 秒、1 天 90,000 秒,剩下 362 天才是 86,400 秒(实测每区 364 个相邻日对)。

七、六种校验写法只有两种能全覆盖,而“100% 拦住”那个是假象

知道有坑之后,下一个问题是怎么把它拦在代码里。我把六种常见写法在纽约全年 60 个空洞分钟 + 60 个重叠分钟(共 120 个异常分钟)上全量跑了一遍,统计每种写法真正拦下了多少。

校验写法拦下空洞(/60)拦下重叠(/60)有120 中的覆盖率机制
V1 ldt.atZone(zone) 直接转 0 0 0% 空洞后搬、重叠取较早偏移,全部静默
V2 ZonedDateTime.ofStrict(ldt, offset, zone) 60 0 50% 只在偏移与本地时间不匹配时抛
V3 getValidOffsets(ldt).size() != 1 60 60 100% 显式分类:0 个是空洞,2 个是重叠
V4 getTransition(ldt) != null 60 60 100% 直接拿到切换对象,可区分 GAP/OVL
V5 DateTimeFormatter + ResolverStyle.STRICT(uuuu) 0 0 0% 120 个全部正常解析,不报错
V6 SimpleDateFormat.setLenient(false) 60 0 50% 空洞报 Unparseable date,重叠静默

三个反直觉结论:

一、ResolverStyle.STRICT 对时区完全无效。它约束的是字段组合能否解析成日期(比如 2 月 30 日),不约束“这个本地时间在该时区到底存不存在”。实测 120 个异常分钟全部成功解析,空洞分钟 2026-03-08 02:30 直接被解析成 2026-03-08T03:30-04:00。

二、ofStrict 只挡得住一半。空洞全部抛;而重叠小时里两个偏移都合法,只要你传的是其中之一就静默通过(实测拦下 0/60)。重叠本质上不是错误,是一个需模业务表态的歧义,因此任何“抛异常式”校验都天然管不了它。

三、旧 API 反而在空洞上更严格。setLenient(false) 的 SimpleDateFormat 对 60 个空洞分钟全部抛 ParseException,但打印出来的因果链只有一层:ParseException:Unparseable date: "2026-03-08 02:30:00",没有更深原因。

// 推荐的“异常分钟”判定:空洞与重叠分开处理,两者都不静默
ZoneRules rules = ZoneId.of("America/New_York").getRules();
List<ZoneOffset> offs = rules.getValidOffsets(ldt); // 1 正常 / 0 空洞 / 2 重叠
switch (offs.size()) {
case 1: return ldt.atOffset(offs.get(0)).toInstant();
case 0: ZoneOffsetTransition gap = rules.getTransition(ldt);
throw new IllegalStateException("不存在于当地日历: " + ldt
+ " 将搬至 " + ldt.plus(gap.getDuration())); // 显式告知,不自作主张
default: ZoneOffsetTransition ovl = rules.getTransition(ldt); // size()==2
return offs.stream().map(o -> ldt.atOffset(o).toInstant()).collect(Collectors.toList())
.get(policy == AMBIGUITY.FIRST ? 0 : 1); // 把选择写成配置
}

这里要诚实地记一次返工。上面表格的 V5 第一版结果是 120/120 全部抛错,看起来完美拦截。我当时的反应是“STRICT 怎么可能把 atZone 拦不住的东西全拦了”,就往里加了一个已知正常的对照样本 2026-07-01 12:00——结果它照样抛。真正的原因是第一版模式串写的是 yyyy-MM-dd HH:mm:ss VV,而 STRICT 下 yyyy 是 year-of-era,没有 era 就无法解析成日期,它对任何输入都抛。改成 uuuu 后,拦截归零(0/120),才是真实行为。完整因果链打印出来是三层:

DateTimeParseException: Text '2026-07-01 12:00:00 America/New_York' could not be parsed:
Unable to obtain ZonedDateTime from TemporalAccessor:
{YearOfEra=2026, DayOfMonth=1, MonthOfYear=7},ISO,America/New_York resolved to 12:00
<- DateTimeException: Unable to obtain ZonedDateTime from TemporalAccessor: … same fields …
<- DateTimeException: Unable to obtain LocalDate from TemporalAccessor: …
// 只取前两个字符判断会误以为是“时区非法”;最内层才是 year-of-era 缺 era

这条值得单独划一句:任何“检出率 100%”的校验,都必须先拿一个已知正常的样本做阴性对照;只看最外层消息也不行,要把 getCause() 一路追到最底层原因。

八、时区 ID 拼错和 setDefault 都不报错:8 个 ID 静默变 GMT,20/20 个静态格式化器永久冻结

先说 ID。我拿了 18 个“真实 ID + 长相接近的错误 ID”分别交给 TimeZone.getTimeZone(String) 和 ZoneId.of(String)。

输入 IDTimeZone 解成rawOffsetZoneId.of判定
America/New_Yorkk(多一个 k) GMT 0 抛 DateTimeException 静默变 GMT
america/New_York(小写开头) GMT 0 抛 静默变 GMT
America/NY、America/NewYork GMT 0 抛 静默变 GMT
Asia/Shangai(少一个 h) GMT 0 抛 静默变 GMT
Eastern GMT 0 抛 静默变 GMT
EST EST -5 抛 缩写只在旧 API 可用
US/Eastern、EST5EDT 原样 -5 接受 旧别名两边都认
Etc/GMT+5 Etc/GMT+5 -5 接受(-05:00) 符号是反的
Etc/GMT-5 Etc/GMT-5 +5 接受(+05:00) 符号是反的
GMT+8 GMT+08:00 +8 接受 仅 ID 被规范化
合计 18 个:旧 API 回读 ID 与输入不一致 9 个(其中硬落到 GMT 的 8 个、仅别名规范化的 1 个);ZoneId.of 抛异常 9 个。

这里发生过第三次返工,必须记下来。我本想用“两个 API 解出的偏移是否相等”作为拼写检测手段,第一次跑出的结果是 same=9 diff=9,样本长这样:America/New_York old=-4h new=0h。纽约不可能是 UTC+0——这个物理上不可能的数字说明错在测台:TimeZone.getOffset(long) 返回的是毫秒,而 ZoneOffset.getTotalSeconds() 返回的是秒,我两侧用了同一个 /3600000 除数,把 java.time 一侧全部除成了 0。改成各自单位后实测 same=18 diff=0。

修完之后的结论反而更重要:偏移对拍完全查不出拼错时区,因为旧 API 已经把错误 ID 静默换成 GMT,两边都是 0,对拍自然一致。真正可用的判据只有一条:比较回读 ID。

TimeZone tz = TimeZone.getTimeZone(inputId);
if (!tz.getID().equals(inputId)) { // 唯一可靠判据
throw new IllegalStateException("unresolved zone id: " + inputId + " -> " + tz.getID());
}
// 或者一开始就用 ZoneId.of(inputId),让拼错直接抛异常;启动时跑一次,不要等到线上

接下来说说默认时区。很多团队靠单测里 TimeZone.setDefault(…) 隔离时区依赖,实测它对已创建的对象无效:

对象setDefault(LA) 后输出改变恢复默认后
20 个预先 new 好的 SimpleDateFormat 0/20 20/20 仍与最初输出一致
20 个预先 Calendar.getInstance() 0/20(构造时 clone 了当时默认时区) 同上
20 个切换后新建的 SimpleDateFormat 20/20 变 20/20 变回
另一线程内新建的格式化器 看到新默认(跨线程可见,因为默认时区是进程级) –
Instant.now() / ZoneId.systemDefault() 前者不变(本来就是 UTC),后者立即变 LA –

所以:默认时区是进程级开关,不是线程级;而任何“在静态字段里初始化一次”的格式化器会永久停留在类加载那一刻的默认时区,setDefault 再改回来也救不回来。实测里那 20 个预建实例在两次切换之后仍输出 2026-11-01 21:30:00 CST,即它们从始至终用的都是最初的 Asia/Shanghai。

九、Agent 工程里的两个真实踩点:360 次请求被压成 240 个幂等键

时区问题到 Agent 工程上并不新鲜,但后果更直接:工具调用的去重键、会话 TTL、“最近 N 分钟”的上下文窗口、喂给模型的当前时间串,全部建在时间上。我拿幂等键做了个最小验证:以 30 秒为间隔产生 360 个请求,时间轴恰好跨过 2026-11-01 的回拨小时,分别用三种方式构键。

构键方式请求数不重复键撞键数重复请求数相邻字典序倒置
本地时间串 yyyyMMdd_HHmmss 360 240 120 120 1
epoch 毫秒 360 360 0 0 未单独测(键本身即瞬间)
本地时间 + 偏移 ID(如 01:30-04:00 / 01:30-05:00) 360 360 0 0 未单独测

3 小时真实时长只对应 2 小时本地墙钟,因此重叠半小时里的每个 30 秒槽位都被用了两次,120 个键仍重复。对一个“同一小时内同一工具+同一参数不重跑”的 Agent 工具锁来说,这就是直接的漏执行:两次真实不同的调用在锁看来是同一个。另外字典序只出现 1 次倒置(跳回的那一刻),但一次就足够把“取最大时间戳作为最新状态”的合并逻辑带错。

排序侧实测到的一个具体现象:把回拨前后 3 小时内的瞬间按顺序排出来,本地时间轴不单调,会出现 2026-11-01T01:45-04:00 后面跟 2026-11-01T01:00-05:00;而 01:30 这个本地时间串在 3 小时窗口里出现 2 次。如果你把工具调用 trace 按本地时间串排序做回放,重叠小时内的先后关系是反的。

// 幂等键:把“瞬间”拼进键里,而不是把本地时间串拼进键里
String keyVulnerable = toolName + "@" + LocalDateTime.now(ZoneId.of("America/New_York"))
.format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); // 实测 120/360 撞
String keySafe = toolName + "@" + Instant.now().toEpochMilli(); // 实测 0/360 撞
ZonedDateTime z = LocalDateTime.now(ZoneId.of("America/New_York")).atZone(ZoneId.of("America/New_York"));
String keyReadable = toolName + "@" + z.toLocalDateTime() + z.getOffset().getId(); // 可读且不撞,实测 0/360

还有一个组合坑,把第八节和第六节叠起来看就明白了:如果服务里有一个 static final DateTimeFormatter 在类加载时绑定了默认时区(实测 20/20 个预建格式化器不随 setDefault 改变),那么它发出的时间串与另一侧用 java.time 现取现算的串,在重叠小时内会差整 1 小时;同一套日志里两套时间戳、两个幂等键空间,查问题时对不上。这类问题不在任何单测里出现,因为每个 API 自己都是自洽的。

十、适用边界与风险提示

主动说清楚哪些结论只在什么范围内成立,以及哪些我根本没做。

  • 环境边界:本文所有数字来自单机单进程、当前目录、只执行 javac/java 的本机 JDK 17.0.18。未做跨版本对拍(本机只有一套 JDK),因此不能声称 JDK 8/11/21 会得到同样的切换年表。
  • 数据边界:“下一次切换”只实测到 nextTransition() 返回的一步(最早 2027-03-14)。再往后的日期是 tzdb 按规则推算,不是本文实测;tzdb 本身会随安全更新变化,旧 JDK 可能不认识最新调整。
  • “永久夏令时”那一列不是预测:第四节里写死 -04:00 的 127/128 天,只是“把制度变化简化成一个固定偏移”的算术对照,不构成对《阳光保护法案》结果的任何判断。
  • 未验证的调度框架:Quartz、Spring @Scheduled、XXL-JOB、Cron4j 各自的时区语义本文一律未测。第六节的两种步进方式是我手写的最小实现,只能证明“这两种语义会给出不同结果”,不能推断某个框架内置行为。
  • 未验证的系统层:NTP 跳变、容器里的 TZ 环境变量、/usr/share/zoneinfo 与 JDK 内置 tzdb 不同步、updatetz 类补丁场景,均未实测。
  • 未验证的并发边界:第八节只做了“另一个线程里新建的格式化器能看到新默认时区”的单点验证,不构成任何线程安全结论,也没做 setDefault 竞态压测。
  • 不要直接引用为线上指标:第九节的 360/240/120 是“用本地时间串做键 + 30 秒固定间隔”这一构造选择的直接推论;真实线上的撞键率取决于到达节奏、窗口长度与键粒度。
  • 风险与回退:把 atZone 改成 ofStrict 会把空洞时刻从“静默后搬”变成“抛异常”。对计费、风控、证书类有效期逻辑来说这是行为变更,建议先只加日志不抛错,在灰度环境观察一个完整切换周期(3 月与 11 月各一次)后再收紧。
  • 热点引用口径:文中两个外部日期(美国 2026-11-01 结束、2027-03-14 开始)来自网易新闻 2026-11-02 与腾讯新闻 2026-11-01 的报道,并由本机 tzdb 独立复核一遍;欧洲 2026-10-25 同样是本机实测与公开报道相互印证。众议院投票 308 对 117 是报道里的数字,本文未向第一方立法网站核实,不作为自己核实过的数据使用。

AI 辅助说明:实验程序与全部数据均在本机实跑,文中每个数字都能在第十二节的命令下复现得出;未填充任何估算或虚构的实验结果。选题与行文由作者整理,热点部分仅引用公开报道并已标注来源。

十一、完整复现:两条命令与六条判定标准

实验程是一个单文件 TzProbe.java,798 行、95 个输出行,不依赖任何外部包与测试材料:所有时间数据由 java.time 自己生成,所有循环在同一个 JVM 里完成,输出 192 行。它只写当前工作目录,不动 JDK 安装目录与系统配置。

javac -encoding UTF-8 -d out exp/TzProbe.java
java -cp out TzProbe

预期输出里必须出现下面六组数字。它们不依赖 CPU、内存与负载(程序里没有任何计时项参与结论),因此可以当回归断言用。

判定项应当等于对不上时先怀疑
① E1 TOTAL minutes=6307200 gapMin=510 overlapMin=510 且 E1 SELF-ASSERT-OK 6,307,200 / 510 / 510 年份常量是不是还是 2026;时区列表是不是漏了 12 个
② E1 表里 New_York 的 getTransitionsLastYear 2008 这是 API 语义而非数据错误;如果你把它当成“没有切换”,就重现了本文的第二次返工
③ E5e 两行 gap agree=60 disagree=0;overlap agree=0 disagree=60 你的默认时区不是 Asia/Shanghai 不影响(程内显式设了时区),但 JDK tzdb 版本不同会变
④ E8a 六行检出数 V1 0|0,V2 60|0,V3 60|60,V4 60|60,V5 0|0,V6 60|0 V5 如果全是“拦住”,先看 STRICT+yyyy 那条对照行是不是也抛了——那就是第三个坑
⑤ old-vs-new offset agreement same=18 diff=0 若出现 same=9 diff=9 且样本里有 new=0h,说明你在 getTotalSeconds() 上除了 3600000
⑥ 三次复跑 diff 除实时钟那一行外逐字节一致(190 行相同) 任何统计列不一致都意味着测台有跨场景污染,先拆成单场景重跑

复现时两个环境细节需要注意。一是输出编码:本机 file.encoding 是 GBK,而工具链往往按 UTF-8 解码,所以程内一律只输出 ASCII(包括时区名与 ISO 偏移串),中文只在本文里写。二是年份相关数字的“新鲜度”:如果把年份改成 2027,第四节的 238 会变(因为 3 月 14 日与 11 月 7 日之间的天数变了),这不是 bug。

十二、结论:三条可记住的判断,一个自检动作

第一条:调钟影响的不是“两天”,而是“大半年”。把偏移写死成 -05:00 在纽约每年错 238 天(占 65.2%);固定 24 小时步进排期每年偏 238 天;而 23 小时日与 25 小时日在“全年总时长”这一项上恰好抵消(实测 31,536,000 与 365×86400 完全相等)——这就是为什么年度健检发现不了它。

第二条:重叠小时内,JDK 两代 API 系规律地差整 1 小时。60 个空洞分钟两边 100% 一致(都后搬),60 个重叠分钟两边 0% 一致。它不会在任何单一 API 的自洽单测里出现,只会在“旧代码与新代码共存”、“跳服务传时间串”时响。

第三条:校验层只有显式问规则才能全覆盖。120 个异常分钟上,getValidOffsets().size() 与 getTransition() != null 是 120/120;ofStrict 与 setLenient(false) 只拦住空洞那一半(60/120);ResolverStyle.STRICT 与 atZone 一样,0/120。

建议今天就做的自检动作(5 分钟):在服务启动时加两行——第一行用 ZoneId.of(configuredZoneId) 解析配置里的时区并打印回读值,第二行用 getValidOffsets() 把下一个切换日前后 48 小时内的异常分钟扫出来打一条 INFO。然后对比你手上的 cron 表达式:如果它们落在打印出来的分钟里,就已经确认了风险。

两个问题想听听你们的做法:第一,你们服务里“按天”的计费、限流或上下文窗口,到底存的是 Instant 还是 LocalDateTime?夏令时切换那天它应该跑两次还是一次,代码里有注释写明吗?第二,你有看过自己那套 JDK 里 getTransitions() 的最后一年吗?如果运维脚本依赖它,今年 11 月 1 日会漏掉哪些任务?

本文的六张实测表(异常分钟分布、错钟天数、两 API 分歧、颗粒度丢槽、六种校验覆盖率、时区 ID 静默清单)适合直接收到代码评审清单里,下次碰到“11 月任务没跑”类工单时不必重新跑一遍实验。

赞(0)
未经允许不得转载:网硕互联帮助中心 » Java 时区与夏令时实测:630 万分钟扫描下的 238 天偏移错钟、60 个重叠分钟两代 API 差 1 小时与 getTransitions 停在 2008 年
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!