Skip to content

Java 日期时间 API

日期时间是业务系统高频能力:订单创建时间、支付超时时间、会员到期时间、定时任务、账单周期、跨时区展示都离不开它。日期时间处理错误,轻则展示不对,重则订单过期、计费错误、定时任务重复执行。

为什么推荐 java.time

早期 Java 的 DateCalendar 存在很多问题:

老 API 问题后果
Date 可变传出去后可能被修改
月份从 0 开始容易写错
API 设计不直观代码难读
时区处理混乱跨地区业务容易出错

Java 8 引入 java.time,更清晰、更安全。

旧 API 到底有什么坑

java.util.Date 的名字很迷惑,它并不只是“日期”,内部本质上表示一个时间点,也就是从 1970-01-01T00:00:00Z 开始经过的毫秒数。

java
import java.util.Date;

public class DateMutableDemo {
    public static void main(String[] args) {
        Date expireTime = new Date();
        change(expireTime);
        System.out.println(expireTime);
    }

    private static void change(Date date) {
        date.setTime(0L); // 外部传入的 Date 被修改
    }
}

Date 是可变对象。你把它从实体里返回给外部,外部就可以修改它,导致对象状态被绕过。

Calendar 的月份从 0 开始:

java
import java.util.Calendar;

Calendar calendar = Calendar.getInstance();
calendar.set(2026, 6, 1); // 这里的 6 表示 7 月,不是 6 月

这类 API 设计会让代码很难读,也容易制造线上边界问题。新代码优先使用 Java 8 的 java.time

SimpleDateFormat 为什么线程不安全

SimpleDateFormat 是旧项目里高频问题。它内部有可变状态,多线程共享一个实例时,解析和格式化可能互相污染。

错误写法:

java
import java.text.SimpleDateFormat;
import java.util.Date;

public class SimpleDateFormatBad {
    private static final SimpleDateFormat FORMAT =
            new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static String format(Date date) {
        return FORMAT.format(date); // 多线程共享不安全
    }
}

正确选择:

  1. 新代码用 DateTimeFormatter,它不可变且线程安全。
  2. 老代码如果必须用 SimpleDateFormat,不要静态共享,可以局部创建或用 ThreadLocal,但更推荐迁移。
java
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class DateTimeFormatterDemo {
    private static final DateTimeFormatter FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public static String format(LocalDateTime time) {
        return time.format(FORMATTER);
    }
}

这也是 JDK 8 日期时间 API 在服务端项目里特别重要的原因之一。

核心类型

mermaid
flowchart TD
    A["java.time"] --> B["LocalDate 日期"]
    A --> C["LocalTime 时间"]
    A --> D["LocalDateTime 日期时间"]
    A --> E["Instant 时间戳"]
    A --> F["ZonedDateTime 带时区时间"]
    A --> G["Duration 时间间隔"]
    A --> H["Period 日期间隔"]
类型表达内容示例
LocalDate年月日2026-07-01
LocalTime时分秒22:30:00
LocalDateTime年月日时分秒,不含时区2026-07-01T22:30
InstantUTC 时间线上的一个点时间戳
ZonedDateTime带时区日期时间Asia/Shanghai
Duration秒、纳秒级时间差30 分钟
Period年、月、日级日期差1 个月

怎么选:

业务语义推荐类型说明
生日、账单日、统计日期LocalDate只有日期,没有具体时分秒
每天几点执行LocalTime只有一天内的时间
本地创建时间LocalDateTime不带时区,适合同一时区系统内部展示
统一时间点、日志、事件时间InstantUTC 时间线上的点,适合存储和比较
跨时区会议、用户本地展示ZonedDateTime同时包含本地时间和时区规则
30 秒、15 分钟、2 小时Duration精确时间间隔
1 天、1 月、1 年Period日历日期间隔

一句话:机器比较用 Instant,人类日历用 LocalDate/LocalDateTime,跨时区展示用 ZonedDateTime

日期时间的工作原理

日期时间要分清两层含义:

  1. 人看到的日历时间,比如 2026-07-01 22:30:00
  2. 机器计算的时间点,比如从 Unix epoch 开始经过了多少毫秒。
mermaid
flowchart TD
    A["人类日历时间"] --> B["结合时区规则"]
    B --> C["转换为 Instant"]
    C --> D["机器可比较的时间点"]
    D --> E["再按目标时区格式化展示"]

所以时间比较最好比较时间对象或时间戳,不要直接比较格式化后的字符串。跨时区系统必须明确:存储的是时间点,展示时才转换成人所在时区的本地时间。

