GregorianCalendar.set() 与夏令时的行为

Behaviour of GregorianCalendar.set() with daylight saving times

在排除故障时,此方法行为中出现了一些奇怪的现象。


上下文

一些国家过去常常通过改变时间来节省一些日光。例如,在时区“Europe/Paris”中,每年 3 月末的时间向前移动 1 小时,10 月末的时间向后移动 1 小时,均在凌晨 2 点和凌晨 3 点之间。例如,这会导致日期 2016 年 10 月 30 日 02:15 AM 出现两次。

幸运的是,两个日期没有相同的时间戳(毫秒)量,也不是可读表示:

问题

在巴黎时区(使用 SimpleDateFormat)实例化一个 GregorianCalendar 对象后,我们在向后移动之前得到我们的 02:15 AM,正如预期的那样。

但是,如果我们想使用 .set() 为该对象设置分钟,+0200 偏移信息会损坏为 +0100(“同一时间”,但在时移之后) 有没有办法这样做,因为方法 .add() 实际上保留了偏移信息?

例子

    // Instantiation
    GregorianCalendar gc1 = new GregorianCalendar(TimeZone.getTimeZone("Europe/Paris"));
    gc1.setTime(new SimpleDateFormat("yyyy-MM-dd hh:mm:ss Z").parse("2016-10-30 02:15:00 +0200"));
    GregorianCalendar gc2 = (GregorianCalendar) gc1.clone();
    
    System.out.println(gc1.getTime()); // Output : Sun Oct 30 02:15:00 CEST 2016 ; OK
    System.out.println(gc2.getTime()); // Output : Sun Oct 30 02:15:00 CEST 2016 ; OK
    
    // Set/add minutes
    gc1.set(Calendar.MINUTE, 10);
    gc2.add(Calendar.MINUTE, 10);
    
    System.out.println(gc1.getTime()); // Output : Sun Oct 30 02:10:00 CET 2016 ; Unexpected
    System.out.println(gc2.getTime()); // Output : Sun Oct 30 02:25:00 CEST 2016 ; OK

一个丑陋的替代方法是在设置值后从 gc1 中减去 1 小时:

gc1.set(Calendar.MINUTE, 10);
gc1.add(Calendar.HOUR, -1);

结果将是 Sun Oct 30 02:10:00 CEST 2016

不幸的是,这似乎是 Calendar API 可用的最佳解决方案。 set 方法的这种行为被报告为错误,the recommendation in JDK bug tracker 是使用 add 来“修复”它:

During the "fall-back" period, Calendar doesn't support disambiguation and the given local time is interpreted as standard time.

To avoid the unexpected DST to standard time change, call add() to reset the value.

我正在使用 Java 8,所以这个错误似乎从未被修复。


Java新Date/TimeAPI

旧的 类(DateCalendarSimpleDateFormat)有 lots of problems and design issues,它们正在被新的 APIs.

如果您正在使用 Java 8,请考虑使用 new java.time API. It's easier, less bugged and less error-prone than the old APIs.

如果您使用 Java <= 7,您可以使用 ThreeTen Backport, a great backport for Java 8's new date/time classes. And for Android, there's the ThreeTenABP (more on how to use it ).

下面的代码适用于两者。 唯一的区别是:

  • 包名(在Java 8中是java.time,在ThreeTen Backport(或Android的ThreeTenABP)中是org.threeten.bp),但是类和方法名称相同。
  • 转换from/to旧CalendarAPI

要将 GregorianCalendar 转换为新的 API,您可以执行以下操作:

// Paris timezone
ZoneId zone = ZoneId.of("Europe/Paris");
// convert GregorianCalendar to ZonedDateTime
ZonedDateTime z = Instant.ofEpochMilli(gc1.getTimeInMillis()).atZone(zone);

在Java8中,你还可以这样做:

ZonedDateTime z = gc1.toInstant().atZone(zone);

要获取与 java.util.Date 生成的格式相同的日期,您可以使用 DateTimeFormatter:

DateTimeFormatter fmt = DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss z yyyy", Locale.ENGLISH);
System.out.println(fmt.format(z));

输出为:

Sun Oct 30 02:15:00 CEST 2016

我使用 java.util.Locale 强制将语言环境设置为英语,因此月份名称和星期几的格式正确。如果您不指定区域设置,它将使用系统的默认设置,并且不能保证始终是英语(并且默认设置也可以在不通知的情况下更改,即使在运行时也是如此,因此最好始终明确说明是哪个区域设置你正在使用)。

有了这个 API 您可以轻松添加或设置分钟:

// change the minutes to 10
ZonedDateTime z2 = z.withMinute(10);
System.out.println(fmt.format(z2)); // Sun Oct 30 02:10:00 CEST 2016

// add 10 minutes
ZonedDateTime z3 = z.plusMinutes(10);
System.out.println(fmt.format(z3)); // Sun Oct 30 02:25:00 CEST 2016

输出将是:

Sun Oct 30 02:10:00 CEST 2016
Sun Oct 30 02:25:00 CEST 2016

请注意,在新的 API 中,类 是不可变的,因此 withplus 方法 return 一个新实例。

要将 ZonedDateTime 转换回 GregorianCalendar,您可以这样做:

gc1.setTimeInMillis(z.toInstant().toEpochMilli());

在java8中,你还可以这样做:

gc1 = GregorianCalendar.from(z);

要解析输入 2016-10-30 02:15:00 +0200,您必须使用另一个格式化程序:

DateTimeFormatter parser = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss XX")
    .withZone(ZoneId.of("Europe/Paris"));
ZonedDateTime z = ZonedDateTime.parse("2016-10-30 02:15:00 +0200", parser);

我必须使用 withZone 方法设置时区,因为仅偏移量 +0200 不足以确定它(more than one timezone can use this same offset 和 API 不能决定使用哪一个)