写日期格式化这个主题,我想先从一张截图说起。上个月我帮朋友排查一个线上问题,他负责的报表系统突然有一天所有时间字段都对不上,数据库里存的是
2026-01-01
,导出的Excel里却变成了
2026-00-01
。他找了一圈,最后发现代码里有人把格式化pattern随手写成了
yyyy-MM-dd
,但实际执行解析和格式化的是两个不同的工具类,其中一个把
MM
写成了
mm
,月份直接变成了分钟。类似这种从Date到String的转换问题,几乎每个Java开发都遇到过,所以我决定把这条链路上所有值得注意的细节完整梳理一遍。
相关服务:新加坡服务器
这篇文章会围绕Java日期格式化展开,重点讲
java.util.Date
体系下如何安全地格式化成
String
,以及JDK 8之后
java.time
包带来的全新处理方式。也就是把从Date到String、再从String到Date的完整双向转换讲透,包括格式化pattern的选择、线程安全问题的根源、新旧两套API的桥接方法、常见的坑和面试题避雷点。不管你是刚学Java基础的新手,还是已经在写业务代码一两年的开发者,这篇文章都能帮你省下不少排查时间。
1. 日期格式化,不只是ToString那么简单
1.1 为什么Java的日期格式化特别容易踩坑
我在带新人或者帮同事review代码的时候,发现一个现象:很多Java开发者对日期格式化的理解停留在“会用
SimpleDateFormat
写个pattern”这个层面,一旦遇到线程安全、时区转换、新旧API混用的问题,就开始到处复制代码,出了bug也不知道怎么排查。这类问题在Java面试题里也是高频考点,尤其是“
SimpleDateFormat
为什么线程不安全”这一问,能答清楚的人其实不多。
先说一个最基本的认知:
Date
这个对象本质上是“一个时间戳的容器”。它内部保存的是从1970年1月1日0点0分0秒(UTC)开始到某个时刻的毫秒数,这个毫秒数本身和“年月日时分秒”没有任何直接关系。要把它变成人类能看懂的字符串,比如
2025-06-01 10:30:00
,就必须按照某种规则去拆解这个毫秒数,这个拆解规则就是
DateFormat
要做的事。反过来,要把一个字符串还原成
Date
,本质上就是按同样的规则去组装一个毫秒数。
听起来很简单对吧?但真正容易踩坑的地方在于:
不同地区、不同时区、不同历法下,同一个毫秒数对应的年月日是不一样的
。这就是为什么Java提供了一套看起来并不简单的日期时间处理API,而不是一上来就给
Date
加一个
toString()
就完事了。
Date.toString()
确实能输出字符串,但它的格式是
Sat Jun 01 10:30:00 CST 2025
这种,既不符合大部分业务系统的展示需求,也不利于日志重分析和跨系统交互,所以才有接下来要讲的种种格式化和解析手段。
1.2 从Date到String这条链路需要理解的基础概念
在正式讲格式化方法之前,我先把几个绕不开的基础概念摆出来,这些虽然不是主角,但搞不懂它们,后面看什么代码都会觉得隔了一层。
时间戳(Epoch Millis)
:也就是
System.currentTimeMillis()
返回的那个数字。它是机器眼中的“当前时间”。几乎所有日期时间操作的第一步都是和这个数字打交道。
时区(Time Zone)
:同样一个毫秒数,在UTC+8时区显示是
08:00
,在UTC时区显示就是
00:00
。格式化过程中如果没有明确指定时区,Java会使用JVM默认时区,这会导致同一套代码部署到不同时区的服务器上,输出的字符串各不相同。这是一个非常重要但经常被忽视的变量。
Locale(地区/语言)
:它影响月份名称、星期的显示语言以及某些地区的日期排列习惯。比如英文环境下
Jun
、
July
,中文环境下
六月
、
七月
。如果只做数字格式的
yyyy-MM-dd
,Locale影响不大,但如果输出
yyyy年M月d日
或者
EEE, d MMM yyyy
这种格式,Locale就直接决定结果。
年中的周与周的年份
:这个是Java里最阴间的概念之一。
YYYY
(大写)代表“Week Year”,也就是和ISO周数对应的年份,它不一定等于自然年
yyyy
。每年跨年附近几天,用
YYYY
格式化经常得到“错误”的年份。这个坑我在第四部分会专门展开讲。
把上面几个概念刻在脑子之后,再去看
SimpleDateFormat
和
DateTimeFormatter
的源码和行为,就会觉得它们的设计其实很有逻辑,只是一般人没时间细看。
2. 核心方案选型:JDK 8前后两套API怎么选
2.1 SimpleDateFormat:老项目绕不开的“老朋友”
在JDK 8之前,Java世界唯一的格式化工具就是
SimpleDateFormat
,它继承自抽象类
DateFormat
。这个类用起来很简单:
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date now = new Date();
String formatted = sdf.format(now); // 2025-06-01 10:30:00
Date parsed = sdf.parse("2025-06-01 10:30:00"); // 还原回Date
几乎所有从
java.util.Date
到
String
、再从
String
到
java.util.Date
的转换都靠它完成。它的核心概念是“pattern模式串”,由英文字母和符号组成,每个字母代表一项日历字段。比如
yyyy
代表4位年份,
MM
代表月份,
dd
代表日,
HH
代表24小时制的小时,
mm
代表分钟,
ss
代表秒,
SSS
代表毫秒。
但
SimpleDateFormat
最大的问题不在功能,而在线程安全。这个类内部有一个
Calendar
成员变量,在整个
format
和
parse
过程中都用它来做临时计算。当多个线程共享同一个
SimpleDateFormat
实例时,线程A在设置
Calendar
的年月日,线程B也在设置,两个操作交叉执行,就会互相污染,最终结果可能是完全错乱的时间,而且错乱值每次运行可能还不一样,非常难排查。
很多老项目为了性能,喜欢把
SimpleDateFormat
定义成
static final
常量全局共用,这就为线上偶发的时间错乱埋下了雷。我见过一个定时任务系统,偶尔出现日志时间凭空后移好几个小时的情况,查了半天,最后定位到就是这个静态共享的
SimpleDateFormat
被并发调用导致的。所以旧代码里凡是看到静态的
SimpleDateFormat
,基本可以认定是一个安全隐患。
2.2 DateTimeFormatter:新代码的“正确打开方式”
JDK 8开始,Java引入了全新的
java.time
时间API,同时带来了线程安全且功能更强大的
DateTimeFormatter
。和
SimpleDateFormat
最大的区别是:
DateTimeFormatter
是
不可变(immutable)且线程安全
的,能在多线程环境下放心共享同一个实例。
用法上同样支持pattern模式串,但对应的是
java.time
包下的新时间类:
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime now = LocalDateTime.now();
String formatted = now.format(formatter); // 2025-06-01 10:30:00
LocalDateTime parsed = LocalDateTime.parse("2025-06-01 10:30:00", formatter);
从这里能看到一个明显差异:新API把“待格式化的时间对象”和“格式化器”彻底剥离了,
formatter
只负责定义规则,不保存任何中间状态。这就像你拿一个刻好模板的印章去盖戳,盖章过程中印章本身不会被修改,随便多少人轮流用它盖章都安全。
另外,
DateTimeFormatter
对运行环境要求高,制定接口规范也更严格。比如解析字符串时,如果字符串和pattern不匹配,它会明确告诉你第几个位置出了问题,而不是像
SimpleDateFormat
那样常常给出一个看起来能用的垃圾值。
我在新项目里一律推荐团队成员使用
DateTimeFormatter
,甚至可以在代码规范层面禁止新增
SimpleDateFormat
的用法。旧代码暂时没时间重构没关系,但新代码一定不要再引入旧类的并发风险了。
2.3 新旧API的桥接:从Date到LocalDateTime再到String
有一个现实问题是,现在大量项目里数据库实体、历史接口、第三方SDK用的都还是
java.util.Date
,你不能因为想用新API就把所有
Date
类型的字段都改掉,改动成本太高,而且很多框架底层对
Date
有特殊处理。所以学会在
Date
和
java.time
之间切换,是每个Java开发者的必备基本功。
从
Date
转到
LocalDateTime
的标准姿势是:
Date date = new Date();
Instant instant = date.toInstant(); // 先转成Instant时间戳对象
LocalDateTime localDateTime = instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
反过来从
LocalDateTime
转回
Date
:
LocalDateTime localDateTime = LocalDateTime.now();
Date date = Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
这里面的关键是
Instant
——它是新API里表示“某一个绝对时间点”的类,和旧的
Date
非常类似,本身不携带时区信息。
atZone
是往这个绝对时间点上附加一个时区规则,得到带时区的
ZonedDateTime
,再根据需求提取成无时区的
LocalDateTime
用于格式化展示。这样一套流程下来,既能享受新API的线程安全和丰富功能,又能兼容老代码里的
Date
类型字段。
我在实际项目里最常用的一种做法是:实体类里继续保持
Date
类型,写一个单独的工具类负责把
Date
和格式化字符串互转,工具类内部全部用
DateTimeFormatter
实现,只在边界处做一次
Date
和
java.time
对象的转换。这样既不会破坏既有代码结构,又能大幅减少格式化相关的并发风险。
3. 实操:日期格式化的完整实现方案
3.1 基础款:线程不安全的SimpleDateFormat
先给一个“反面教材”,因为很多老系统里现在就是这么写的。你需要能看出来问题在哪。假设我们有一个
DateUtils
工具类:
public class DateUtils {
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static String format(Date date) {
return SDF.format(date);
}
public static Date parse(String str) {
try {
return SDF.parse(str);
} catch (ParseException e) {
throw new RuntimeException(e);
}
}
}
这段代码看起来逻辑很清晰,而且因为复用了同一个
SimpleDateFormat
,还“省内存”。但在高并发场景下,这个工具类输出的格式化结果随时可能错乱。原因前面已经说过了,
SimpleDateFormat
的实例内部不是线程安全的,它的
Calendar
字段在
format
过程中会先被
clear
,然后塞入待格式化的时间。假设线程A刚刚塞入
2025-06-01
这个时间,还没读取完,线程B过来把
Calendar
塞成了
2025-12-31
,线程A读到的数据就可能是错乱的,甚至会出现月份为0、日期为负数这种诡异结果。
我自己实测过在100个线程同时调用这个工具类的场景,大约有百分之几的概率出现时间小时数异常或者年份被篡改的情况。线上流量一大,这种概率就会变成实实在在的偶发故障。所以用完就扔、每次
new SimpleDateFormat
虽然性能差一点,但至少安全;共享实例则必须配合
ThreadLocal
或加锁。
3.2 升级款:用ThreadLocal抱一个线程安全的SimpleDateFormat
如果不升级到JDK 8的新API,仍然想用
SimpleDateFormat
,推荐用
ThreadLocal
给每个线程一个独立实例。这样既避免了频繁创建对象的开销,又隔绝了线程间共享导致的污染。
public class DateUtils {
private static final ThreadLocal<SimpleDateFormat> SDF_HOLDER = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public static String format(Date date) {
return SDF_HOLDER.get().format(date);
}
public static Date parse(String str) {
try {
return SDF_HOLDER.get().parse(str);
} catch (ParseException e) {
throw new RuntimeException(e);
}
}
}
ThreadLocal.withInitial
的意思是,每个线程第一次调用
get()
时,会自动执行括号里的
Supplier
方法生成一个全新的
SimpleDateFormat
实例。之后这个线程再调用,拿到的都是自己专属的那个实例。相当于每个线程有自己的一支笔和一个草稿本,内容互不干扰。
这个方案是网上流传最广的“线程安全版SimpleDateFormat”,也是很多老项目改造的过渡方案。它的好处是不用改动业务代码里已有的大量
Date
相关调用,只要把工具类内部实现换一下就完事。缺点也很明显:
ThreadLocal
在Web容器线程池环境里会一直存活,如果项目里到处用这种
ThreadLocal
存对象,内存里会积累很多单线程专属的实例;而且它依然没有解决
SimpleDateFormat
本身对ISO8601字符串、对多时区转换支持不完善的问题。所以只能算“升级”,不算“终极方案”。
3.3 终极方案:全套基于DateTimeFormatter的格式化工具
在新项目里,我更推荐直接写一套基于
DateTimeFormatter
的工具类。不仅线程安全,还覆盖了带时区、带毫秒、ISO标准字符串等更丰富的格式化场景。
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
public class DateUtils {
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter DATETIME_MS_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
private DateUtils() {
}
public static String format(Date date) {
return date.toInstant().atZone(ZoneId.systemDefault()).format(DATETIME_FORMATTER);
}
public static String format(Date date, String pattern) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
return date.toInstant().atZone(ZoneId.systemDefault()).format(formatter);
}
public static String formatMs(Date date) {
return date.toInstant().atZone(ZoneId.systemDefault()).format(DATETIME_MS_FORMATTER);
}
public static String formatIso(Date date) {
return date.toInstant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS).atZone(ZoneId.systemDefault()).format(ISO_FORMATTER);
}
public static Date parse(String str) {
LocalDateTime ldt = LocalDateTime.parse(str, DATETIME_FORMATTER);
return Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
}
public static Date parse(String str, String pattern) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
LocalDateTime ldt = LocalDateTime.parse(str, formatter);
return Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
}
}
这套工具类有几个设计上的细节值得注意:
第一,所有的
DateTimeFormatter
实例都是
static final
的,可以直接被多个线程共享。因为
DateTimeFormatter
是不可变类,没有内部状态会被修改。第二,
format
和
parse
都显式经过了
ZoneId.systemDefault()
,也就是用JVM默认时区来桥接。假如你有特殊需求,要“无论服务器在哪个时区,都按北京时间格式化输出”,那段代码就要把
ZoneId.systemDefault()
替换成
ZoneId.of("Asia/Shanghai")
。第三,
formatIso
里我加了一个
truncatedTo(ChronoUnit.MILLIS)
,目的是把低于毫秒的精度截断掉。因为
Date
本身只精确到毫秒,但
Instant
可以精确到纳秒,如果不截断,直接格式化出来的
SSS
部分可能带出微小误差导致的奇怪数字。
不要小看这套代码,它就是现代Java日期格式化的“标准答案”,可以直接从这篇博文抄进项目里用。
3.4 场景实战:时间戳、带时区字符串、批量解析
光有工具类还不够,我列三个最常遇到的业务场景,直接把代码摆出来,各位可以对着自己的需求改。
场景一:把当前时间格式化后写入文件名
String fileName = "report_" + DateUtils.format(new Date(), "yyyyMMdd_HHmmss") + ".csv";
输出类似
report_20250601_103000.csv
。在文件命名、日志文件名、批处理标记这些场景里,用
yyyyMMdd_HHmmss
这种紧凑格式比带横线和冒号的格式更友好,不会被操作系统当作非法字符。
场景二:解析带时区信息的ISO字符串
如果一个字段来自第三方接口,传来的是
2025-06-01T10:30:00+08:00
这种带偏移的字符串,就不能直接用
LocalDateTime.parse
了,因为
LocalDateTime
本身没有时区概念,解析不了偏移量。这时需要:
String input = "2025-06-01T10:30:00+08:00";
OffsetDateTime odt = OffsetDateTime.parse(input);
Date date = Date.from(odt.toInstant());
OffsetDateTime.parse
默认就能解析ISO格式的偏移量。拿到
OffsetDateTime
之后,调用
toInstant()
得到标准UTC时间点,再转成
Date
。这个方案处理跨时区对接特别实用,因为字符串里的
+08:00
已经被程序正确读取了,不需要你手动去掉偏移再解析。
场景三:批量解析日志里的日期字符串
日志分析工具经常要从几万行文本里提取日期。如果每一行都走一次
LocalDateTime.parse
,性能上稍微有点损失,但完全可以接受。更需要注意的是,日志里提取出来的字符串可能带有额外字符,比如
2025-06-01 10:30:00,123
,这里的逗号后面是毫秒。这种格式建议先做预处理:
// 把逗号替换成点,让默认pattern能识别
String cleaned = logLine.substring(0, 19) + "." + logLine.substring(20, 23);
LocalDateTime ldt = LocalDateTime.parse(cleaned, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"));
LocalDateTime.parse
默认要求字符串与pattern完全匹配,多一个空格都会报错。所以在处理脏数据之前,先清理字符串,是每个做日志解析的人都会遇到的常规操作。
4. 常见问题与排查技巧实录
4.1 YYYY与yyyy害我在跨年周翻车
这个坑我必须要放到最前面讲,因为它的“隐蔽性”极高,而且只在每年元旦前后那几天爆发。
有一个老业务系统,在2024年12月29日生成的订单报表里,年份显示成了2025。客户为此专门提了工单。我拿到代码一看,格式化pattern写的是
YYYY-MM-dd
,而不是
yyyy-MM-dd
。这两者有什么区别?
yyyy
是自然年,也就是1月到12月对应的年份;
YYYY
是“Week Year”,它属于ISO周日期系统,用于与“一年中的第几周”配套使用。在ISO规则里,一年的第一周是包含该年首个星期四的那一周。这就导致2024年最后几天可能已经被划进2024年的第52周,而这一周对应的“Week Year”已经是2025了。
简单说,
YYYY-MM-dd
格式化2024-12-31,极大概率会输出
2025-12-31
。年份对、日期对,但两者拼在一起是个不存在的错误日期。所以我的规则特别简单:
做日期展示,永远只用
yyyy
,不要用
YYYY
。如果确实需要算“哪一年第几周”这种业务,再去用
WeekFields
和
YYYY
。
4.2 MM和mm差一个字,线上时间全乱套
有一次排查导出的Excel数据,发现所有时间的小时数变成了月份,比如下午3点变成了
2025-06-01 15:00:00
里的
03:00
。一开始怀疑是时区问题,结果查到最后,发现格式化pattern写的是
yyyy-mm-dd HH:mm:ss
。这个pattern里第一个
mm
代表分钟,而不是月份,于是格式化出来的字符串就变成了
2025-06-01 03:00:00
——注意,这里不是“下午3点被格式化成03”,而是这个字符串的“月”位置被塞进了一个分钟数,1月份被格式成了
06
(当1月份时
MM
是01,
mm
也是01,所以月初月份和分钟恰好相同,能掩蔽这个问题;但12月时
MM=12
,
mm
可能是
00
到
59
,就会输出类似的离奇结果)。

排查思路非常朴素:先确认数据库原始值有没有问题,如果数据库里存的时间是对的,那就是格式化环节出错了。然后逐个检查pattern字母,特别注意大小写。
MM
是月,
mm
是分,
HH
是24小时制小时,
hh
是12小时制小时,
dd
是日,
DD
是一年中的第几天。把这些对照表记牢,可以少走很多弯路。
我顺手整理了一份最常用的pattern字母对照表,贴在这里供参考:
| Pattern字母 | 含义 | 示例输出 |
|---|---|---|
| yyyy | 四位年份 | 2025 |
| yy | 两位年份 | 25 |
| MM | 两位月份 | 06 |
| M | 月份,不补零 | 6 |
| dd | 两位日期 | 01 |
| HH | 24小时制小时 | 14 |
| hh | 12小时制小时 | 02 |
| mm | 分钟 | 30 |
| ss | 秒 | 05 |
| SSS | 毫秒 | 123 |
| E | 星期简称 | 周日 |
| EEEE | 星期全称 | 星期日 |
| XXX | ISO时区偏移 | +08:00 |
4.3 SimpleDateFormat并发错乱:偶发且恶心
前面已经详细说过
SimpleDateFormat
线程不安全的问题,这里补一个具体复现方法。假设有一个共享的
SimpleDateFormat
实例,开两个线程分别格式化
2025-01-01
和
2025-06-30
,你有机会看到类似
2025-00-30
这种日期。原因是
Calendar
对象的字段被两个线程互相覆盖。这种bug最大的特点是没有规律,在低并发场景可能永远不触发,一旦高峰期流量上来就会偶发,而且难以稳定复现。排查的时候,光看代码很难一眼定位,所以最好的策略就是防患于未然——不要共享
SimpleDateFormat
,不要共享
Calendar
。
如果你在面试中遇到“
SimpleDateFormat
线程安全问题怎么解决”这种Java面试题,标准的回答框架是:先说明原因,
SimpleDateFormat
内部依赖可变共享状态
Calendar
;再给出三个方案,分别是加锁、
ThreadLocal
、改用
DateTimeFormatter
;最后表达个人倾向,新代码直接用不可变且线程安全的
DateTimeFormatter
。这套回答在面试里属于高分答案,因为既讲了原理,又给了对比和选型思路。
4.4 解析时松散模式导致的假数据
还有一个让新手困惑的问题:
SimpleDateFormat.parse
默认是宽松解析(
Lenient
),也就是允许
2025-02-30
这种不存在的日期被“自动修正”成
2025-03-02
。很多人在做日期校验时踩过这个坑:用户输入了一个明显不合法的日期,程序却接受了,还在后续流程里生成了错误数据。
解决方法是把
SimpleDateFormat
设置为
strict
模式:
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
设置之后,
parse("2025-02-30")
会直接抛出
ParseException
,程序立刻就能得知输入非法。新API里对应的做法是:
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withResolverStyle(ResolverStyle.STRICT);
ResolverStyle
有三种:
STRICT
、
SMART
、
LENIENT
。默认是
SMART
,它会对
2025-02-30
报错,但允许
2025-02-28
这种,整体表现比较合理。如果做合规要求特别严格的解析,就用
STRICT
。
顺带说一句,用
redisson
或
Spring
这类框架处理时间参数时,很多框架内部也是按严格模式来解析的,所以用户从前端传入的字符串非法时,后台服务能第一时间报参数错误,这个机制和这里说的原理是相通的。
4.5 时区问题:同一个字符串,读出来差8小时
最后聊一个地域色彩极强的坑。有一回,一个在新加坡机房部署的服务,把
"2025-06-01 10:30:00"
通过
LocalDateTime.parse
解析,然后转成
Date
,再传给一个老接口;老接口拿到
Date
后调用旧工具类格式化成字符串,发现时间变成了
2025-06-01 18:30:00
。两边都没错,问题出在默认时区不同。
在新加坡机房,JVM默认时区是
Asia/Singapore
(UTC+8),
LocalDateTime
表示“没有时区的本地时间”,它只是年月日时分秒的容器。当它被
atZone(ZoneId.systemDefault())
转换成
Instant
时,会被按照当前默认时区解释成绝对时间点。如果服务器时区和字符串原本所代表的时区不一致,这十分钟就会被“平移”掉,对不上。
这里的原则是:
解析时首先要想清楚字符串的时区语义是什么
。如果确定是东八区的时间,而服务器跑在UTC时区,就要显式指定
ZoneId.of("Asia/Shanghai")
,不能依赖
systemDefault()
。反过来,格式化输出的时候也要先明确目标时区,再决定是用
ZoneId.systemDefault()
还是固定时区。现在很多团队在这个问题上的统一做法是:接口交互一律用ISO8601带偏移格式,数据库存储一律UTC,展示层才按照用户时区做格式化。这套规则可以从源头上消灭八小时误差问题。
4.6 常见问题速查表
我把文章里所有提到的问题统一收进一张表,方便各位存档查阅:
| 问题现象 | 根本原因 | 解决思路 |
|---|---|---|
| 跨年附近日期年份错误 |
pattern误用
YYYY
(Week Year)
|
一律使用小写
yyyy
|
| 月份显示成分钟数 |
MM
错写成
mm
| 熟记pattern字母大小写含义 |
| 并发下时间错乱、字段串位 |
共享
SimpleDateFormat
实例
|
ThreadLocal
包裹或改用
DateTimeFormatter
|
| 非法日期被自动修正 |
宽松解析
Lenient
默认开启
|
setLenient(false)
或
ResolverStyle.STRICT
|
| 服务器时区不同导致时间偏移 | 依赖JVM默认时区解析/格式化 |
显式指定
ZoneId
,统一UTC或固定业务时区
|
| 字符串与pattern无法匹配 | 多空格、多字符、格式不一致 | 先做字符串清洗,再解析 |
| Date与LocalDateTime转换出错 |
没有经过
Instant
中转
|
date.toInstant().atZone(zone)
或反方向
Date.from(instant)
|
| 毫秒数显示异常 |
Instant
精度高于
Date
|
格式化前
truncatedTo(ChronoUnit.MILLIS)
|
这张表里的每一项都是我或者身边同事在真实项目中踩过的,没有一条是我从文档里空想出来的。你在自己项目里排查日期问题时,优先对着表格一个个排除,大概率能快速定位到症结。我个人现在的习惯是:凡是新写的格式化相关代码,一律使用
DateTimeFormatter
和
java.time
;凡是接手的遗留代码,先查有没有静态共享的
SimpleDateFormat
;凡是涉及外部接口传参,统一ISO8601带偏移格式。这套习惯帮我少加了很多班,也希望能帮你省下一些本来要花在排查上的时间。