LocalDateTime 为什么不能代表全球唯一时间点

LocalDateTime 没有时区。2026-07-01 10:00:00 在上海和纽约不是同一个瞬间。

java
import java.time.LocalDateTime;
import java.time.ZoneId;

public class LocalDateTimeZoneDemo {
    public static void main(String[] args) {
        LocalDateTime local = LocalDateTime.of(2026, 7, 1, 10, 0);

        long shanghaiMillis = local.atZone(ZoneId.of("Asia/Shanghai"))
                .toInstant()
                .toEpochMilli();

        long newYorkMillis = local.atZone(ZoneId.of("America/New_York"))
                .toInstant()
                .toEpochMilli();

        System.out.println(shanghaiMillis);
        System.out.println(newYorkMillis);
        System.out.println(shanghaiMillis == newYorkMillis);
    }
}

所以跨时区系统不能只传 LocalDateTime。至少要同时明确时区,或者统一传 Instant / epoch milliseconds。

基本使用

java
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;

public class DateTimeBasicDemo {
    public static void main(String[] args) {
        LocalDate today = LocalDate.now();
        LocalTime nowTime = LocalTime.now();
        LocalDateTime now = LocalDateTime.now();

        System.out.println(today);
        System.out.println(nowTime);
        System.out.println(now);
    }
}

日期计算

java
import java.time.LocalDateTime;

public class DateCalculateDemo {
    public static void main(String[] args) {
        LocalDateTime createTime = LocalDateTime.now();
        LocalDateTime expireTime = createTime.plusMinutes(30);

        System.out.println("创建时间:" + createTime);
        System.out.println("过期时间:" + expireTime);
        System.out.println("是否过期:" + LocalDateTime.now().isAfter(expireTime));
    }
}

订单超时、优惠券有效期、会员到期,都可以用类似思路处理。

plusDays 和 plusHours 不一样

plusDays(1) 是日历意义上的加一天,plusHours(24) 是精确加 24 小时。大多数地区平时看起来一样,但遇到夏令时切换就可能不同。

java
import java.time.ZoneId;
import java.time.ZonedDateTime;

public class DstDemo {
    public static void main(String[] args) {
        ZoneId zone = ZoneId.of("America/New_York");
        ZonedDateTime time = ZonedDateTime.of(2026, 3, 8, 1, 30, 0, 0, zone);

        System.out.println(time.plusDays(1));
        System.out.println(time.plusHours(24));
    }
}

如果业务是“明天同一当地时间”,更接近 plusDays(1);如果业务是“严格 24 小时后过期”,更接近 plusHours(24)Duration.ofHours(24)

格式化和解析

java
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class DateFormatDemo {
    private static final DateTimeFormatter FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) {
        LocalDateTime now = LocalDateTime.now();
        String text = now.format(FORMATTER);
        LocalDateTime parsed = LocalDateTime.parse(text, FORMATTER);

        System.out.println(text);
        System.out.println(parsed);
    }
}

DateTimeFormatter 是不可变且线程安全的,可以定义为静态常量。

时区为什么重要

LocalDateTime 不包含时区,它只是一个“本地日期时间”。同样的 2026-07-01 10:00:00,在上海和纽约代表的 UTC 时间点不同。

mermaid
flowchart TD
    A["LocalDateTime"] --> B["没有时区"]
    C["ZoneId"] --> D["时区规则"]
    A --> E["ZonedDateTime"]
    D --> E
    E --> F["Instant 时间线上的点"]

Demo:

java
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;

public class ZoneDemo {
    public static void main(String[] args) {
        ZonedDateTime shanghai = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
        Instant instant = shanghai.toInstant();
        ZonedDateTime newYork = instant.atZone(ZoneId.of("America/New_York"));

        System.out.println(shanghai);
        System.out.println(newYork);
    }
}

商业系统建议:

  1. 数据库存储统一时间,常见做法是 UTC 或服务器统一时区。
  2. 前端展示时按用户所在时区转换。
  3. 跨国业务不要只存 LocalDateTime,要明确时区策略。

数据库存时间怎么选

数据库存时间没有唯一答案,但必须统一策略。

常见策略:

策略优点风险
存 UTC 时间戳或 UTC 时间跨时区一致,适合事件时间展示前必须转换时区
存服务器统一时区时间国内单时区系统简单迁移服务器时区或跨国业务会复杂
存本地时间 + 时区语义完整,适合预约、会议字段更多,查询和转换更复杂

