1. 概述

日期时间处理是Java应用开发中的基础需求。随着Java版本的演进,日期时间API也在不断优化,为开发者提供了更简洁高效的解决方案。

本文将首先回顾Java早期的日期时间处理方式,然后深入探讨现代最佳实践,帮助开发者建立系统化的日期时间处理能力。

2. 传统方案

java.time包出现之前,Java主要依赖DateCalendar类处理日期时间。这些类虽然可用,但存在明显缺陷。

2.1. java.util.Date

java.util.Date是Java最初的日期处理方案,但存在以下问题:

  • 可变性:对象状态可修改,存在线程安全隐患
  • 时区缺失:无法处理时区相关操作
  • API混乱:方法命名和返回值反直觉(如getYear()返回1900年以来的年数)
  • 废弃方法:大量方法已被标记为废弃

使用无参构造函数创建Date对象会获取当前时间戳:

Date now = new Date();
logger.info("当前日期时间: {}", now);

输出类似:Wed Sep 24 10:30:45 PDT 2024。⚠️ 尽管此构造函数仍可用,但不推荐在新项目中使用

2.2. java.util.Calendar

为弥补Date的不足,Java引入了Calendar类,提供了改进:

  • 支持多种日历系统
  • 时区管理能力
  • 更直观的日期操作方式

日期操作示例:

Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH, 5);
Date fiveDaysLater = cal.getTime();

这段代码计算5天后的日期并存储为Date对象。

Calendar仍有缺陷:

  • 可变性:与Date一样非线程安全
  • API复杂:如月份从0开始计数等反直觉设计

3. 现代方案:java.time

Java 8引入的java.time提供了现代化的日期时间API。该包专门解决了传统类的设计缺陷,使日期时间操作更直观友好

受流行的Joda-Time库启发,java.time已成为Java日期时间处理的核心方案。

3.1. java.time核心类

java.time包包含多个实用类,可分为三大类:

时间容器类:

  • LocalDate:仅表示日期(不含时间/时区)
  • LocalTime:仅表示时间(不含日期/时区)
  • LocalDateTime:日期+时间(不含时区)
  • ZonedDateTime:含时区的完整日期时间
  • Instant:时间线上的瞬时点(类似时间戳)

时间操作类:

  • Duration:基于时间的时长(如"5小时")
  • Period:基于日期的时长(如"2年3个月")
  • TemporalAdjusters:日期调整工具(如"下个周一")
  • Clock:时钟访问器(支持时区控制)

格式化类:

3.2. java.time的优势

相比传统日期时间类,java.time包带来显著改进:

  • 不可变性:所有类不可变,天然线程安全
  • 清晰API:方法命名一致,降低学习成本
  • 职责明确:每个类专注特定功能(存储/操作/格式化)
  • 内置格式化:提供便捷的格式化和解析方法

4. java.time使用示例

在探索高级特性前,我们先掌握java.time的基础用法,包括日期创建、调整、格式化和时区处理。

4.1. 创建日期对象

java.time提供多种类表示不同维度的日期时间。使用LocalDateLocalTimeLocalDateTime创建基础对象:

@Test
void givenCurrentDateTime_whenUsingLocalDateTime_thenCorrect() {
    LocalDate currentDate = LocalDate.now(); // 当前日期
    LocalTime currentTime = LocalTime.now(); // 当前时间
    LocalDateTime currentDateTime = LocalDateTime.now(); // 当前日期时间

    assertThat(currentDate).isBeforeOrEqualTo(LocalDate.now());
    assertThat(currentTime).isBeforeOrEqualTo(LocalTime.now());
    assertThat(currentDateTime).isBeforeOrEqualTo(LocalDateTime.now());
}

也可通过参数创建特定日期时间:

@Test
void givenSpecificDateTime_whenUsingLocalDateTime_thenCorrect() {
    LocalDate date = LocalDate.of(2024, Month.SEPTEMBER, 18);
    LocalTime time = LocalTime.of(10, 30);
    LocalDateTime dateTime = LocalDateTime.of(date, time);

    assertEquals("2024-09-18", date.toString());
    assertEquals("10:30", time.toString());
    assertEquals("2024-09-18T10:30", dateTime.toString());
}

4.2. 使用TemporalAdjusters调整日期

获取日期对象后,可通过TemporalAdjusters进行调整:

@Test
void givenTodaysDate_whenUsingVariousTemporalAdjusters_thenReturnCorrectAdjustedDates() {
    LocalDate today = LocalDate.now();

    LocalDate nextMonday = today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
    assertThat(nextMonday.getDayOfWeek())
        .as("下个周一应正确识别")
        .isEqualTo(DayOfWeek.MONDAY);

    LocalDate firstDayOfMonth = today.with(TemporalAdjusters.firstDayOfMonth());
    assertThat(firstDayOfMonth.getDayOfMonth())
        .as("当月首日应为1号")
        .isEqualTo(1);
}

除预定义调整器外,还可创建自定义调整器:

@Test
void givenCustomTemporalAdjuster_whenAddingTenDays_thenCorrect() {
    LocalDate specificDate = LocalDate.of(2024, Month.SEPTEMBER, 18);
    TemporalAdjuster addTenDays = temporal -> temporal.plus(10, ChronoUnit.DAYS);
    LocalDate adjustedDate = specificDate.with(addTenDays);

    assertEquals(
      specificDate.plusDays(10),
      adjustedDate,
      "调整后日期应为2024年9月18日后10天"
    );
}

4.3. 格式化日期

DateTimeFormatter提供线程安全的日期格式化能力:

@Test
void givenDateTimeFormat_whenFormatting_thenVerifyResults() {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm");
    LocalDateTime specificDateTime = LocalDateTime.of(2024, 9, 18, 10, 30);

    String formattedDate = specificDateTime.format(formatter);
    LocalDateTime parsedDateTime = LocalDateTime.parse("18-09-2024 10:30", formatter);

    assertThat(formattedDate).isNotEmpty().isEqualTo("18-09-2024 10:30");
}

支持预定义格式和自定义模式两种方式。

4.4. 解析日期

DateTimeFormatter同样可将字符串解析为日期对象:

@Test
void givenDateTimeFormat_whenParsing_thenVerifyResults() {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm");

    LocalDateTime parsedDateTime = LocalDateTime.parse("18-09-2024 10:30", formatter);

    assertThat(parsedDateTime)
            .isNotNull()
            .satisfies(time -> {
                assertThat(time.getYear()).isEqualTo(2024);
                assertThat(time.getMonth()).isEqualTo(Month.SEPTEMBER);
                assertThat(time.getDayOfMonth()).isEqualTo(18);
                assertThat(time.getHour()).isEqualTo(10);
                assertThat(time.getMinute()).isEqualTo(30);
            });
}

4.5. 时区处理:OffsetDateTimeOffsetTime

处理多时区场景时,OffsetDateTimeOffsetTime类非常实用:

@Test
void givenVariousTimeZones_whenCreatingOffsetDateTime_thenVerifyOffsets() {
    ZoneId parisZone = ZoneId.of("Europe/Paris");
    ZoneId nyZone = ZoneId.of("America/New_York");

    OffsetDateTime parisTime = OffsetDateTime.now(parisZone);
    OffsetDateTime nyTime = OffsetDateTime.now(nyZone);

    assertThat(parisTime)
            .isNotNull()
            .satisfies(time -> {
                assertThat(time.getOffset().getTotalSeconds())
                        .isEqualTo(parisZone.getRules().getOffset(Instant.now()).getTotalSeconds());
            });

    // 验证时区间时间差
    assertThat(ChronoUnit.HOURS.between(nyTime, parisTime) % 24)
            .isGreaterThanOrEqualTo(5)  // 纽约通常比巴黎晚5-6小时
            .isLessThanOrEqualTo(7);
}

