simpledateformat线程不安全因其内部calendar和numberformat状态可变,多线程并发调用parse/format会互相覆盖导致numberformatexception或日期错乱;正确方案是用threadlocal.withinitial()封装并显式设置时区、leniency,或升级至java 8+使用不可变的datetimeformatter。

SimpleDateFormat在多线程下为什么会抛java.lang.NumberFormatException或乱解析
因为SimpleDateFormat内部维护了可变的calendar和numberFormat状态,多个线程同时调用parse()或format()会互相覆盖中间结果。不是偶尔出错,而是只要并发稍高,就必然出现日期错位、年份变成0、甚至抛NumberFormatException——比如把"2023-01-01"解析成"2023-13-01"再转字符串时崩掉。
常见场景:Spring Controller里直接new一个SimpleDateFormat成员变量,或用static修饰后被多个请求共用。
- 别把它当工具类用,它不是
String.valueOf()那种无状态方法 - 哪怕只读不写(比如只调
parse()),也照样线程不安全——calendar.clear()和calendar.set()本身就是破坏性操作 - 加
synchronized能解决,但会严重拖慢吞吐,尤其在高并发格式化场景下
用ThreadLocal包装SimpleDateFormat的正确写法
核心是让每个线程持有一个独占的SimpleDateFormat实例,既避免共享又不用锁。但别直接写new ThreadLocal<simpledateformat>()</simpledateformat>然后重写initialValue()——容易漏掉时区、语言环境等配置,导致线上时间偏移或中文星期显示异常。
正确姿势是显式设置关键参数,并复用实例:
立即学习“Java免费学习笔记(深入)”;
private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT = ThreadLocal.withInitial(() -> {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
sdf.setTimeZone(TimeZone.getTimeZone("GMT+8")); // 必设!否则默认JVM时区
sdf.setLenient(false); // 建议关闭宽松解析,避免"2023-13-01"被强行转成2024-01-01
return sdf;
});
- 必须用
ThreadLocal.withInitial(),别用老式new ThreadLocal() { protected SimpleDateFormat initialValue() { ... } }——后者在Android或某些旧JDK上有兼容问题 -
setLenient(false)不是可选,是防止非法日期静默修正(比如"2023-02-30"变成"2023-03-02") - 别在每次使用前调
DATE_FORMAT.get().applyPattern(...)——会污染其他线程的pattern,且破坏ThreadLocal本意
为什么不用DateTimeFormatter(Java 8+)更省心
DateTimeFormatter是不可变对象,天生线程安全,不用ThreadLocal、不担心内存泄漏、API也更清晰。但直接替换可能踩坑:
-
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")默认用系统时区,而老SimpleDateFormat常设为GMT+8,不显式指定withZone(...)会导致时间差8小时 -
LocalDateTime.parse()不能处理带时区的字符串(如"2023-01-01T12:00:00+08:00"),得换用ZonedDateTime.parse()或OffsetDateTime.parse() - 如果项目还在用JDK 7,这条路走不通;JDK 8+建议新代码一律用
DateTimeFormatter,老代码逐步迁移
ThreadLocal没清理导致的内存泄漏真实表现
Web容器(如Tomcat)用线程池复用Worker线程,如果每次请求都往ThreadLocal里塞新SimpleDateFormat却不remove(),这个实例会一直挂在Thread对象上,GC无法回收——尤其当格式器里还持有TimeZone、DecimalFormat等大对象时,几天下来就OOM。
解决方案很简单,但极易被忽略:
- 在Filter或拦截器末尾调用
DATE_FORMAT.remove()(不是set(null)) - 如果用的是Spring MVC,更稳妥是在
@ControllerAdvice的@AfterReturning或@AfterThrowing里统一清理 - 千万别依赖
ThreadLocal的finalize()——它根本不会被及时触发
线程安全不是加个synchronized就完事,也不是套个ThreadLocal就高枕无忧。真正麻烦的是时区、leniency、内存生命周期这三块,哪一块漏了,线上就等着收报警。