国内单体业务常见做法是统一服务器、数据库、JVM 时区为 Asia/Shanghai,数据库保存 datetime。但如果是跨地区、跨国、多租户系统,更推荐保存 UTC 时间点,并在展示层按用户时区转换。

流程:

mermaid
flowchart TD
    A["用户提交本地时间"] --> B["携带用户时区"]
    B --> C["转换为 Instant"]
    C --> D["数据库保存统一时间点"]
    D --> E["查询时取出 Instant"]
    E --> F["按用户时区格式化展示"]

重点不是“哪种永远正确”,而是系统内不能混用。最怕一部分代码按 UTC,一部分按服务器本地时区,一部分按前端字符串处理。

时间戳转换

java
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;

public class TimestampDemo {
    public static void main(String[] args) {
        long millis = System.currentTimeMillis();

        LocalDateTime time = LocalDateTime.ofInstant(
                Instant.ofEpochMilli(millis),
                ZoneId.of("Asia/Shanghai")
        );

        long restored = time.atZone(ZoneId.of("Asia/Shanghai"))
                .toInstant()
                .toEpochMilli();

        System.out.println(time);
        System.out.println(restored);
    }
}

秒和毫秒不要混

时间戳常见两种单位:

单位示例长度Java API
10 位左右Instant.ofEpochSecond(seconds)
毫秒13 位左右Instant.ofEpochMilli(millis)

错误示例:

java
long seconds = 1783296000L;
Instant wrong = Instant.ofEpochMilli(seconds); // 把秒当毫秒,时间会跑到 1970 附近

正确:

java
Instant instant = Instant.ofEpochSecond(seconds);

线上经常出现“前端传 10 位秒时间戳,后端按 13 位毫秒解析”,导致时间相差几十年。

Duration 和 Period

java
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Period;

public class DurationPeriodDemo {
    public static void main(String[] args) {
        Duration duration = Duration.between(
                LocalDateTime.now(),
                LocalDateTime.now().plusHours(2)
        );

        Period period = Period.between(
                LocalDate.now(),
                LocalDate.now().plusMonths(1)
        );

        System.out.println(duration.toMinutes());
        System.out.println(period.getMonths());
    }
}

Duration 适合精确时间差,Period 适合日期差。

Clock:让时间逻辑可测试

业务代码里到处直接调用 LocalDateTime.now(),测试会很难写,因为当前时间一直变。

更好的方式是注入 Clock

java
import java.time.Clock;
import java.time.LocalDateTime;

public class OrderExpireService {
    private final Clock clock;

    public OrderExpireService(Clock clock) {
        this.clock = clock;
    }

    public boolean isExpired(LocalDateTime createTime) {
        LocalDateTime now = LocalDateTime.now(clock);
        return now.isAfter(createTime.plusMinutes(30));
    }
}

测试时可以固定当前时间:

java
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;

Clock fixedClock = Clock.fixed(
        Instant.parse("2026-07-01T10:00:00Z"),
        ZoneId.of("UTC")
);

这在订单超时、优惠券过期、会员有效期、定时任务窗口判断里非常有用。

商业 Demo:订单支付超时判断

java
import java.time.LocalDateTime;

public class OrderExpireDemo {
    public static boolean isPaymentExpired(LocalDateTime createTime, int timeoutMinutes) {
        if (createTime == null) {
            throw new IllegalArgumentException("订单创建时间不能为空");
        }
        LocalDateTime expireTime = createTime.plusMinutes(timeoutMinutes);
        return LocalDateTime.now().isAfter(expireTime);
    }

    public static void main(String[] args) {
        LocalDateTime createTime = LocalDateTime.now().minusMinutes(40);
        System.out.println(isPaymentExpired(createTime, 30));
    }
}

真实业务中,超时取消订单通常还会结合定时任务、消息队列或延迟队列处理。

商业 Demo:账单周期计算

账单、会员、订阅类业务经常按自然月计算,不能简单用 30 天代替一个月。

java
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;

public class BillingCycleDemo {
    public static void main(String[] args) {
        LocalDate anyDay = LocalDate.of(2026, 2, 15);

        LocalDate start = anyDay.with(TemporalAdjusters.firstDayOfMonth());
        LocalDate end = anyDay.with(TemporalAdjusters.lastDayOfMonth());

        System.out.println(start); // 2026-02-01
        System.out.println(end);   // 2026-02-28
    }
}

为什么不能简单 start.plusDays(30)?因为每个月天数不同,还有闰年。按自然月、自然季度、自然年统计时,应使用日历规则,而不是固定秒数。

