Java日期比较全攻略:从Date到LocalDateTime的完整实践

发布时间:2026/9/10 17:52:59
Java日期比较全攻略:从Date到LocalDateTime的完整实践 日期比较这活儿看起来简单实际上特别容易踩坑。别小看一个date1.before(date2)业务里一跑全看你会不会玩。尤其是业务系统里天天跟“过期时间”“活动区间”“任务定时”打交道的同学这块基本功不扎实迟早给你整几个线上事故出来。这篇文章我从头到尾梳理一遍 Java 日期比较的完整姿势覆盖老 APIDate、Calendar和新 APILocalDate、LocalDateTime、Instant包括场景选型、代码模板、面试题、以及我踩过的那些坑。适合准备面试的在校生、写业务 CRUD 的 Java 开发以及想系统复习一遍日期时间的同学。1. 日期比较的整体思路与API选型1.1 当前主流的三种日期比较方式Java 里做日期比较说白了就三条路第一种用java.util.Date和java.text.SimpleDateFormat这套老 API。JDK 1.0 就有了虽然官方早就建议别用了但老项目里存量代码实在太多。你能跑起来是一回事能不能写出不出 bug 的比较逻辑又是另一回事。第二种用java.util.Calendar。这货是 JDK 1.1 顶上来的设计上解决了Date的一部分问题但用起来极其啰嗦而且月份从 0 开始脑子稍微一乱就翻车。第三种用 JDK 8 的java.time包。这才是现代 Java 里最推荐的做法。LocalDate、LocalDateTime、Instant、ZonedDateTime每一种都语义清晰比较方法直白还自带isBefore、isAfter、isEqual。如果项目允许新代码无脑选这个就对了。你可以把这三套 API 理解成手机里的三种地图版本老Date是纸质地图能用但信息太粗Calendar是早期电子地图能看但界面反人类java.time是现在的高德、百度又好用又准。1.2 为什么不能只用时间戳和字符串硬比很多新手一看日期比较脑子里第一个方案就是getTime()转成 long然后、去比。这个思路对不对对但只对了一半。时间戳本质是 UTC 毫秒数它确实可以唯一刻画一个时刻。用它比大小一定能得出正确的先后顺序。但问题在于业务里你要比的往往不是“某一个时刻”而是“某一天”“这一周的周几”“自然日是否相等”。这些概念用时间戳裸比是比不出来的。举个例子我要判断“今天是不是 2025 年 6 月 1 日”如果用new Date()跟new Date(2025, 5, 1)比先别管Date构造方法已废弃单说逻辑毫秒数一定不相等因为你今天的时分秒不等于 0 点 0 分 0 秒。于是“判断今天是不是某个节日”这种最普通的需求用时间戳硬比就挂了。字符串比较也是同理。2025-12-30.compareTo(2025-12-20)这种比较确实能工作因为 ISO 格式的日期字符串按字典序就是时间序。但只要你哪天格式换成了2025/12/30或者30-12-2025比较结果立马错乱还没有任何编译期报错跑起来才发现问题。所以说比较 API 的存在不是为了让你不用getTime()而是为了让你的代码能直接表达业务语义。你要比较日期就写日期比较要比较时间就写时间比较而不是把一个业务概念硬降级成普通数值去比较。1.3 新版 API 的三个真正优势第一个优势是设计上分清了“日期”和“时间”的概念。LocalDate只有年月日LocalDateTime带时分秒Instant是时间戳。你写代码的时候用哪个类型就能看出你到底想表达什么代码可读性直接提升一个档次。第二个优势是不可变性。java.time里所有对象都是 immutable 的每次操作返回新对象不会像SimpleDateFormat那样有线程安全问题。第三个优势是自然语言式 API。plusDays(1)、minusMonths(2)、isBefore(now)方法名读起来就是一句人话比calendar.add(Calendar.DAY_OF_MONTH, -1)不知道强到哪里去了。2. 老 API 的核心细节与实操要点2.1 老 Date 的坑你真的以为是用 compareTo 比较吗老 API 里最常用的比较方案就是Date.compareTo()还有before()和after()。Date now new Date(); Date future new Date(now.getTime() 1000 * 60 * 60); System.out.println(now.before(future)); // true System.out.println(now.after(future)); // false System.out.println(now.compareTo(future)); // -1负数表示 now 在 future 之前这段代码没错。但问题往往出在equals()上。Date.equals()比较的是毫秒级时间戳。也就是说两个Date对象哪怕一个代表2025-06-01 00:00:00.000另一个代表2025-06-01 00:00:00.001也equals返回 false。这在业务上经常被误用。我见过一个真实事故一个优惠券系统判断用户领券日期和“活动开始日期”是否相等代码写的是userGetDate.equals(activityStartDate)结果活动开始时间的毫秒部分不是 0用户领取时间精确到毫秒也不可能完全一致导致所有用户都领不了券排查了半天。正确做法是先把两个Date都格式化到“天”级别再比较SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd); Date d1 ...; Date d2 ...; if (sdf.format(d1).equals(sdf.format(d2))) { // 判断为同一天 }或者更推荐用org.apache.commons.lang3.time.DateUtils.isSameDay()一行搞定DateUtils.isSameDay(d1, d2);这个坑面试八股里不一定会考但工作里真的每个老项目都有。2.2 Calendar 比较月份从 0 开始的恐惧Calendar面试常考的点就是月份JANUARY是 0DECEMBER是 11。很多人开发时直接用数字比如Calendar.MONTH设为 5以为是 5 月其实是 6 月。而Calendar.DAY_OF_MONTH又是从 1 开始的傻傻分不清。比较方面Calendar也有before()、after()、compareTo()三个方法逻辑和Date的类似。但它有一个隐藏较深的坑Calendar的compareTo()有 bug 级行为它比较的最小粒度是“毫秒”而且当两个Calendar的时区不一致时结果可能出乎意料。举个实际例子Calendar c1 Calendar.getInstance(TimeZone.getTimeZone(Asia/Shanghai)); Calendar c2 Calendar.getInstance(TimeZone.getTimeZone(UTC)); c1.set(2025, Calendar.JUNE, 1, 0, 0, 0); c2.set(2025, Calendar.JUNE, 1, 0, 0, 0); System.out.println(c1.compareTo(c2));这段代码在同一个机器上如果没有指定时区两边都是本地时区看着没啥问题。但一旦两个Calendar来自不同时区比如服务器是 UTC数据库返回的是本地时间compareTo就会拿各自时区下的绝对时间戳去比结果自然和你“肉眼看字段一样所以应该相等”的直觉完全相反。这还没完Calendar是最容易写出“裸奔比较”的 APIif (calendar1.getTimeInMillis() calendar2.getTimeInMillis()) { // do something }不是不能这么写但若哪天有人改了其中一个Calendar的时区这段逻辑就直接出 bug 了。因为getTimeInMillis()返回的是 UTC 毫秒两个不同时区的Calendar在同一本地时间下getTimeInMillis()结果不一样比较结果自然不同。这是我在老项目中踩过无数次的坑归根结底就是你永远不知道别人在哪个环节改了时区。2.3 老 API 比较时的线程安全警告老 API 里还有一个大坑SimpleDateFormat不是线程安全的。在多线程环境下同时使用同一个SimpleDateFormat实例做parse或format会出现各种诡异现象解析结果错乱、抛出NumberFormatException甚至直接 JVM 崩溃。原因是SimpleDateFormat内部用了一个可变成员变量来保存解析状态多线程并发时就会相互覆盖。如果你真的要在一个老项目里写日期比较并且需要先格式化或者先解析字符串请务必给SimpleDateFormat加锁或者用ThreadLocal包一层或者干脆在方法内 new 一个新实例。最省心的方案是每次用的时候都new SimpleDateFormat(yyyy-MM-dd)。虽然有点浪费但安全第一。3. 新旧 API 的实战对比与最佳实践3.1 三种场景对比各自选哪个场景推荐 API理由比较两个日期是否同一天LocalDate.isEqual或DateUtils.isSameDay语义清晰代码可读性高比较两个时刻的先后Instant或LocalDateTime用isBefore/isAfter天然支持时间轴比较没有格式化问题比较“某个时间是否在指定区间内”LocalDateTimeisAfterisBefore链式写法直观区间判断不靠手写老项目维护不能改类型DateUtilscommons-lang3或Calendar老代码直接用工具类封一层风险最小以“判断今天是否在活动区间内”为例新 API 写法LocalDate today LocalDate.now(); LocalDate start LocalDate.of(2025, 6, 1); LocalDate end LocalDate.of(2025, 6, 30); if (!today.isBefore(start) !today.isAfter(end)) { // 在区间内 }这里我用的是!isBefore和!isAfter跟前闭后闭的语义一致。如果你要的是前闭后开含头不含尾就写today.isBefore(end)。老 API 写法Date today new Date(); Date start ...; Date end ...; if (today.after(start) today.before(end)) { // 待定 }这种写法要小心因为today.after(start)是严格大于today.before(end)是严格小于会把边界值漏掉。你要是用老 API边界判断必须单独处理。3.2 LocalDate、LocalDateTime、Instant 怎么选很多开发者搞不清楚这三者的区别选错类型又弄得代码很别扭。我的建议很简单只要年月日不需要时分秒用LocalDate。需要在年月日上带时分秒但没有时区概念用LocalDateTime。需要精确到时间戳级别或者要跟数据库时间戳、外部接口传时间戳对接用Instant。需要考虑时区转换用ZonedDateTime。业务系统里最常见的组合是数据库存datetime实体用LocalDateTime页面展示时格式化。做比较时LocalDateTime直接isBefore、isAfter、isEqual比老 API 舒服太多了。来看一个完整例子判断某条订单记录是否超过 30 分钟未支付LocalDateTime orderTime ...; LocalDateTime now LocalDateTime.now(); if (orderTime.isBefore(now.minusMinutes(30))) { // 超时未支付自动关单 }注意这里now.minusMinutes(30)是先算边界时间再比较可读性极好完全不用手写时间戳运算。3.3 新旧 API 互转存量系统的必经之路现实项目里不可能要求你一次性把所有Date全改成LocalDateTime所以互转能力必须掌握。老类型转新类型// Date - Instant Date date new Date(); Instant instant date.toInstant(); // Date - LocalDateTime需要先转 Instant再通过系统时区转换 LocalDateTime ldt LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()); // Date - LocalDate LocalDate ld date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();新类型转老类型// LocalDateTime - Date LocalDateTime ldt LocalDateTime.now(); Date date Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant()); // Instant - Date Date dateFromInstant Date.from(Instant.now()); // Calendar - LocalDateTime LocalDateTime ldtFromCalendar LocalDateTime.ofInstant(calendar.toInstant(), ZoneId.systemDefault());这里要点是任何从新到老或从老到新的转换都必须经过“时区”这一道关口。因为老 API 的Date本质是一个 UTC 时间戳而LocalDateTime本身不携带时区你不指定时区就无法完成转换。很多线上 bug 都是这种用户在东八区创建的订单时间转成Date存库时用的是默认时区换了一台 UTC 时区的服务器后时间直接偏移 8 小时。比较逻辑对不上排查时发现哪哪都“没错”最后发现是服务器时区问题。4. 高频业务场景与面试必问题型4.1 判断日期是否在某个区间别把边界搞混业务里最最常见的日期比较场景就是“时间区间判断”比如优惠券有效期、会员到期时间、活动开始结束时间。我直接给你一套比较稳定的写法。需求判断current是否在[start, end]闭区间内。LocalDateTime current LocalDateTime.now(); LocalDateTime start ...; LocalDateTime end ...; boolean within !current.isBefore(start) !current.isAfter(end);需求判断current是否在[start, end)前闭后开区间内。boolean within !current.isBefore(start) current.isBefore(end);这套写法把“是否等于边界”的语义完全交给!isBefore和isBefore的组合基本不会出错。如果你用了compareTo边界值处理要格外仔细。4.2 判断两个时间段是否有重叠千万别去列所有重叠情况另一个场景是两个区间判断是否重叠比如会议室预约冲突、结算周期交叉。有基础的开发者都知道一个结论两个区间[a1, a2]和[b1, b2]有重叠当且仅当a1 b2 b1 a2。代码实现LocalDateTime a1, a2, b1, b2; boolean overlap a1.isBefore(b2) b1.isBefore(a2);这个公式不论你是闭区间还是开区间只要边界处理一致都能用推荐直接背下来。比你去枚举“包含、被包含、交叉、相离”N 种情况高效得多。4.3 定时任务里判断“是否当天”“是否本周”我做过一个报表任务要求每个工作日的凌晨跑一次把当天所有下单记录汇总。这个场景下就得判断“这条记录生成的日期是否等于今天”并且要忽略时分秒。推荐写法LocalDate today LocalDate.now(); LocalDate recordDate orderTime.toLocalDate(); if (today.isEqual(recordDate)) { // 是今天的记录 }如果非要用老 API那就用DateUtils.isSameDay()别自己去格式化字符串比较又啰嗦又有性能损耗。4.4 面试高频题两个日期相差多少天这个题在 Java 面试里出现的频率非常高往往和ChronoUnit配套考。给你一个标准答案LocalDate d1 LocalDate.of(2025, 1, 1); LocalDate d2 LocalDate.of(2025, 12, 31); long days ChronoUnit.DAYS.between(d1, d2); System.out.println(days); // 364注意ChronoUnit.DAYS.between()是“第一个参数到第二个参数”之间的天数如果你把结束日期放前面返回负数。不要以为它内部会帮你取绝对值。老项目里如果用Date计算天数差正确的做法是long diff Math.abs((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24));但这里有个巨大的坑如果两个Date跨了夏令时部分国家有一天可能变成 23 小时或 25 小时整除下来结果就少 1 天。国内虽然没这个问题但做海外业务的同学必须注意。4.5 面试高频题字符串与日期互转的比较陷阱面试官经常会问2025-01-02 10:00:00和2025-01-02 09:00:00如何比较大小标准答案分两层第一层全部转成LocalDateTime再用isBefore/isAfter比较。DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); LocalDateTime t1 LocalDateTime.parse(2025-01-02 10:00:00, formatter); LocalDateTime t2 LocalDateTime.parse(2025-01-02 09:00:00, formatter); System.out.println(t1.isAfter(t2)); // true第二层如果字符串本身就是 ISO 格式yyyy-MM-dd或yyyy-MM-ddTHH:mm:ss直接用LocalDate.parse()或LocalDateTime.parse()即可因为java.time默认就能解析 ISO 格式不需要额外传 formatter。这也是新 API 的贴心之处。面试时如果你能顺带提到“用字符串去直接比较是只能限定 ISO 格式而且容易埋雷万一格式变了字符串排序就不等于时间排序”这个答案会让面试官觉得你是真踩过坑的人。5. 踩坑清单与实操心得5.1 直接上踩坑清单我把这些年做 Java 项目遇到的日期比较坑整理成一个表方便你自查。坑位现象根因解法Date.equals比较失败明明同一个业务日期equals 返回 false比较精确到毫秒时分秒不一致格式化成yyyy-MM-dd或DateUtils.isSameDay()Calendar月份少 1设置 6 月实际是 7 月月份从 0 开始使用Calendar.JUNE这种常量不硬编码数字compareTo边界语义不对判断包含边界时漏数据严格比较不包含等于用!before !after组合SimpleDateFormat并发错乱多线程下解析出错误时间甚至抛异常非线程安全每次 new 实例或用ThreadLocal跨时区比较结果不对同一本地时间在不同环境下比较结果不同Date本质是 UTC 时间戳统一转成Instant或统一时区后再比较字符串比较格式一旦变化就错日期串排序不等于时间排序字符串自然序依赖格式优先用类型比较不裸比较字符串时间差计算跨夏令时差一天天数差计算少一天一天不是固定 24 小时用ChronoUnit别自己整除这张表不夸张每一条背后都有对应的生产事故或者面试翻车现场。尤其是“SimpleDateFormat线程安全”和“Date比较毫秒级陷阱”我见过不下十次。5.2 到底用哪个 API我的选型建议先说结论新工程一律用java.time包老工程能逐步替换就逐步替换。如果老工程没法替换至少要把比较逻辑封装到工具类里对外只暴露统一方法别让每个业务都自己写一套比较代码。工具类的代码可以长这样public final class DateCompareUtils { private DateCompareUtils() {} public static boolean isSameDay(LocalDate d1, LocalDate d2) { return d1.isEqual(d2); } public static boolean isSameDay(LocalDateTime dt1, LocalDateTime dt2) { return dt1.toLocalDate().isEqual(dt2.toLocalDate()); } public static boolean isBetweenInclusive(LocalDateTime target, LocalDateTime start, LocalDateTime end) { return !target.isBefore(start) !target.isAfter(end); } public static boolean isBetweenExclusiveEnd(LocalDateTime target, LocalDateTime start, LocalDateTime end) { return !target.isBefore(start) target.isBefore(end); } }这样业务代码变成一行调用后续如果要换底层实现只改工具类即可。5.3 为什么我强烈建议你在实体层和时间相关字段上用 LocalDateTime另外送你一个额外建议如果用 MyBatis 或 JPA实体里的日期字段尽量用LocalDateTime或LocalDate别用Date。因为LocalDateTime与数据库方言的映射很成熟MyBatis 3.4 原生支持 JDK 8 时间类型JPA 2.2 之后也默认支持。用LocalDateTime后比较、格式化、运算都清爽不存在SimpleDateFormat的线程安全问题也天然免疫“date 类型被前端传成空”这种低级错误。6. 从面试到生产日期比较的最终建议最后再分享几个实用小技巧。第一写任何日期比较前先问自己一句我比的是“时刻”还是“自然日”如果答案是自然日不要直接拿着带时分秒的对象去比较。先把时、分、秒、毫秒全部清零或者用toLocalDate()截断再比较。第二如果你在写时间区间判断强烈建议在代码注释里写清楚是开区间还是闭区间。单看代码逻辑!isBefore !isAfter这种写法一个月后你自己可能都记不清含不含边界。业内常见的规避方案是定义枚举RangeType { CLOSED, OPEN_END }把区间类型显式化。第三老项目里如果必须用Date至少把比较逻辑统一封装并写好单元测试。不要在每个业务类里自己写一套否则排查问题的时候你会发现十个地方有十种写法九种还不一样。我个人的经验是日期比较这个看似基础的功能最容易暴露一个人“到底有没有真正写过生产代码”。面试官只要问一下“如何判断两个日期是否同一天”“LocalDate和LocalDateTime的区别”“Date转为LocalDateTime为什么会丢时区”基本就能摸出真实水平。把这篇里的内容吃透再去写业务你会发现日期相关的 bug 少了一大半。至少下次再看到“用户生日判断失败”“优惠券过期时间不对”这类工单时你脑子里已经能快速列出嫌疑点而不需要再对着SimpleDateFormat发呆查半天了。