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.5。toSeconds() 取出 -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 返回非负间隔;需要判断谁早谁晚,应使用 isBefore、isAfter,不能依赖它的正负号。
使用场景:检查请求时间的前后偏差
校验请求时间与服务器时间的间隔,可以这样调用:
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 后端的实用代码,讲清写法、原理和使用时要注意的细节。