商业 Demo:接口时间参数解析

对外接口常收到字符串时间,必须统一格式和时区。

java
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

public class ApiTimeParseDemo {
    private static final DateTimeFormatter FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public static Instant parseShanghaiTime(String text) {
        if (text == null || text.trim().isEmpty()) {
            throw new IllegalArgumentException("时间不能为空");
        }
        LocalDateTime localDateTime = LocalDateTime.parse(text, FORMATTER);
        return localDateTime.atZone(ZoneId.of("Asia/Shanghai")).toInstant();
    }

    public static void main(String[] args) {
        System.out.println(parseShanghaiTime("2026-07-01 10:00:00"));
    }
}

接口文档要写清楚:

  1. 时间格式是什么。
  2. 时区按谁算。
  3. 时间戳单位是秒还是毫秒。
  4. 是否允许为空。
  5. 解析失败返回什么错误码。

不写清楚,前后端、第三方系统、定时任务就会各按各的理解处理。

线上排查流程

mermaid
flowchart TD
    A["时间相关问题"] --> B{"表现是什么"}
    B -- "时间差 8 小时" --> C["检查 UTC 和 Asia/Shanghai 转换"]
    B -- "时间差几十年" --> D["检查秒和毫秒是否混用"]
    B -- "偶发格式错乱" --> E["检查 SimpleDateFormat 是否静态共享"]
    B -- "账期边界错误" --> F["检查自然月和固定天数是否混用"]
    B -- "定时任务重复或漏执行" --> G["检查时区、夏令时、幂等和补偿"]
    B -- "数据库时间不一致" --> H["检查 JVM、数据库、服务器时区配置"]

排查建议:

  1. 日志同时打印原始入参、解析后的对象、最终 epoch milliseconds。
  2. 查清楚问题机器的系统时区、JVM 时区、数据库会话时区。
  3. 检查接口时间戳单位,10 位通常是秒,13 位通常是毫秒。
  4. 如果只在高并发下格式错乱,优先排查共享 SimpleDateFormat
  5. 定时任务必须有幂等,不能只靠“时间刚好触发一次”。

常见风险

问题后果建议
混用服务器本地时区多地区时间错乱明确统一时区
Date 到处传可读性差且可变新代码优先 java.time
格式化模式写错解析失败或展示错误统一封装格式
时间比较只比较字符串逻辑错误转成日期时间对象比较
定时任务忽略夏令时某些地区时间跳变使用带时区类型
静态共享 SimpleDateFormat高并发下解析/格式化错乱使用 DateTimeFormatter
秒和毫秒混用时间跑到 1970 或未来明确 timestamp 单位
用 30 天代替一个月账单、会员周期错误使用 Period 或日历调整器
到处直接 now()测试困难,边界不可控注入 Clock

面试常问

为什么推荐 java.time?
因为旧的 Date 可变、语义不清,Calendar API 繁琐且月份从 0 开始,SimpleDateFormat 线程不安全。Java 8 的 java.time 类型更明确,大多不可变且线程安全,更适合现代业务系统。

LocalDateTime 和 Instant 区别?
LocalDateTime 是不带时区的本地日期时间,不能单独代表全球唯一时间点;Instant 是 UTC 时间线上的一个瞬间,适合存储、比较和日志事件时间。跨时区系统应明确时区或使用 Instant。

SimpleDateFormat 为什么线程不安全?
它内部有可变状态,多线程共享同一个实例时,格式化和解析过程会互相污染,可能出现错乱结果。生产新代码应使用线程安全的 DateTimeFormatter

Duration 和 Period 区别?
Duration 表示精确时间间隔,按秒和纳秒计算,适合 30 分钟、24 小时这种超时;Period 表示日历日期间隔,按年、月、日计算,适合一个月、一年这种自然日历周期。

为什么时间会差 8 小时?
通常是 UTC 和 Asia/Shanghai 没转换清楚。UTC 比北京时间少 8 小时,如果数据库、后端、前端有的按 UTC、有的按本地时区展示,就会出现 8 小时偏差。

定时任务遇到时间问题怎么保证可靠?
要明确时区,避免夏令时地区的时间跳变影响;任务本身要幂等,防止重复执行;要有补偿扫描,防止漏执行;日志里记录计划触发时间、实际触发时间和业务时间窗口。

本章小结

日期时间要重点理解本地时间、时间戳、时区、格式化和时间差。java.time 的设计比旧 API 更清晰。业务系统里要提前制定时区和存储策略,否则后期排查时间问题非常痛苦。