Java日期格式化全解析:SimpleDateFormat与DateTimeFormatter

2026-09-30 23:16:2428 阅读量

写日期格式化这个主题,我想先从一张截图说起。上个月我帮朋友排查一个线上问题,他负责的报表系统突然有一天所有时间字段都对不上,数据库里存的是 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 ,就会输出类似的离奇结果)。

Java日期格式化全解析:SimpleDateFormat与DateTimeFormatter

排查思路非常朴素:先确认数据库原始值有没有问题,如果数据库里存的时间是对的,那就是格式化环节出错了。然后逐个检查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带偏移格式。这套习惯帮我少加了很多班,也希望能帮你省下一些本来要花在排查上的时间。

本文地址:https://www.limyvps.com/news/26_230238.html