昨晚我真是…困得要死还在看线上日志,你们懂吧,就是那种“明明只改了个小功能,怎么时间全乱了”的那种崩溃。起因特别蠢:一个接口返回“创建时间”,前端说同一条记录在列表里是 10:03,点进去详情变成 18:03。我们第一反应:时区呗。结果你猜怎么着,锅居然扣在java.util.Date身上……我当时还嘴硬:Date 不就是个时间点么,能有啥花活。然后脸就被打得啪啪响。
我先说个最常见的坑哈,Date 这个东西它长得像“时间”,但它没把“时区、日历系统、格式化”这些边界说清楚,你一旦把它当“业务时间”用,就会开始乱。比如你要表达“2026-01-31 00:00:00 北京时间”,你用 Date 其实表达的是一个 UTC 时间戳,至于展示成什么,全看你格式化时拿了哪个时区。然后项目里又经常是:本地开发机 GMT+8,测试环境机器可能是 UTC,容器里还可能继承宿主机,反正就是…你以为固定了,其实没固定。
还有个更阴间的:Date 是可变对象。对,可变。你传来传去以为是值,其实是个能被人改的“引用”。我就见过有人为了“省对象”,搞了个 static Date 缓存,结果并发下时间直接串了。类似这种:
import java.util.Date;
public class DateBugDemo {
// 你看着像常量,其实可变…
private static final Date SHARED = new Date();
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
SHARED.setTime(System.currentTimeMillis());
System.out.println("t1 -> " + SHARED.getTime());
sleep(10);
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
// 业务里可能是“给 Date 加 8 小时”这种骚操作
SHARED.setTime(SHARED.getTime() + 8L * 3600 * 1000);
System.out.println("t2 -> " + SHARED.getTime());
sleep(10);
}
});
t1.start(); t2.start();
t1.join(); t2.join();
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
}
你别笑啊,真实项目里就是这样发生的,只是更隐蔽:一个方法拿到 Date 后做setTime、setYear(老 API 还挺多这种),另一个方法以为 Date 还是原来的值,最后落库、发 MQ、打印日志,时间线像被狗啃过一样。
然后是第二波坑:Date 的“周边生态”太容易踩雷。你想格式化吧,很多人会写SimpleDateFormat,这货又是线程不安全的;你放到 static 里复用,跟上面 Date 一组合,直接双倍快乐。你说那我每次 new 一个呗,可以,但你性能又开始抖,线上高并发你就会看到 GC 抽风。反正就是两头不讨好。
第三波坑我觉得更要命:Date 很难表达“业务语义”。业务里经常不是“某个时间点”,而是“某一天”“某个本地时间”“某个时区的截止时间”。比如生日是“1999-05-20”,你用 Date 表示会变成一个带时区的时间点,跨时区一格式化就变前一天/后一天,用户直接来骂你。还有“每天 0 点清算”,你用 Date 表示那个 0 点,是哪个 0 点?夏令时那天甚至可能没有 02:00,或者重复一次,反正挺恶心的。
所以现在我在 Java 项目里基本就一句话:能不用java.util.Date就别用,除非你是跟遗留接口对接不得不用。新代码优先上java.time(JDK8+ 那套),它把“时间点/本地日期/本地时间/时区”分得很清楚,而且大多数类是不可变的,天然少很多坑。
举个你们天天写的场景:接口传入“开始时间、结束时间”,后端要查库。以前很多人 Date 一把梭:
// 旧写法的味道…你懂的
Date start = ...;
Date end = ...;
// 然后各种 + 24h - 1ms 之类的骚操作
换成java.time我一般这么写,语义清楚点:
import java.time.*;
import java.time.format.DateTimeFormatter;
public class TimeGoodDemo {
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
// 业务上就是“某天”
LocalDate day = LocalDate.parse("2026-01-31");
// 清算区间:当天 00:00:00 到 23:59:59(注意:更严谨可用 [start, nextDayStart))
ZonedDateTime start = day.atStartOfDay(ZONE);
ZonedDateTime nextDayStart = day.plusDays(1).atStartOfDay(ZONE);
System.out.println("start=" + start.format(FMT));
System.out.println("nextDayStart=" + nextDayStart.format(FMT));
// 真要落库 timestamp(UTC 时间点)就用 Instant
Instant startInstant = start.toInstant();
Instant endInstantExclusive = nextDayStart.toInstant();
System.out.println("startInstant=" + startInstant);
System.out.println("endInstantExclusive=" + endInstantExclusive);
}
}
你看这个写法就舒服:LocalDate表示“日历上的一天”,ZonedDateTime表示“带时区的本地时间”,Instant表示“时间戳”。每个东西都有自己的地盘,不会混。
还有个我特别爱用的小技巧:用Clock把“当前时间”从代码里抽出来。你们写测试是不是老在那new Date()、System.currentTimeMillis()然后 Mock 半天?换成这样:
import java.time.*;
public class ClockDemo {
private final Clock clock;
public ClockDemo(Clock clock) {
this.clock = clock;
}
public Instant now() {
return Instant.now(clock);
}
public static void main(String[] args) {
Clock fixed = Clock.fixed(Instant.parse("2026-01-31T00:00:00Z"), ZoneOffset.UTC);
ClockDemo demo = new ClockDemo(fixed);
System.out.println(demo.now()); // 测试里稳得一批
}
}
最后再说一句现实点的:很多框架、库、老接口还是会给你 Date,比如 JDBC、某些 SDK。那也没事,你在边界层做转换就行,别让 Date 在你业务域里乱跑。转换也很直白:
import java.time.Instant;
import java.util.Date;
public class ConvertDemo {
public static Instant toInstant(Date date) {
return date == null ? null : date.toInstant();
}
public static Date toDate(Instant instant) {
return instant == null ? null : Date.from(instant);
}
}
反正我现在的感觉就是:java.util.Date最大的问题不是“它不能用”,而是它太容易让人“以为自己用对了”。一开始看着省事,后面排查线上时间 bug 的时候,你会发现时间这种东西一乱,日志、监控、订单状态、对账,全都跟着乱…你想想那种半夜被拉群里,“为啥昨天的订单跑到今天了”,你还得解释“因为服务器是 UTC”…算了不说了,真顶不住。
对了,有人要是还在项目里到处new Date()当“业务时间”,你就把这段话甩他脸上(别太用力哈),不然迟早一起熬夜。然后…我先去喝口水,眼睛都睁不开了。