Java 时间处理的两个坑:单位误判,半秒算成一秒

Java 时间处理的两个坑:单位误判,半秒算成一秒

开篇

处理 Java 时间时,有两个容易忽略的坑:把毫秒时间戳当成秒,转换出的日期就会出错;计算时间差时,明明只差半秒,调换两个时间的顺序后,却可能算出一秒。下面用代码说明原因和避坑写法。

运行环境:JDK 21。本文讨论的时间 API 来自 JDK 的 java.time;示例已在 UTC 和 Asia/Shanghai 两个默认时区下验证,日期输出注释以 UTC 为准。

坑点

坑点一:毫秒被当成秒,日期跑到五万年以后

不能拿 Instant 的最大秒数当单位阈值,它比常见的毫秒值还大:

java 复制代码
// 错误示例:Instant的最大秒数不是区分秒和毫秒的阈值。
long timestamp = 1_700_000_000_000L;
long threshold = Instant.MAX.getEpochSecond();
// 1700000000000小于31556889864403199,会错误地走秒分支。
LocalDateTime wrong = LocalDateTime.ofInstant(
        timestamp < threshold
                ? Instant.ofEpochSecond(timestamp)
                : Instant.ofEpochMilli(timestamp),
        ZoneOffset.UTC);
System.out.println(wrong); // +55840-11-08T22:13:20

秒和毫秒都小于这个阈值,便会走同一个秒分支。日期被推到遥远未来后,如果时间检查只判断"当前时间减请求时间是否大于 30 秒",负差值还会漏过这条检查。

坑点二:半秒的时间差,取绝对值后却变成一秒

只想知道相差多久,很容易写成 Math.abs(Duration.between(a, b).toSeconds())。问题是,负的小数秒并不是向零截断:

java 复制代码
LocalDateTime early = LocalDateTime.of(2026, 9, 11, 12, 0);
LocalDateTime late = early.plusNanos(500_000_000);

System.out.println(Duration.between(early, late).toSeconds()); // 输出:0
System.out.println(Duration.between(late, early).toSeconds()); // 输出:-1

// 先取整秒再取绝对值,与先取时长绝对值再取整秒,结果不同。
Duration negative = Duration.between(late, early);
System.out.println(Math.abs(negative.toSeconds())); // 输出:1
System.out.println(negative.abs().toSeconds()); // 输出:0

Duration 保存秒数和非负的纳秒部分:

实际时长 秒数部分 纳秒部分
+0.5 秒 0 500000000
-0.5 秒 -1 500000000

负半秒表示为 -1 + 0.5toSeconds() 取出 -1,再取绝对值便得到 1。这符合 JDK 21 Duration 的存储与取秒约定,不是 JDK 算错了。问题在于先丢掉小数部分,再处理方向。

正确写法

避坑一:转换时间戳前,先约定识别范围

需要用同一个入口兼容常见秒、毫秒输入时,可以按明确的业务日期范围区分:

java 复制代码
    /**
     * 将Unix时间戳(秒或毫秒)转换为系统默认时区的LocalDateTime。
     * 按数值范围兼容常见业务时间戳:绝对值小于1000亿按秒,其余按毫秒。
     * 这是单位识别约定,不适用于任意日期:接近1970年的毫秒值可能被当作秒,
     * 绝对值达到1000亿的秒值会被当作毫秒。调用方须确认日期范围符合该约定。
     *
     * @param timestamp 时间戳,单位可能是秒或毫秒
     * @return 对应的 LocalDateTime 对象
     */
    public static LocalDateTime unix2LocalDateTime(long timestamp) {
        // 常见的10位秒值与13位毫秒值分属阈值两侧,负时间戳使用相同规则。
        final long THRESHOLD = 100_000_000_000L;

        // 直接比较正负边界,避免Math.abs(Long.MIN_VALUE)溢出。
        if (timestamp > -THRESHOLD && timestamp < THRESHOLD) {
            // 范围内按秒处理,零也进入此分支。
            return LocalDateTime.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault());
        } else {
            // 达到任一边界按毫秒处理,保留毫秒部分。
            return LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault());
        }
    }

