SimpleDateFormat的线程安全事故:生产上的NPE是怎么来的
SimpleDateFormat的线程安全事故:生产上的NPE是怎么来的
适读人群:Java初中级开发者、有过SimpleDateFormat踩坑经历的后端工程师 | 阅读时长:约12分钟 | 文章类型:事故复盘+原理分析
开篇故事
这是一个堪称经典的线程安全事故。大概六年前,一个并发量不算高的系统,在某天下午请求量稍微上来一点(才几十个并发),日志里开始出现大量奇怪的报错:
java.lang.NumberFormatException: multiple points
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1061)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:267)
at java.lang.Double.parseDouble(Double.java:541)
at java.text.DigitList.getDouble(DigitList.java:168)
at java.text.DecimalFormat.parse(DecimalFormat.java:2056)
at java.text.SimpleDateFormat.subparse(SimpleDateFormat.java:2137)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1456)报错在日期解析里,但输入的日期字符串看起来完全正常。同一行代码,有时候正常,有时候报NumberFormatException,有时候得到一个莫名其妙的错误日期。
排查了很久,最后发现:一个工具类里有一个static的SimpleDateFormat,被多个线程共享使用了。
public class DateUtils {
// 就是这行代码,害了整个系统
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static Date parse(String dateStr) {
return SDF.parse(dateStr); // 多线程同时调用,出问题了
}
}修掉这行代码,问题消失。
一、SimpleDateFormat为什么线程不安全
内部状态是共享的
SimpleDateFormat继承自DateFormat,内部有一个Calendar字段:
// DateFormat源码
protected Calendar calendar;
protected NumberFormat numberFormat;parse()和format()方法在执行过程中,会反复修改这个calendar实例。
当多个线程同时调用同一个SimpleDateFormat实例的parse():
- 线程A正在往
calendar里设置年月日 - 线程B同时也在修改
calendar - 结果:calendar里的数据互相干扰,解析出错误的日期,或者抛出数值异常
这是最经典的竞态条件(Race Condition):多个线程同时修改共享的可变状态,没有任何同步保护。
为什么是静态的情况下才出问题
SimpleDateFormat本身的设计不是线程安全的,但只要每个线程独立创建自己的实例,就不会有问题:
// 这样是安全的,每次parse都创建新实例
public static Date parse(String dateStr) throws ParseException {
return new SimpleDateFormat("yyyy-MM-dd").parse(dateStr);
}问题出在把它定义为静态变量或单例,让多个线程共享同一个实例。
二、核心原理深挖
parse()执行过程中的状态修改
来看简化的SimpleDateFormat.parse()流程:
四种解决方案对比
| 方案 | 思路 | 优缺点 |
|---|---|---|
| 每次new | 每次调用创建新实例 | 简单,但频繁创建对象 |
| synchronized | 加锁串行化 | 安全,但高并发下成为瓶颈 |
| ThreadLocal | 每个线程独立持有一个实例 | 性能好,但需要注意内存泄漏 |
| DateTimeFormatter(推荐) | Java 8+ 不可变格式化器 | 线程安全,现代化,推荐 |
三、完整代码实现
代码一:四种修复方案的完整实现
package com.laozhang.trap.dateformat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
/**
* SimpleDateFormat线程安全问题的4种修复方案
*/
public class DateFormatSolutions {
// ===== 方案0:错误示范(线程不安全)=====
@Deprecated
static class WrongDateUtils {
private static final SimpleDateFormat SDF =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static Date parse(String dateStr) throws ParseException {
return SDF.parse(dateStr); // 多线程下出问题
}
}
// ===== 方案1:每次new(最简单,适合低频调用)=====
static class Solution1_NewEachTime {
public static Date parse(String dateStr) throws ParseException {
// 每次创建新实例,线程安全
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(dateStr);
}
public static String format(Date date) {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
}
}
// ===== 方案2:synchronized(简单安全,高并发下有性能瓶颈)=====
static class Solution2_Synchronized {
private static final SimpleDateFormat SDF =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static synchronized Date parse(String dateStr) throws ParseException {
return SDF.parse(dateStr);
}
public static synchronized String format(Date date) {
return SDF.format(date);
}
}
// ===== 方案3:ThreadLocal(性能好,注意线程池场景要remove)=====
static class Solution3_ThreadLocal {
private static final ThreadLocal<SimpleDateFormat> SDF_HOLDER =
ThreadLocal.withInitial(() ->
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); // 每个线程一个实例
public static Date parse(String dateStr) throws ParseException {
return SDF_HOLDER.get().parse(dateStr);
}
public static String format(Date date) {
return SDF_HOLDER.get().format(date);
}
// 注意:在线程池场景中,用完后必须remove,否则可能内存泄漏
public static void cleanup() {
SDF_HOLDER.remove();
}
}
// ===== 方案4:DateTimeFormatter(推荐,Java 8+)=====
static class Solution4_DateTimeFormatter {
// DateTimeFormatter是不可变的,天然线程安全,可以安全地共享静态实例
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
/**
* 解析日期时间字符串
*/
public static LocalDateTime parseDateTime(String dateStr) {
return LocalDateTime.parse(dateStr, FORMATTER);
}
/**
* 格式化为字符串
*/
public static String format(LocalDateTime dateTime) {
return dateTime.format(FORMATTER);
}
/**
* 如果需要Date类型(兼容旧代码)
*/
public static Date parseToDate(String dateStr) {
LocalDateTime ldt = parseDateTime(dateStr);
return Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
}
}
/**
* 并发测试:验证各方案的线程安全性
*/
public static void concurrentTest(int threadCount, Runnable task) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
AtomicInteger errorCount = new AtomicInteger(0);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
startLatch.await(); // 等待所有线程就绪
task.run();
} catch (Exception e) {
errorCount.incrementAndGet();
System.out.println("错误: " + e.getMessage());
} finally {
endLatch.countDown();
}
});
}
startLatch.countDown(); // 同时启动所有线程
endLatch.await();
executor.shutdown();
System.out.println("错误次数: " + errorCount.get());
}
public static void main(String[] args) throws Exception {
String testDate = "2024-01-15 10:30:00";
int THREAD_COUNT = 50;
System.out.println("=== 测试方案1(每次new)===");
concurrentTest(THREAD_COUNT, () -> {
try {
Date d = Solution1_NewEachTime.parse(testDate);
String s = Solution1_NewEachTime.format(d);
if (!testDate.equals(s)) throw new RuntimeException("结果不正确: " + s);
} catch (ParseException e) { throw new RuntimeException(e); }
});
System.out.println("=== 测试方案3(ThreadLocal)===");
concurrentTest(THREAD_COUNT, () -> {
try {
Date d = Solution3_ThreadLocal.parse(testDate);
Solution3_ThreadLocal.cleanup(); // 用完清理
} catch (ParseException e) { throw new RuntimeException(e); }
});
System.out.println("=== 测试方案4(DateTimeFormatter)===");
concurrentTest(THREAD_COUNT, () -> {
LocalDateTime ldt = Solution4_DateTimeFormatter.parseDateTime(testDate);
String formatted = Solution4_DateTimeFormatter.format(ldt);
if (!testDate.equals(formatted)) throw new RuntimeException("结果不正确: " + formatted);
});
}
}代码二:现代化日期工具类(推荐方案)
package com.laozhang.trap.dateformat;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Date;
/**
* 基于Java 8+ DateTimeFormatter的日期工具类
* 线程安全,功能完整,推荐在新项目中使用
*/
public class ModernDateUtils {
// DateTimeFormatter是不可变的,线程安全,可以定义为静态常量
public static final DateTimeFormatter DATETIME_FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static final DateTimeFormatter DATE_FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
public static final DateTimeFormatter TIME_FMT =
DateTimeFormatter.ofPattern("HH:mm:ss");
public static final DateTimeFormatter COMPACT_FMT =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/**
* 格式化 LocalDateTime
*/
public static String format(LocalDateTime ldt) {
return ldt == null ? null : ldt.format(DATETIME_FMT);
}
public static String formatDate(LocalDate ld) {
return ld == null ? null : ld.format(DATE_FMT);
}
/**
* 解析日期时间字符串
*/
public static LocalDateTime parseDateTime(String s) {
if (s == null || s.trim().isEmpty()) return null;
try {
return LocalDateTime.parse(s.trim(), DATETIME_FMT);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("日期格式不正确: " + s + ",期望格式: yyyy-MM-dd HH:mm:ss");
}
}
public static LocalDate parseDate(String s) {
if (s == null || s.trim().isEmpty()) return null;
return LocalDate.parse(s.trim(), DATE_FMT);
}
/**
* Date与LocalDateTime互转(兼容老代码)
*/
public static LocalDateTime toLocalDateTime(Date date) {
if (date == null) return null;
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
}
public static Date toDate(LocalDateTime ldt) {
if (ldt == null) return null;
return Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
}
/**
* 常用日期操作
*/
public static LocalDateTime now() {
return LocalDateTime.now();
}
public static LocalDate today() {
return LocalDate.now();
}
public static boolean isBefore(LocalDateTime a, LocalDateTime b) {
return a.isBefore(b);
}
public static boolean isInRange(LocalDateTime time, LocalDateTime start, LocalDateTime end) {
return !time.isBefore(start) && !time.isAfter(end);
}
public static void main(String[] args) {
System.out.println("当前时间: " + format(now()));
LocalDateTime dt = parseDateTime("2024-01-15 10:30:00");
System.out.println("解析结果: " + dt);
System.out.println("加一天: " + format(dt.plusDays(1)));
// 兼容旧代码的Date转换
Date legacyDate = toDate(dt);
LocalDateTime backToLocal = toLocalDateTime(legacyDate);
System.out.println("Date互转: " + format(backToLocal));
}
}四、踩坑实录
坑1:静态SimpleDateFormat在高并发下随机报错
报错现象:
java.lang.NumberFormatException: multiple points
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:...)
java.lang.ArrayIndexOutOfBoundsException: 3624
at java.text.DigitList.getDouble(DigitList.java:168)错误不固定,有时候是NumberFormatException,有时候是ArrayIndexOutOfBoundsException,有时候是返回错误的日期值(无异常但结果错)。这种"薛定谔的错误"就是并发竞态的典型特征。
根本原因:
静态SimpleDateFormat被多线程共享,内部Calendar状态被多线程同时修改。
具体解法:
换成DateTimeFormatter(Java 8+),或者用ThreadLocal,或者改为每次new。
坑2:ThreadLocal方案在线程池里内存泄漏
报错现象:
使用ThreadLocal方案一段时间后,内存占用持续增长,heap dump里有大量未被回收的SimpleDateFormat对象。
根本原因:
线程池里的线程不会被销毁,ThreadLocal里的值也就不会被GC。如果每个任务都设置了ThreadLocal但没有清理,每条线程的ThreadLocalMap里会积累越来越多的value。
// ThreadPool里的线程生命周期=应用生命周期
// 每条线程都有一个ThreadLocalMap
// 只要线程存在,SDF_HOLDER里的SimpleDateFormat就不会被回收具体解法:
// 使用try-finally确保执行完后清理
try {
Date result = Solution3_ThreadLocal.parse(dateStr);
// ...处理业务
return result;
} finally {
Solution3_ThreadLocal.cleanup(); // remove ThreadLocal
}或者直接换成DateTimeFormatter,彻底绕开这个问题。
坑3:format()方法也有线程安全问题
报错现象:
同样是并发场景,format输出的日期字符串错乱,比如"2024-01-15"出现了"2024-01-00"这样的值。
根本原因:
很多人只知道SimpleDateFormat的parse()有线程安全问题,但format()同样不安全。format()在执行中也会修改内部Calendar的状态。
具体解法:
format()和parse()都要同等对待,使用相同的线程安全方案。
五、总结与延伸
SimpleDateFormat的线程安全问题,在Java 8之前确实是一个高频坑。Java 8引入的DateTimeFormatter彻底解决了这个问题——它是不可变对象,所有方法都是无状态的,天然线程安全。
推荐的迁移策略:
- 新代码:直接用
LocalDateTime、LocalDate等JSR 310类型和DateTimeFormatter - 旧代码兼容:封装一个
ModernDateUtils,统一处理Date和LocalDateTime的互转 - 渐进式替换:找到所有
SimpleDateFormat静态变量,优先修复高并发调用路径
DateTimeFormatter的API设计比SimpleDateFormat友好得多,表达式也更丰富(支持时区、偏移量、本地化格式等)。如果还没有迁移的,趁这次彻底换掉。
