客服说:订单创建时间显示成了 1970 年
5 月 14 号早上,客服群里转过来一张用户截图,订单详情里的创建时间是 1970-01-01 08:00:00。我第一反应是数据库存了 0,查了一下,数据库里的时间戳完全正常。
那就是显示的时候格式化错了。去看那段代码:
@Service
public class OrderService {
// 为了"复用",把 SimpleDateFormat 提成了静态常量
private static final SimpleDateFormat SDF =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public OrderVO convert(Order order) {
OrderVO vo = new OrderVO();
vo.setCreateTime(SDF.format(order.getCreateTime()));
return vo;
}
}
我还记得写这行代码时的心理活动:老师说过"不要重复创建对象,浪费性能",所以把它提成 static final。看起来很规范,对吧。
复现:一模一样的异常
在本地用多线程跑了一遍:
public class SdfTest {
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(20);
CountDownLatch latch = new CountDownLatch(200);
Set<String> results = Collections.synchronizedSet(new HashSet<>());
for (int i = 0; i < 200; i++) {
pool.execute(() -> {
try {
Date d = new Date(1526236800000L); // 2018-05-14 00:00:00
results.add(SDF.format(d));
} catch (Exception e) {
results.add("EXCEPTION: " + e.getClass().getSimpleName() + " " + e.getMessage());
} finally {
latch.countDown();
}
});
}
latch.await();
pool.shutdown();
System.out.println("出现了 " + results.size() + " 种不同的结果:");
results.forEach(System.out::println);
}
}
同一个 Date,理论上只能有一种结果。实际输出:
出现了 7 种不同的结果:
2018-05-14 00:00:00
2018-01-05 00:00:00
0018-05-14 00:00:00
2018-05-14 00:00:14
2018-12-05 00:00:00
1970-01-01 00:00:00
EXCEPTION: NumberFormatException multiple points
月份和日期乱窜,年份被截成 0018,还有直接抛 NumberFormatException 的。用户看到的 1970 就是这么来的。
根因:共享的 Calendar 字段
翻开 SimpleDateFormat 的源码,它的父类 DateFormat 里有个 protected 字段:
public abstract class DateFormat extends Format {
protected Calendar calendar;
protected NumberFormat numberFormat;
}
SimpleDateFormat 的 format 方法会先把待格式化的时间"设置"到这个共享的 Calendar 上,再逐字段读取:
// SimpleDateFormat.format 内部(JDK 8 节选)
private StringBuffer format(Date date, StringBuffer toAppendTo, FieldDelegate delegate) {
calendar.setTime(date); // 第一步:把 calendar 设置成这次要格式化的时间
boolean useDateFormatSymbols = useDateFormatSymbols();
for (int i = 0; i < compiledPattern.length; ) {
int tag = compiledPattern[i] >>> 8;
int count = compiledPattern[i++] & 0xff;
switch (tag) {
case TAG_QUOTE_ASCII_CHAR:
toAppendTo.append((char)count);
break;
case TAG_QUOTE_CHARS:
toAppendTo.append(compiledPattern, i, count);
i += count;
break;
default:
subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
break;
}
}
return toAppendTo;
}
问题就在这:calendar.setTime(date) 和后面 subFormat 里读 calendar 字段的这一整段,没有任何同步措施。
多线程下会发生什么?线程 A 执行完 calendar.setTime(2018-05-14),还没来得及读取,线程 B 插进来执行了 calendar.setTime(1970-01-01)。等线程 A 继续往下读字段时,读到的就是 B 设置的值了。两个线程互相踩,于是月份、日期、年份各读各的,拼出一个不存在的日期。
NumberFormatException: multiple points 那个异常也是同理:SimpleDateFormat 内部用 DecimalFormat 格式化数字字段,而 numberFormat 同样是共享字段,两个线程同时往同一个 StringBuffer 里写数字,就写出了 2018.05.14 这种含两个小数点的串,解析时炸掉。
顺带一提,parse 方法也不安全,源码里能看到同样的 calendar.setTime() + 读字段模式,而且它还会先调 calendar.clear(),多线程下有概率把别人的中间状态清掉。
四种修法
方案一:每次 new(最简单,性能可接受)
public OrderVO convert(Order order) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
vo.setCreateTime(sdf.format(order.getCreateTime()));
}
老实说这是最省心的办法。我实测了一下,单次创建 SimpleDateFormat 大约 3.2 微秒,格式化一次大约 0.8 微秒。也就是说每次 new 会让耗时变成 4 倍——听起来很吓人,但绝对值只有 3 微秒,对于 QPS 几百的接口完全可以忽略。只有每秒几十万次的场景才需要考虑优化。
方案二:加锁或用 ThreadLocal(我们线上临时用的)
出事当天要先止血,我用 ThreadLocal 改的——每个线程一份实例,互不影响:
private static final ThreadLocal<SimpleDateFormat> SDF_HOLDER =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public OrderVO convert(Order order) {
vo.setCreateTime(SDF_HOLDER.get().format(order.getCreateTime()));
}
注意用 ThreadLocal 要留意两个点:一是 Web 容器(Tomcat)的线程是复用的,ThreadLocal 的生命周期跟着线程走,如果放的是大对象会有内存泄漏风险,SimpleDateFormat 不大,无所谓;二是如果线程池里的任务会被传递,ThreadLocal 的值不会跟着走。
方案三:DateTimeFormatter(最终方案)
JDK 8 引入的 java.time 包里,DateTimeFormatter 是不可变且线程安全的,可以直接定义成 static final 常量放心用:
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public OrderVO convert(Order order) {
LocalDateTime ldt = LocalDateTime.ofInstant(
order.getCreateTime().toInstant(), ZoneId.systemDefault());
vo.setCreateTime(ldt.format(FORMATTER));
}
为什么它线程安全?因为 DateTimeFormatter 内部没有任何可变的共享状态——解析和格式化时,它把上下文信息作为参数传递(DateTimePrintContext),而不是存在自己的字段里。这种"无状态"的设计从根上消除了并发问题。
用 200 线程跑一遍同样的测试,200 次结果全部一致,没有任何异常。
方案四:Apache Commons Lang 的 FastDateFormat
如果因为历史原因必须用 Date(比如公司的公共库接口写死了),可以用 commons-lang3 的 FastDateFormat,它是线程安全的:
private static final FastDateFormat FDF =
FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss");
内部也是靠不可变对象实现的,API 和 SimpleDateFormat 基本兼容,迁移成本低。
顺带修了数据库字段
改完代码我又回头查了一遍数据库,发现另一个隐患:t_order.create_time 用的是 timestamp 类型,MySQL 5.7 里 timestamp 的范围是 1970-01-01 到 2038-01-19,也就是所谓的 2038 年问题。虽然离得远,但我顺手把新表都改成了 datetime(范围 1000-9999 年)。老表不敢动,怕影响线上。
小结
这次的教训有两条。
第一,"提取成 static final 复用"这个优化动作本身是有前提的——被复用的对象必须线程安全。JDK 里明确标注了线程安全的类(如 String、Integer、DateTimeFormatter、ConcurrentHashMap)可以放心共享,没标注的(SimpleDateFormat、HashMap、ArrayList、Random)一律不要跨线程共享。想偷懒的时候去翻一下 javadoc,看有没有 "This class is thread-safe" 或 "not synchronized" 的字样。
第二,我后来给全项目做了一次扫描,把所有 static 的 SimpleDateFormat 找出来:
grep -rn "static.*SimpleDateFormat" --include=*.java src/
一共 11 处,全都改成了 DateTimeFormatter。这种坑不会在你测试的时候暴露,只会在并发上来之后随机出现,属于典型的"埋雷"型 bug。