正负边界直接比较,避免 Math.abs(Long.MIN_VALUE) 仍为负数的问题。毫秒分支直接调用 ofEpochMilli,不先除以 1000,保留毫秒精度。

这适用于日期范围符合约定的业务输入,不是任意日期的自动识别。 1000 可以表示秒或毫秒,这个方法按秒处理。接近 1970 年的毫秒值、绝对值达到 1000 亿的秒值,都有歧义;接口已约定单位时,仍须按协议传值。

下面的调用分别传秒和毫秒,输出在 JVM 默认时区为 UTC 时验证:

java 复制代码
LocalDateTime seconds = DateTimeUtil.unix2LocalDateTime(1_700_000_000L);
LocalDateTime millis = DateTimeUtil.unix2LocalDateTime(1_700_000_000_000L);
System.out.println(seconds.equals(millis)); // 输出:true
System.out.println(millis); // UTC 输出:2023-11-14T22:13:20
System.out.println(
        DateTimeUtil.unix2LocalDateTime(1_700_000_000_123L)); // UTC 输出:2023-11-14T22:13:20.123

避坑二:比较时间差,先排序再换算单位

只比较相差多久、不关心谁早谁晚时,先把较早时间放在前面。后续每个分支都计算正向间隔:

java 复制代码
    /**
     * 按指定单位计算两个时间的非负差值,参数顺序不影响结果。
     * 年、月按日期计算完整日历单位,其余按持续时间计算,舍去不足一个单位的部分。
     *
     * @param dateTime      - 第一个时间
     * @param otherDateTime - 第二个时间
     * @param unit          - 时间单位
     * @return 非负差值;时间相同或不足一个单位时返回0
     * @throws ArithmeticException 差值超出long范围(例如极端日期间的毫秒差)
     */
    public static long diff(LocalDateTime dateTime, LocalDateTime otherDateTime, ChronoUnit unit) {
        paramNotNull(dateTime, "dateTime");
        paramNotNull(otherDateTime, "otherDateTime");
        paramNotNull(unit, "unit");
        // 先按时间排序,避免负数截断和月份计算因参数顺序产生不同结果。
        if (dateTime.isAfter(otherDateTime)) {
            LocalDateTime temp = dateTime;
            dateTime = otherDateTime;
            otherDateTime = temp;
        }
        switch (unit) {
            case YEARS:
                return Period.between(dateTime.toLocalDate(), otherDateTime.toLocalDate()).getYears();
            case MONTHS:
                Period period = Period.between(dateTime.toLocalDate(), otherDateTime.toLocalDate());
                return period.getYears() * 12L + period.getMonths();
            case WEEKS:
                return Duration.between(dateTime, otherDateTime).toDays() / 7;
            case DAYS:
                return Duration.between(dateTime, otherDateTime).toDays();
            case HOURS:
                return Duration.between(dateTime, otherDateTime).toHours();
            case MINUTES:
                return Duration.between(dateTime, otherDateTime).toMinutes();
            case SECONDS:
                return Duration.between(dateTime, otherDateTime).toSeconds();
            case MILLIS:
                return Duration.between(dateTime, otherDateTime).toMillis();
            default:
                throw new IllegalArgumentException("Unsupported time unit: " + unit);
        }
    }

排序发生在单位转换之前,两个方向的半秒都得到 0 个完整秒,避免先取整再取绝对值的不对称。年、月也固定从较早日期向后计算。

只处理 Duration 时,duration.abs().toSeconds() 同样可以先消除方向。这里统一排序,是为了让年、月和其他单位遵循一致的参数顺序。

diff 返回非负间隔;需要判断谁早谁晚,应使用 isBeforeisAfter,不能依赖它的正负号。

使用场景:检查请求时间的前后偏差

校验请求时间与服务器时间的间隔,可以这样调用:

