在Java中处理夏令时

2025/04/14

1. 概述

夏令时(或称DST)是一种在夏季提前时钟的做法,以便利用额外一小时的自然光(节省取暖电力、照明电力、改善心情等)。

它被多个国家使用,在处理日期和时间戳时需要考虑到它。

在本教程中,我们将了解如何根据不同位置正确在Java中处理DST。

2. JRE和DST可变性

首先,务必了解全球夏令时区域经常变化,并且没有中央机构进行协调。

一个国家,或者在某些情况下甚至是一个城市,可以决定是否以及如何应用或撤销它

每次发生这种情况时,更改都会记录在IANA时区数据库中,并且更新将在JRE的未来版本中推出。

如果无法等待,我们可以通过Oracle官方工具Java时区更新工具(Java Time Zone Updater Tool)将包含新DST设置的修改后的时区数据强制放入JRE,该工具可在Java SE下载页面上找到。

3. 错误方法:三个字母的时区ID

在JDK 1.1时代,API允许使用3个字母的时区ID,但这导致了一些问题。

首先,这是因为同一个3个字母的ID可能指代多个时区。例如,CST可能是美国的“中部标准时间”,也可能是“中国标准时间”。因此,Java平台只能识别其中一个。

另一个问题是,标准时区从不考虑夏令时,多个地区/区域/城市可以在同一个标准时区内使用各自的夏令时,因此标准时间不会遵循夏令时。

由于向后兼容,仍然可以使用3个字母的ID实例化java.util.Timezone。但是,此方法已弃用,不应再使用

4. 正确方法:TZDB时区ID

在Java中处理DST的正确方法是使用特定的TZDB时区ID实例化时区,例如“Europe/Rome”。

然后,我们将结合时间特定的类(如java.util.Calendar)使用它来获得TimeZone的原始偏移量(到GMT时区)的正确配置,以及自动DST偏移调整。

让我们看看当使用正确的时区时,如何自动处理从GMT+1到GMT+2的转变(发生在意大利,2018年3月25日,凌晨2:00):

TimeZone tz = TimeZone.getTimeZone("Europe/Rome");
TimeZone.setDefault(tz);
Calendar cal = Calendar.getInstance(tz, Locale.ITALIAN);
DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.ITALIAN);
Date dateBeforeDST = df.parse("2018-03-25 01:55");
cal.setTime(dateBeforeDST);
 
assertThat(cal.get(Calendar.ZONE_OFFSET)).isEqualTo(3600000);
assertThat(cal.get(Calendar.DST_OFFSET)).isEqualTo(0);

我们可以看到,ZONE_OFFSET为60分钟(因为意大利是GMT+1),而当时DST_OFFSET为0。

让我们在Calendar中增加十分钟:

cal.add(Calendar.MINUTE, 10);

现在DST_OFFSET也变成了60分钟,并且该国已将其当地时间从CET(中欧时间)转换为CEST(中欧夏令时),即GMT+2:

Date dateAfterDST = cal.getTime();
 
assertThat(cal.get(Calendar.DST_OFFSET))
    .isEqualTo(3600000);
assertThat(dateAfterDST)
    .isEqualTo(df.parse("2018-03-25 03:05"));

如果我们在控制台中显示这两个日期,我们还会看到时区也发生了变化:

Before DST (00:55 UTC - 01:55 GMT+1) = Sun Mar 25 01:55:00 CET 2018
After DST (01:05 UTC - 03:05 GMT+2) = Sun Mar 25 03:05:00 CEST 2018

作为最后的测试,我们可以测量两个日期之间的距离,1:55和3:05:

Long deltaBetweenDatesInMillis = dateAfterDST.getTime() - dateBeforeDST.getTime();
Long tenMinutesInMillis = (1000L * 60 * 10);
 
assertThat(deltaBetweenDatesInMillis)
    .isEqualTo(tenMinutesInMillis);

正如我们所料,距离是10分钟,而不是70分钟。

我们已经了解了如何通过正确使用TimeZone和Locale来避免在使用Date时可能遇到的常见陷阱。

5. 最佳方法:Java 8日期/时间API

使用这些线程不安全且并不总是用户友好的java.util类一直很困难,特别是由于兼容性问题导致它们无法被正确重构。

为此,Java 8引入了一个全新的包java.time,以及一套全新的API,即Date/Time API。它以ISO为中心,完全线程安全,并深受著名库Joda-Time的启发。

让我们仔细看看这个新类,从java.util.Date的后继者java.time.LocalDateTime开始:

LocalDateTime localDateTimeBeforeDST = LocalDateTime
    .of(2018, 3, 25, 1, 55);
 
assertThat(localDateTimeBeforeDST.toString())
    .isEqualTo("2018-03-25T01:55");

我们可以观察LocalDateTime如何符合ISO8601配置文件,这是一种标准且广泛采用的日期时间符号。

但是,它完全不知道区域和偏移量,这就是为什么我们需要将其转换为完全支持DST的java.time.ZonedDateTime

ZoneId italianZoneId = ZoneId.of("Europe/Rome");
ZonedDateTime zonedDateTimeBeforeDST = localDateTimeBeforeDST
    .atZone(italianZoneId);
 
assertThat(zonedDateTimeBeforeDST.toString())
    .isEqualTo("2018-03-25T01:55+01:00[Europe/Rome]");

我们可以看到,现在日期包含两个基本尾随信息:+01:00是ZoneOffset,而[Europe/Rome]是ZoneId。

与前面的示例类似,让我们通过增加十分钟来触发DST:

ZonedDateTime zonedDateTimeAfterDST = zonedDateTimeBeforeDST
    .plus(10, ChronoUnit.MINUTES);
 
assertThat(zonedDateTimeAfterDST.toString())
    .isEqualTo("2018-03-25T03:05+02:00[Europe/Rome]");

再次,我们看到时间和区域偏移如何向前移动,并且仍然保持相同的距离:

Long deltaBetweenDatesInMinutes = ChronoUnit.MINUTES
    .between(zonedDateTimeBeforeDST,zonedDateTimeAfterDST);
assertThat(deltaBetweenDatesInMinutes)
    .isEqualTo(10);

6. 总结

我们通过不同版本的Java核心API中的一些实际示例了解了夏令时是什么以及如何处理它。

在使用Java 8及更高版本时,鼓励使用新的java.time包,因为它易于使用且具有标准、线程安全的特性。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章