示例展示了如何创建不同时区的OffsetDateTime实例并验证其偏移量。首先使用ZoneId定义巴黎和纽约时区,然后通过OffsetDateTime.now()获取当前时间。

测试验证了巴黎时间的偏移量是否符合预期,并检查纽约和巴黎的时间差是否在5-7小时的合理范围内。

4.6. 高级用法:Clock

Clock类提供了灵活的时间访问方式,在需要时间控制或测试时间相关逻辑时特别有用

与直接使用LocalDateTime.now()不同,Clock允许基于特定时区获取时间,甚至模拟时间流逝。通过向Clock.system()传递ZoneId,可获取任意地区的当前时间:

@Test
void givenSystemClock_whenComparingDifferentTimeZones_thenVerifyRelationships() {
    Clock nyClock = Clock.system(ZoneId.of("America/New_York"));

    LocalDateTime nyTime = LocalDateTime.now(nyClock);

    assertThat(nyTime)
            .isNotNull()
            .satisfies(time -> {
                assertThat(time.getHour()).isBetween(0, 23);
                assertThat(time.getMinute()).isBetween(0, 59);
                // 验证时间在最近1分钟内
                assertThat(time).isCloseTo(
                        LocalDateTime.now(),
                        within(1, ChronoUnit.MINUTES)
                );
            });
}

Clock在需要管理多时区或统一控制时间流的应用中极具价值。

5. 传统类到现代类的迁移

维护遗留系统时,可能仍需处理使用DateCalendar的代码。幸运的是,我们可以将传统类迁移到现代日期时间类

5.1. DateInstant

**传统Date类可通过toInstant()方法轻松转换为Instant**。这在迁移到java.time包时非常有用,因为Instant表示时间线上的瞬时点:

@Test
void givenSameEpochMillis_whenConvertingDateAndInstant_thenCorrect() {
    long epochMillis = System.currentTimeMillis();
    Date legacyDate = new Date(epochMillis);
    Instant instant = Instant.ofEpochMilli(epochMillis);
    
    assertEquals(
      legacyDate.toInstant(),
      instant,
      "Date和Instant应表示同一时刻"
    );
}

通过相同的毫秒值创建DateInstant,验证它们表示同一时间点。

5.2. CalendarZonedDateTime

处理Calendar时,可迁移到更现代的ZonedDateTime,它能同时处理日期时间和时区信息:

@Test
void givenCalendar_whenConvertingToZonedDateTime_thenCorrect() {
    Calendar calendar = Calendar.getInstance();
    calendar.set(2024, Calendar.SEPTEMBER, 18, 10, 30);
    ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(
      calendar.toInstant(),
      calendar.getTimeZone().toZoneId()
    );

    assertEquals(LocalDate.of(2024, 9, 18), zonedDateTime.toLocalDate());
    assertEquals(LocalTime.of(10, 30), zonedDateTime.toLocalTime());
}

示例将Calendar实例转换为ZonedDateTime,并验证它们表示相同的日期时间。

6. 最佳实践

以下是使用java.time类的一些最佳实践

  1. ✅ 新项目必须使用java.time
  2. ✅ 不需要时区时使用LocalDate/LocalTime/LocalDateTime
  3. ✅ 处理时区或时间戳时使用ZonedDateTimeInstant
  4. ✅ 使用DateTimeFormatter进行日期解析和格式化
  5. ✅ 始终明确指定时区以避免混淆

这些实践为Java日期时间处理奠定了坚实基础,确保应用能高效准确地处理日期时间逻辑。

7. 总结

Java 8引入的java.time包彻底革新了日期时间处理方式。采用该API能显著提升代码的简洁性和可维护性。

尽管可能遇到使用DateCalendar的遗留代码,但在新开发中采用java.timeAPI是明智之选。遵循本文概述的最佳实践,将帮助开发者编写更简洁、高效且可维护的代码。

完整源代码请参考GitHub仓库


原始标题:Java Date and Calendar: From Legacy to Modern Approaches | Baeldung