java 复制代码
        LocalDateTime callTime = DateTimeUtil.unix2LocalDateTime(req.getTimestamp());
        LocalDateTime now = DateTimeUtil.nowLocalDateTime();
        if (DateTimeUtil.diff(callTime, now, ChronoUnit.SECONDS) > 30) {
            return Resp.error(ErrorCode.INVALID_CALLER, "调用时间与当前时间相差30秒");
        }

过去 60 秒和未来 60 秒都会得到 60,满足 > 30。这只负责时间窗口检查,不能替代签名等其他鉴权。

用固定时间验证两个方向和半秒情况:

java 复制代码
LocalDateTime now = LocalDateTime.of(2026, 9, 11, 12, 0);

// 过去和未来60秒,都应超过允许的时间间隔。
System.out.println(
        DateTimeUtil.diff(now.minusSeconds(60), now, ChronoUnit.SECONDS) > 30); // 输出:true
System.out.println(
        DateTimeUtil.diff(now.plusSeconds(60), now, ChronoUnit.SECONDS) > 30); // 输出:true

// 相差半秒,调换参数后仍是0个完整秒。
LocalDateTime halfSecondLater = now.plusNanos(500_000_000);
System.out.println(DateTimeUtil.diff(now, halfSecondLater, ChronoUnit.SECONDS)); // 输出:0
System.out.println(DateTimeUtil.diff(halfSecondLater, now, ChronoUnit.SECONDS)); // 输出:0

同样的非负间隔也适合比较两条记录的时间距离。用于日志耗时或锁超时诊断时,要留意时钟回拨:它会隐藏时间倒退的方向,不能替代单调计时器。

使用前确认边界

  • 相同时间或不足一个单位,返回 0
  • 前后相差 30.999 秒,秒分支返回 30> 30 不拦截。这不是严格到毫秒的 30 秒窗口。
  • 系统默认时区改变,转换后的本地日期时间也会改变。LocalDateTime 本身不携带时区。
  • 年、月差值按日期计算,不按固定秒数折算;周及更小单位按本地时间的持续时间计算。
  • 极端日期的毫秒差超出 long 范围时,会抛出异常。

需要严格超时或跨时区比较时,还要明确精度和时间基准。非负差值不能解决时钟回拨和夏令时问题。

总结

秒、毫秒的兼容识别必须有日期范围约定。只比较时间间隔时,先消除方向,再转换单位,不要先取整再取绝对值。

测试除了整秒和当前时间,还要覆盖半秒、负数、阈值两侧及参数互换。这些输入更容易暴露单位和取整顺序的问题。


如果这篇文章对您有用,欢迎关注。后续会继续拆解 Java 后端的实用代码,讲清写法、原理和使用时要注意的细节。

相关推荐
黄骨鱼骨1 小时前
DatI:给AI接入数据库,Agent时代的NL2SQL问数与多维表格应该怎么做
java·database·text2sql·nl2sql·chatbi·mcp·data agent
hiahiahia1231 小时前
SSE 到底是什么?
前端·人工智能
Bigger1 小时前
我把苹果发布会里的折叠屏,真的用网页做出来了
前端·ai编程·交互设计
Wang's Blog1 小时前
Java框架快速入门: Spring Security+OAuth2之实现UserDetails与GrantedAuthority深度定制
java·spring·mybatis
萧鼎1 小时前
Python 数据验证神器 Pydantic:数据解析与验证、设置管理、JSONSche、与ORM集成全搞定
开发语言·python
haon11222 小时前
海外信贷的策略全生命周期:从准入到还款的本地化布防
大数据·开发语言·深度学习·面试·职场和发展·产品经理
linx2952 小时前
单元四 · 对称认知·上:内存与指针
c语言·开发语言·数据结构·嵌入式硬件·算法
Amberish2 小时前
26Java细节知识点总结
java
529宝宝起名网2 小时前
用 Python 开发名字字源查询工具:从甲骨文到楷书的字形演变与文化寓意解析
开发语言·python