Java日期时间转换实战:Date/Long/时间戳与格式化全解析
2. 为什么要写这篇指南先说个真实场景。上周有个同事调接口前端传了个时间戳过来他拿new Date(Long.parseLong(str))一解析页面上的日期直接变成了 1970 年。排查了半天发现是前端把毫秒当秒传了后端拿到手就当成毫秒去构造中间差了整整 1000 倍。这种问题在 Java 开发里太常见了。日期时间处理看起来是个基础功但一到 Date、Long、时间戳、格式化这几个概念之间来回转换的时候总有人栽跟头。尤其是面试的时候面试官最喜欢问Date 转 Long 有哪几种方式long 类型的时间戳怎么格式化为什么 SimpleDateFormat 不建议用了这几个问题能答清楚的基础一般差不到哪去。这篇指南就是干这个用的。我用 Java 最常见的几种日期处理方式把 Date 转 Long、Long 转格式化字符串、时间戳的边界情况、线程安全问题全部串一遍每个场景都配了可直接抄的代码。适合正在准备面试的 Java 开发也适合写业务代码时被日期转换折磨过的朋友还有那些想把手里的老项目从java.util.Date往java.time迁移但不知道从哪下手的人。先说清楚这篇指南覆盖的范围从最原始的getTime()说起到LocalDateTime的转换再到时区问题、1970 时间戳的坑、线程安全、性能对比最后给一套我在项目中实际用的封装工具类。看完之后你遇到日期转换第一反应不再是百度而是自己能推出来。3. 先搞清楚 Date、Long、时间戳这三者的关系3.1 一句话理解时间戳时间戳说白了就是从 1970-01-01 00:00:00 UTC协调世界时到某个时刻经过的毫秒数。Java 里的System.currentTimeMillis()返回的就是这个值它是个long类型。你可能会问为什么偏偏选 1970 年这是 Unix 系统定的起点取了个整数时间方便计算。Java 沿用了这个约定所以Date.getTime()拿到的 longs 都是基于这个起点的毫秒偏移量。生活里类比一下时间戳就像从起点站开始计算的里程数给定一个数字就能推算出车开到了哪个位置。Date 对象则是一个具体的站点标识它内部其实就存着这个里程数只是包装了一层让你能直接看到年月日时分秒。3.2 Date 内部的本质java.util.Date这个类的内部实现核心就是一个long类型的fastTime字段存的是毫秒时间戳。你 new 一个Date()它内部就调用了System.currentTimeMillis()把当前时刻存下来。你调用date.getTime()它把这个字段返回给你。所以Date 转 Long这件事本质上是把你已经包装好的时间对象重新拆成原始数字。没有神秘的算法没有复杂的公式就是一个 getter 方法。这也就意味着任何能拿到 Date 对象的代码转 Long 都是同一套操作Date date new Date(); long timestamp date.getTime();简单到让人怀疑是不是漏了什么。对就是这么简单。真正有文章可做的是 Long 转回 Date、Date 转字符串、字符串转 Date 这些反向和跨类型的转换。3.3 为什么 long 比 Date 更适合传输和存储项目里接口传输、数据库存储我基本都用 long 时间戳不用 Date 对象。原因很简单long 是基本类型天然可序列化不需要额外处理。跨语言友好。前端 JavaScript、后端 Go、Python都认识 long 时间戳但 Date 对象序列化出来的格式每个语言都不一样。比较大小方便。两个 long 直接比数字大小就能知道时间先后Date 在 Java 里虽然实现了 Comparable但转成 long 之后一切从简。精度可控。业务上如果不需要毫秒直接除以 1000 存秒省空间。所以在实际的系统设计里我的习惯是数据模型层存 long接口传输用 long只有到展示层前端页面、日志输出、报表才转成格式化字符串。这样从源头避免了很多时区、格式扯皮的问题。4. 基础操作Date 和 Long 互转的五种姿势4.1 Date 转 Long常规操作与避坑最基础、最该背下来的代码import java.util.Date; public class DateToLongDemo { public static void main(String[] args) { Date date new Date(); long timestamp date.getTime(); System.out.println(当前时间戳毫秒: timestamp); } }输出类似当前时间戳毫秒: 1723000000000这一步没有任何坑只要 date 不为 null。唯一的坑就是 null 值如果你从数据库、缓存、接口里拿到一个可能为 null 的 Date直接调用 getTime() 会抛 NullPointerException。实际项目里一定要判空public static long dateToLongSafe(Date date) { return date null ? 0L : date.getTime(); }这里返回 0L 是业务约定你也可以返回 null看场景。但大多数时候返回 0 会让调用方以为是 1970-01-01 00:00:00容易造成数据错觉所以我更倾向于用 Optional 包装或者抛出业务异常。看团队规范不展开。4.2 Long 转 Date两种写法结果一样Long 转 Date 有两种等价的写法// 方式一new Date(long) long timestamp 1723000000000L; Date date1 new Date(timestamp); // 方式二Date.setTime(long) Date date2 new Date(); date2.setTime(timestamp);方式一更干净直接构造。方式二适合你已经持有了一个 Date 对象、想复用这个引用的情况虽然实际场景不多。这里有个面试高频题new Date(long) 和 setTime(long) 有什么区别答案是没有本质区别都是把内部的 fastTime 字段设置为传入的毫秒值。new 会新建一个对象setTime 是修改现有对象。仅此而已。要提醒一点使用 new Date(long) 时传进去的 long 如果超出合法范围小于 Long.MIN_VALUE 或大于 Long.MAX_VALUE 会被截断吗不会Date 内部会做限制超范围会变成对应的极端值比如 0 或者 Long.MAX_VALUE但实际业务里不会出现这种情况了解一下即可。4.3 秒级时间戳的坑1000 倍差距怎么来的这是面试必考也是开发中最高频的 bug 来源。很多外部系统返回的时间戳不是毫秒而是秒。比如某些支付回调、第三方开放平台、前端 JavaScript 里的Date.now()是毫秒但Math.floor(Date.now() / 1000)就是秒。如果你默认拿到的 long 是毫秒直接new Date(seconds * 1000)就会得到正确时间如果拿到的是秒直接new Date(seconds)得到的就是 1970 年附近的时间因为少了 1000 倍。判断标准很简单看数字的位数。当前时间大约是 17 亿秒或者 17 千亿毫秒1723000000000。所以10 位是秒例如 172300000013 位是毫秒例如 172300000000016 位是微秒很少见部分高精度系统用我在代码里写过一个通用处理工具入参先判断位数然后决定乘不乘 1000public static Date parseTimestamp(Object obj) { if (obj null) return null; long value; if (obj instanceof Long) { value (Long) obj; } else if (obj instanceof String) { value Long.parseLong((String) obj); } else { throw new IllegalArgumentException(不支持的时间戳格式: obj); } if (value 10000000000L) { value * 1000L; } return new Date(value); }这段代码的核心逻辑就是小于 10 位数100 亿说明是秒乘 1000否则按照毫秒处理。当然这个判断只适用于当前时代的合理时间范围如果是 2286 年时间戳就超过 13 位了但你基本碰不到这种场景。4.4 Date 和 Long 互转的常见坑位速查表场景问题解决方案Date 为 null调用 getTime() 抛 NPE判空返回默认值或抛业务异常Long 时间戳为 nullnew Date(null) 编译不通过判空用 Optional 或三目运算秒 vs 毫秒结果差了 1000 倍先判断位数再乘除负数时间戳1970 之前的日期确保你的处理逻辑支持负数时间戳溢出超出 long 范围一般不会使用 BigDecimal 处理超大值罕见5. 日期格式化从 Long 到可读字符串的完整链路5.1 用 SimpleDateFormat 格式化 Long 时间戳现在我们已经有了 long 时间戳1723000000000L要展示成2024-08-07 10:26:40这种可读格式需要先把 long 转回 Date再通过格式化器转成字符串。最常规的写法import java.text.SimpleDateFormat; import java.util.Date; public class LongToFormatDemo { public static void main(String[] args) { long timestamp 1723000000000L; Date date new Date(timestamp); SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); String formatted sdf.format(date); System.out.println(formatted); // 2024-08-07 10:26:40 } }这里的yyyy-MM-dd HH:mm:ss是格式化模式。注意几个关键点yyyy是四位年份yy是两位年份会带来 2000 年问题别用MM是两位月份M是一位月份就不补零dd是两位日期同理HH是 24 小时制hh是 12 小时制下午 8 点会显示成 08容易混淆mm是分钟ss是秒SSS是毫秒189 这种三位毫秒不足三位会补零多余会截断最重要的坑SimpleDateFormat 不是线程安全的。SimpleDateFormat 内部使用了一个 Calendar 对象来执行日期运算format()方法会修改这个共享 Calendar 的状态所以多线程并发调用同一个 sdf 实例时会出现各种诡异的结果时间错乱、报错NumberFormatException、甚至直接得到 null。这就是为什么《阿里巴巴 Java 开发手册》里明确禁止把 SimpleDateFormat 定义为 static 变量。如果你在项目里看到有人写private static final SimpleDateFormat SDF new SimpleDateFormat(yyyy-MM-dd HH:mm:ss);然后多个线程在用它恭喜你你已经找到了一颗定时炸弹。5.2 线程安全的格式化方案对比既然 SimpleDateFormat 线程不安全那实际项目中怎么解决有四条路方案一每次使用都 new 一个 SimpleDateFormatpublic static String format(long timestamp) { Date date new Date(timestamp); SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); return sdf.format(date); }优点绝对线程安全代码简单。缺点每次 new 会有一定性能开销但高并发下其实也没那么大除非你的接口 QPS 上万否则能接受。不过在 Spring 容器里一般会有工具类频繁 new 确实浪费。方案二ThreadLocal 包装private static final ThreadLocalSimpleDateFormat SDF_HOLDER ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); public static String format(long timestamp) { SimpleDateFormat sdf SDF_HOLDER.get(); return sdf.format(new Date(timestamp)); }优点每个线程有自己的 SimpleDateFormat不互相干扰也不用频繁 new。缺点ThreadLocal 如果线程池复用值一直在内存里不释放有轻微内存残留风险实际影响很小。这个方案在 Java 8 之前是主流。方案三使用 DateTimeFormatter推荐Java 8 引入的java.time.format.DateTimeFormatter是不可变且线程安全的完全可以定义成 static 常量共享。import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; public class ThreadSafeFormatDemo { private static final DateTimeFormatter FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); public static String format(long timestamp) { LocalDateTime dateTime LocalDateTime.ofInstant( Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()); return FORMATTER.format(dateTime); } }这是我最推荐的方案。没有 SimpleDateFormat 的线程安全问题代码干净性能也好。方案四使用 Apache Commons Lang 的 FastDateFormat依赖第三方库但 FastDateFormat 相比 SimpleDateFormat 做了很多优化也是线程安全的import org.apache.commons.lang3.time.FastDateFormat; FastDateFormat fdf FastDateFormat.getInstance(yyyy-MM-dd HH:mm:ss); String formatted fdf.format(timestamp); // 直接支持 long如果你的项目里已经有 commons-lang3 依赖用这个最省事。四种方案对比方案线程安全性能代码量适用场景new SimpleDateFormat是每次新对象一般少低并发、简单工具ThreadLocal是好中老项目改造DateTimeFormatter是真的不可变更好中新项目首选FastDateFormat是好最少已有依赖直接用5.3 时区问题为什么格式化结果和预期差 8 小时这是所有日期时间 bug 里最隐蔽的一类。SimpleDateFormat 默认使用 JVM 所在时区。如果你的服务器时区是 UTC而数据库存的是东八区时间格式化出来自然会差 8 小时。解决方式有两种方式一在 SimpleDateFormat 上手动设置时区SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); sdf.setTimeZone(TimeZone.getTimeZone(Asia/Shanghai));方式二用 DateTimeFormatter 配合 ZoneIdDateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss) .withZone(ZoneId.of(Asia/Shanghai)); String formatted formatter.format(Instant.ofEpochMilli(timestamp));注意这里如果不调用withZone(ZoneId)DateTimeFormatter 默认使用的还是系统时区同样会遇到问题。我的项目里一般这么做服务器统一设置为 UTC 存储展示时显式转成东八区。这样无论用户身处何地看到的时间都是统一的中国时间对于国内业务最稳妥。如果是面向海外的系统则让前端根据用户本地时区做转换后端只负责传时间戳。5.4 LocalDateTime、Instant、Date 三者怎么打通Java 8 以后日期时间被拆成了几个核心类很多人一上来就懵。其实捋清楚之后就是一条链路Instant表示时间线上的一个瞬时点等价于 long 时间戳的面向对象版本。Instant.ofEpochMilli(long)可以从 long 创建。LocalDateTime不携带时区信息的本地日期时间比如2024-08-07 10:26:40。它不知道自己对应哪个时区。ZonedDateTime带时区的日期时间。Date老 API底层就是 long 毫秒。类型转换标准写法// long - Date long ts 1723000000000L; Date date new Date(ts); // Date - Instant Instant instant date.toInstant(); // Instant - LocalDateTime需要指定时区 LocalDateTime ldt LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); // LocalDateTime - long long ts2 ldt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); // Instant - Date Date date2 Date.from(instant);看着多其实用顺了就是两条带时区的转换atZone(ZoneId)加上toInstant()不带时区的转换LocalDateTime.ofInstant(instant, zone)或ldt.atZone(zone)我踩过的坑数据库存的字段类型是datetime映射到 Java 用 LocalDateTime一切正常。但后来有个接口为了和外部系统对齐把这个字段改成了 long 时间戳存入 Redis结果读取的时候我用LocalDateTime.ofEpochSecond转回没有指定 ZoneId导致时间白白差了 8 小时。写代码一时爽排查一晚上。经验LocalDateTime 和 Long 互转一定显式指定时区永远不要用默认行为。默认行为在本地开发环境、测试环境、生产环境可能完全不一致环境一变时间就乱。6. 实践场景在项目中封装自己的日期工具类6.1 为什么不要反复写重复代码我在公司 code review 的时候经常看到每个 Service 里都有一段SimpleDateFormat格式化的代码格式还不统一有人用yyyy/MM/dd有人用yyyy-MM-dd HH:mm:ss有人用yyyyMMddHHmmss。接口之间互相调用字符串格式各不一样一旦要解析对方的时间就得写一堆try-catch代码乱得没法看。正确的做法是在公共模块里封装一个全局唯一的日期工具类统一格式、统一时区、统一异常处理static 方法调就完了。6.2 一套可直接抄的日期工具类直接放我项目里用着的版本精简掉了注释依赖保留了核心方法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 final class DateUtils { private static final ZoneId DEFAULT_ZONE ZoneId.of(Asia/Shanghai); private static final DateTimeFormatter DATE_TIME_FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); private static final DateTimeFormatter DATE_FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd); private static final DateTimeFormatter COMPACT_FORMATTER DateTimeFormatter.ofPattern(yyyyMMddHHmmss); private DateUtils() { // 工具类不允许实例化 } /** Date - long毫秒时间戳 */ public static long dateToLong(Date date) { return date null ? 0L : date.getTime(); } /** long - Date */ public static Date longToDate(long timestamp) { return new Date(timestamp); } /** long - 格式化字符串yyyy-MM-dd HH:mm:ss */ public static String formatTimestamp(long timestamp) { return DATE_TIME_FORMATTER.format( LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), DEFAULT_ZONE)); } /** long - 格式化字符串yyyy-MM-dd */ public static String formatDate(long timestamp) { return DATE_FORMATTER.format( LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), DEFAULT_ZONE)); } /** long - 紧凑格式字符串yyyyMMddHHmmss */ public static String formatCompact(long timestamp) { return COMPACT_FORMATTER.format( LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), DEFAULT_ZONE)); } /** 字符串 - Date自动识别常见格式 */ public static Date parseToDate(String text) { if (text null || text.trim().isEmpty()) { return null; } text text.trim(); // 纯数字当作时间戳处理 if (text.matches(\\d)) { long ts Long.parseLong(text); if (ts 10000000000L) { ts * 1000L; } return new Date(ts); } LocalDateTime ldt; if (text.contains(-) text.contains(:)) { ldt LocalDateTime.parse(text, DATE_TIME_FORMATTER); } else if (text.contains(-)) { ldt LocalDate.parse(text, DATE_FORMATTER).atStartOfDay(); } else { ldt LocalDateTime.parse(text, COMPACT_FORMATTER); } return Date.from(ldt.atZone(DEFAULT_ZONE).toInstant()); } }这段代码几个关键设计点默认时区显式指定为Asia/Shanghai而不是用ZoneId.systemDefault()避免环境不一致。保存统一的 DateTimeFormatter 为 static 常量因为它们不可变、线程安全。parseToDate方法做了容错纯数字自动判断秒/毫秒字符串自动匹配不同格式。工具类构造器私有防止别处 new 出实例浪费资源。这段代码能用不代表所有边界条件都覆盖了比如yyyy-MM-dd的解析用了LocalDate.parse(...).atStartOfDay()那是把时间归零到当天零点。如果业务上需要保留具体时间直接传完整字符串就行。6.3 在 Spring 项目里如何注入和使用工具类是纯静态方法在 Spring 里直接调用DateUtils.formatTimestamp(ts)就行不需要注入 Bean。但如果你希望日期格式化可配置比如不同项目要不同时区可以写一个配置类import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Bean; import java.time.ZoneId; Configuration public class DateTimeConfig { Value(${app.timezone:Asia/Shanghai}) private String timezone; Bean public DateTimeFormatter dateTimeFormatter() { return DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss) .withZone(ZoneId.of(timezone)); } }然后在使用方注入Autowired private DateTimeFormatter dateTimeFormatter; public String format(Long timestamp) { return dateTimeFormatter.format(Instant.ofEpochMilli(timestamp)); }这样好处是时区、格式都能通过配置文件动态调整不用改代码。但要注意DateTimeFormatter本身不可变配置后所有线程共用没问题。上面那种工具类的写法把时区硬编码进常量对于大多数国内项目已经够用了更灵活的方案按需选择。7. 面试重点这几道题目必须会答7.1 Date 和 Long 互转的基本原理是什么面试官问这道题其实是想考察你对 Date 内部结构的理解。答法java.util.Date内部维护了一个 long 类型的fastTime字段存的是从 1970-01-01 00:00:00 UTC 到当前时刻的毫秒偏移量。date.getTime()返回这个值new Date(long)把这个值包装成 Date。所以转换本质上是拆包装和装包装的过程。可以顺带提一句Date 的很多方法比如getYear()、getMonth()已经废弃因为它设计得不够好Java 8 之后官方推荐使用java.time包。7.2 SimpleDateFormat 为什么不安全这道题考线程安全和源码理解。答法SimpleDateFormat 内部持有Calendar calendar这个共享对象format()和parse()会修改 calendar 的状态比如设置年月日时分秒、处理时区多个线程同时调用时一个线程正在 format另一个线程也在 formatcalendar 被并发修改就会得到错乱的时间或抛出异常。解决办法每个线程 new 一个、用 ThreadLocal 包装、改用 DateTimeFormatter 或 FastDateFormat。最本质的方案是从根本原因上去掉共享可变状态即用不可变的 DateTimeFormatter。7.3 long 类型的时间戳怎么存数据库建议数据库存bigint类型字段名create_time或者update_time存储的是毫秒时间戳。好处是不依赖数据库的日期类型避免 MySQL 隐式转换问题。排序、范围查询方便直接比较数字。跨时区显示时前端按用户时区转换即可数据库不用存时区。如果字段很敏感或者需要展示到毫秒级可以用bigint存毫秒查询层再格式化。如果表非常大可以考虑存秒而非毫秒能省一半空间但精度下降看业务是否接受。7.4 实际项目中长整型相加的陷阱搜索词里有long类型相加这也是个容易踩坑的点。日期时间运算时对 long 直接加减没问题但要注意溢出问题。// 计算 1 小时后的时间戳 long now System.currentTimeMillis(); long oneHourLater now 3600 * 1000L;这里 3600 * 1000 必须写成3600 * 1000L而不是3600 * 1000。因为两个 int 相乘的结果是 int3600 * 1000 3,600,000 没超过 int 范围所以这里不写 L 也不会错。但如果计算天数乘以小时再乘以毫秒比如24 * 3600 * 1000L如果忘了 L24 * 3600 * 1000这个结果仍是在 int 范围内的所以多数场景不会出问题。真正容易出问题是当单位变大// 错误写法365 天换成毫秒 long oneYear 365 * 24 * 3600 * 1000; // int 溢出 // 正确写法 long oneYear 365 * 24 * 3600 * 1000L;365 * 24 * 3600 * 1000 31,536,000,000明显超过 int 最大值 2,147,483,647所以必须加 L 让乘法在 long 类型下发生。这个坑在计算过期时间、Token 有效期时极易出现。统一规范是日期时间计算相关的乘法右侧至少一个因子加 L 后缀。7.5 如何优雅地处理Date 修改时间的需求搜索词里还有一个date 修改时间。实际项目中修改实体时要求更新update_time字段常见写法entity.setUpdateTime(new Date());在 MyBatis Plus 里还可以用注解自动填充TableField(fill FieldFill.INSERT_UPDATE) private Date updateTime;配合 MetaObjectHandler 实现自动填充。如果是 long 时间戳字段TableField(fill FieldFill.INSERT_UPDATE) private Long updateTime;在实现类里Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, updateTime, Long.class, System.currentTimeMillis()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, Long.class, System.currentTimeMillis()); } }这样每次插入、更新都会自动维护修改时间省去手写代码。8. 常见问题与排查技巧实录8.1 时间显示成了 1970-01-01看到 1970 这个年份第一反应就是时间戳是 0也就是 new 的时候传的 long 是 0或者 Date 被赋予了 0 毫秒。原因不外乎三个Long 类型为 null 被拆箱成 0。前端传的时间戳是秒后端没乘 1000除以 1000 之后变成 0比如 5 秒除以 1000 是 0 多但取整后是 0。数据库字段类型是 int时间戳超过 int 范围被截断成了部分值但最常见的是 0。排查步骤直接在方法入口打日志输出原始入参 long 值。如果是 0一定是数据源头问题如果是正常的 13 位数字那问题在格式化环节看看是不是时区或者格式模板出了问题。8.2 日期格式化结果少了 8 小时多数情况是时区不一致。排查时可以输出当前 JVM 时区# Linux 服务器 timedatectl # 或者 Java 代码内 System.out.println(TimeZone.getDefault().getID());如果服务器是 UTC代码用默认时区格式化就会比东八区慢 8 小时。解决办法代码里显式指定 TimeZone不要依赖系统默认。另外一个隐藏点如果你用了LocalDateTime.now()并且系统时区是 UTC它拿到的本地时间就是 UTC 时间和东八区的实际时刻差了 8 小时。这时候如果直接转 long转出来的时间戳没错但如果你先格式化成字符串再解析就会发生偏移。建议团队内部约定所有日期时间字符串统一东八区展示所有时间戳统一 UTC 存储。8.3 多线程下 SimpleDateFormat 乱码时间症状同一段代码单测通过压测时报错或者并发时偶发时间不对。我遇到过一次非常诡异的两个线程同时格式化一个线程输出的分钟数变成 0 或者变成别的线程的时间。后来加了断点发现是两个线程共用了同一个 SimpleDateFormat 实例内部 Calendar 状态被互相覆盖。修复方式按前面说的用 ThreadLocal 或者 DateTimeFormatter。如果是老项目改动最小的是 ThreadLocalprivate static final ThreadLocalSimpleDateFormat TIME_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); public static String format(Date date) { return TIME_FORMAT.get().format(date); }注意 ThreadLocal 在线程池场景下记得 remove否则有轻微内存泄漏隐患。不过 SimpleDateFormat 本身很小现代 JVM 下影响几乎可以忽略不 remove 问题也不大。8.4 常用方法速查表需求推荐写法拿当前时间戳System.currentTimeMillis()当前时间 Datenew Date()Date 转 longdate.getTime()long 转 Datenew Date(timestamp)long 转格式化字符串DateTimeFormatter Instant ZoneId格式化字符串转 DateLocalDateTime.parse atZone Date.from字符串转时间戳LocalDateTime.parse atZone toEpochMilli当前时间加减天数System.currentTimeMillis() n * 86400000L比较两个时间先后直接比较 longLocalDateTime 转 DateDate.from(ldt.atZone(zone).toInstant())8.5 项目迁移过程中的兼容处理如果你正在把老项目从java.util.DateSimpleDateFormat迁移到java.time不建议一刀切全替换。我的经验是分三步外部接口层保持 Date只改动内部逻辑使用 LocalDateTime因为接口协议不能随便变。新写的代码全部用java.time老代码能不动就不动。工具类里同时提供 Date 和 LocalDateTime 的转换方法让老代码调用工具类完成桥接而不是各自 new SimpleDateFormat。实际上即便现在已经是 Java 17、Java 21 时代很多第三方框架内部依然使用 Date 类型比如很多 ORM 框架的Date字段、Dubbo 接口的序列化。所以 Date 和 LocalDateTime 之间的转换能力是每个 Java 开发者躲不掉的必修课。9. 总结一下我这几年总结出来的经验日期时间处理在 Java 里属于那种看起来人人会用起来多多少少都有点问题的领域。代码写多了你就会发现绝大多数线上时间 bug 不是难懂的问题而是基础细节没做好少乘了 1000、没指定时区、SimpleDateFormat 被共享、字符串格式不统一。我个人的习惯是每个项目一开工就先把日期工具类写进公共模块统一时区、统一格式、统一线程安全方案。后端接口的入参和出参尽量只传 long 时间戳展示层由前端格式化这样团队里每个人对时间的理解就从各自为政变成单一标准。如果你还在用 SimpleDateFormat 写 static 常量我建议你今天就换成 DateTimeFormatter或者加个 ThreadLocal 保护一下。别再靠运气跑生产环境了。如果你正准备面试把这篇文章里的面试题答透再加上能解释清楚 Date 和 LocalDateTime 的互转链路大部分 Java 岗位的日期时间问题就稳了。多写几行测试代码比背答案有用得多——时间戳的坑亲手踩一遍就再也忘不掉了。