Java 日历 clear() 更改夏令时

Java Calendar clear() changes DST

首先,我想声明我知道 Java Calendar class 正在被其他可以说更好的库所取代。也许我偶然发现了日历失宠的原因之一。

我 运行 对日历中令人沮丧的行为感到沮丧,因为它涉及夏令时结束时的重叠时间。

public void annoying_issue()
{
    Calendar midnightPDT = Calendar.getInstance(TimeZone.getTimeZone("US/Pacific"));
    midnightPDT.set(Calendar.YEAR, 2021);
    midnightPDT.set(Calendar.MONTH, 10);
    midnightPDT.set(Calendar.DAY_OF_MONTH, 7);
    midnightPDT.set(Calendar.HOUR_OF_DAY, 0);
    midnightPDT.set(Calendar.MINUTE, 0);
    midnightPDT.set(Calendar.SECOND, 0);
    midnightPDT.set(Calendar.MILLISECOND, 0);

    Calendar oneAMPDT = Calendar.getInstance(TimeZone.getTimeZone("US/Pacific"));
    oneAMPDT.setTimeInMillis(midnightPDT.getTimeInMillis() + (60*60*1000));//this is the easiest way I've found to get to the first 1am hour at DST overlap

    System.out.println(new Date(midnightPDT.getTimeInMillis()));//prints the expected "Sun Nov 7 00:00:00 PDT 2021" 
    System.out.println(new Date(oneAMPDT.getTimeInMillis()));//prints "Sun Nov 7 01:00:00 PDT 2021" also expected

    oneAMPDT.clear(Calendar.MINUTE);//minute is already 0 so no change should occur... RIGHT!? 
    
    //WRONG!!!!
    //The time is now in PST! The millisecond value has increased by 3600000, too!!
    System.out.println(new Date(oneAMPDT.getTimeInMillis()));//prints "Sun Nov 7 01:00:00 PST 2021"
}

根据评论,您会发现清除日历中的 MINUTE 字段实际上将它提前了一个小时!见鬼!?

当我使用oneAMPDT.set(Calendar.MINUTE, 0)

时也会出现这种情况

这是预期的行为吗?有什么办法可以避免这种情况吗?

避免遗留日期时间classes;需要时转换

如您所述,Calendar 几年前被 JSR 310 中定义的 java.time classes 取代(一致采用)。正如您所注意到的,有 许多 原因可以避免使用 CalendarDate

如果您必须有一个 Calendar 对象来与尚未更新到 java.time 的旧代码进行互操作,请在完成 [=104] 中的工作后进行转换=]java.time.

java.time

指定您想要的时区。请注意 US/Pacific 只是实际时区的别名,America/Los_Angeles.

ZoneId zLosAngeles = ZoneId.of( "America/Los_Angeles" ) ;

指定您想要的时刻。

LocalDate ld = LocalDate.of( 2021 , Month.NOVEMBER , 7 ) ;

在您的代码中,您似乎假设一天的第一时刻发生在 00:00。情况并非总是如此。某些时区的某些日期可能会在另一个时间开始。所以让java.time确定一天的第一个时刻。

ZonedDateTime firstMomentOfThe7thInLosAngeles = ld.atStartOfDay( zLosAngeles ) ;

firstMomentOfThe7thInLosAngeles.toString(): 2021-11-07T00:00-07:00[America/Los_Angeles]

然后你跳到另一个时刻,到凌晨 1 点。

ZonedDateTime oneAmOnThe7thLosAngeles = firstMomentOfThe7thInLosAngeles.with( LocalTime.of( 1 , 0 ) ) ;

oneAmOnThe7thLosAngeles.toString(): 2021-11-07T01:00-07:00[America/Los_Angeles]

那个时间在那个日期可能存在也可能不存在。 ZonedDateTime class 将根据需要进行调整。

您为变量使用了名称 midnightPDT。我建议避免使用术语 midnight,因为它的使用会混淆日期时间处理而没有精确的定义。如果您是这个意思,我建议您使用术语“一天中的第一刻”。

您提取自 1970 年第一时刻的纪元参考以来的毫秒数,如 UTC,1970-01-01T00:00Z.

Instant firstMomentOfThe7thInLosAngelesAsSeenInUtc = firstMomentOfThe7thInLosAngeles.toInstant() ;
long millisSinceEpoch_FirstMomentOf7thLosAngeles = firstMomentOfThe7thInLosAngelesAsSeenInUtc.toEpochMilli() ;

firstMomentOfThe7thInLosAngelesAsSeenInUtc.toString(): 2021-11-07T07:00:00Z

millisSinceEpoch_FirstMomentOf7thLosAngeles = 1636268400000

你也为我们的凌晨 1 点做同样的事情。

Instant oneAmOnThe7thLosAngelesAsSeenInUtc = oneAmOnThe7thLosAngeles.toInstant() ;
long millisSinceEpoch_OneAmOn7thLosAngeles = oneAmOnThe7thLosAngelesAsSeenInUtc.toEpochMilli() ;

oneAmOnThe7thLosAngelesAsSeenInUtc.toString(): 2021-11-07T08:00:00Z

millisSinceEpoch_OneAmOn7thLosAngeles = 1636272000000

我们应该看到一小时的差异。一个小时 = 3,600,000 = 60 * 60 * 1,000.

long diff = ( millisSinceEpoch_OneAmOn7thLosAngeles - millisSinceEpoch_FirstMomentOf7thLosAngeles );  // 3,600,000 = 60 * 60 * 1,000.

diff = 3600000

转换

然后你继续提到 Daylight Saving Time (DST) 切换。那天美国夏令时的切换时间是凌晨 2 点,而不是凌晨 1 点。在凌晨 2 点到来的那一刻,时钟摆回凌晨 1 点,持续了第二个 1:00-2:00 AM 小时。

为了达到那个切换点,让我们加一个小时。

ZonedDateTime cutover_Addition = oneAmOnThe7thLosAngeles.plusHours( 1 );

cutover_Addition = 2021-11-07T01:00-08:00[America/Los_Angeles]

请注意,一天中的时间显示相同(凌晨 1 点),但与 UTC 的偏移量已从比 UTC 晚 7 小时更改为现在比 UTC 晚 8 小时。这就是你要找的时差。

让我们计算第三时刻自纪元以来的毫秒数。在我们有一天的第一时刻 (00:00) 之前,第一个发生在凌晨 1 点,现在我们有第二个发生在 2021 年 11 月 7 日这个“回退”日期的凌晨 1 点。

long millisSinceEpoch_Cutover = cutover_Addition.toInstant().toEpochMilli();

1636275600000

Duration.between( firstMomentOfThe7thInLosAngelesAsSeenInUtc , cutover_Addition.toInstant() ) = PT2H

Duration.between( oneAmOnThe7thLosAngelesAsSeenInUtc , cutover_Addition.toInstant() ) = PT1H

ZonedDateTime class 确实提供了在这些切换时刻的一对使用方法: withEarlierOffsetAtOverlapwithLaterOffsetAtOverlap.

ZonedDateTime cutover_OverlapEarlier =
        cutover_Addition
                .withEarlierOffsetAtOverlap();
ZonedDateTime cutover_OverlapLater =
        cutover_Addition
                .withLaterOffsetAtOverlap();

cutover_OverlapEarlier = 2021-11-07T01:00-07:00[America/Los_Angeles]

cutover_OverlapLater = 2021-11-07T01:00-08:00[America/Los_Angeles]

Calendar

如果您确实需要一个 Calendar 对象,只需转换即可。

Calendar x = GregorianCalendar.from( firstMomentOfThe7thInLosAngeles ) ;
Calendar y = GregorianCalendar.from( oneAmOnThe7thLosAngeles ) ;
Calendar z = GregorianCalendar.from( cutover_Addition );

如果您的目标只是努力理解 Calendar class 行为,我建议您停止自虐。无关紧要。 Sun、Oracle 和 JCP 社区 all gave up 对那些可怕的遗留日期时间 classes。我建议你也这样做。

示例代码

将上面的所有代码放在一起。

ZoneId zLosAngeles = ZoneId.of( "America/Los_Angeles" );

LocalDate ld = LocalDate.of( 2021 , Month.NOVEMBER , 7 );

ZonedDateTime firstMomentOfThe7thInLosAngeles = ld.atStartOfDay( zLosAngeles );
ZonedDateTime oneAmOnThe7thLosAngeles = firstMomentOfThe7thInLosAngeles.with( LocalTime.of( 1 , 0 ) );

Instant firstMomentOfThe7thInLosAngelesAsSeenInUtc = firstMomentOfThe7thInLosAngeles.toInstant();
long millisSinceEpoch_FirstMomentOf7thLosAngeles = firstMomentOfThe7thInLosAngelesAsSeenInUtc.toEpochMilli();

Instant oneAmOnThe7thLosAngelesAsSeenInUtc = oneAmOnThe7thLosAngeles.toInstant();
long millisSinceEpoch_OneAmOn7thLosAngeles = oneAmOnThe7thLosAngelesAsSeenInUtc.toEpochMilli();

long diff = ( millisSinceEpoch_OneAmOn7thLosAngeles - millisSinceEpoch_FirstMomentOf7thLosAngeles );  // 3,600,000 = 60 * 60 * 1,000.

ZonedDateTime cutover_Addition = oneAmOnThe7thLosAngeles.plusHours( 1 );
long millisSinceEpoch_Cutover = cutover_Addition.toInstant().toEpochMilli();
ZonedDateTime cutover_OverlapEarlier =
        cutover_Addition
                .withEarlierOffsetAtOverlap();
ZonedDateTime cutover_OverlapLater =
        cutover_Addition
                .withLaterOffsetAtOverlap();

转换为旧版 classes,如果需要的话。

Calendar x = GregorianCalendar.from( firstMomentOfThe7thInLosAngeles );
Calendar y = GregorianCalendar.from( oneAmOnThe7thLosAngeles );
Calendar z = GregorianCalendar.from( cutover_Addition );

转储到控制台。

System.out.println( "firstMomentOfThe7thInLosAngeles = " + firstMomentOfThe7thInLosAngeles );
System.out.println( "oneAmOnThe7thLosAngeles = " + oneAmOnThe7thLosAngeles );

System.out.println( "firstMomentOfThe7thInLosAngelesAsSeenInUtc = " + firstMomentOfThe7thInLosAngelesAsSeenInUtc );
System.out.println( "millisSinceEpoch_FirstMomentOf7thLosAngeles = " + millisSinceEpoch_FirstMomentOf7thLosAngeles );

System.out.println( "oneAmOnThe7thLosAngelesAsSeenInUtc = " + oneAmOnThe7thLosAngelesAsSeenInUtc );
System.out.println( "millisSinceEpoch_OneAmOn7thLosAngeles = " + millisSinceEpoch_OneAmOn7thLosAngeles );

System.out.println( "diff = " + diff );

System.out.println( "x = " + x );
System.out.println( "y = " + y );
System.out.println( "z = " + z );

System.out.println( "cutover_Addition = " + cutover_Addition );
System.out.println( "millisSinceEpoch_Cutover = " + millisSinceEpoch_Cutover );
System.out.println( "Duration.between( firstMomentOfThe7thInLosAngelesAsSeenInUtc , cutover_Addition.toInstant() ) = " + Duration.between( firstMomentOfThe7thInLosAngelesAsSeenInUtc , cutover_Addition.toInstant() ) );
System.out.println( "Duration.between( oneAmOnThe7thLosAngelesAsSeenInUtc , cutover_Addition.toInstant() ) = " + Duration.between( oneAmOnThe7thLosAngelesAsSeenInUtc , cutover_Addition.toInstant() ) );
System.out.println( "cutover_OverlapEarlier = " + cutover_OverlapEarlier );
System.out.println( "cutover_OverlapLater = " + cutover_OverlapLater );

当运行.

firstMomentOfThe7thInLosAngeles = 2021-11-07T00:00-07:00[America/Los_Angeles]
oneAmOnThe7thLosAngeles = 2021-11-07T01:00-07:00[America/Los_Angeles]
firstMomentOfThe7thInLosAngelesAsSeenInUtc = 2021-11-07T07:00:00Z
millisSinceEpoch_FirstMomentOf7thLosAngeles = 1636268400000
oneAmOnThe7thLosAngelesAsSeenInUtc = 2021-11-07T08:00:00Z
millisSinceEpoch_OneAmOn7thLosAngeles = 1636272000000
diff = 3600000
x = java.util.GregorianCalendar[time=1636268400000,areFieldsSet=true,areAllFieldsSet=true,lenient=true,zone=sun.util.calendar.ZoneInfo[id="America/Los_Angeles",offset=-28800000,dstSavings=3600000,useDaylight=true,transitions=185,lastRule=java.util.SimpleTimeZone[id=America/Los_Angeles,offset=-28800000,dstSavings=3600000,useDaylight=true,startYear=0,startMode=3,startMonth=2,startDay=8,startDayOfWeek=1,startTime=7200000,startTimeMode=0,endMode=3,endMonth=10,endDay=1,endDayOfWeek=1,endTime=7200000,endTimeMode=0]],firstDayOfWeek=2,minimalDaysInFirstWeek=4,ERA=1,YEAR=2021,MONTH=10,WEEK_OF_YEAR=44,WEEK_OF_MONTH=1,DAY_OF_MONTH=7,DAY_OF_YEAR=311,DAY_OF_WEEK=1,DAY_OF_WEEK_IN_MONTH=1,AM_PM=0,HOUR=0,HOUR_OF_DAY=0,MINUTE=0,SECOND=0,MILLISECOND=0,ZONE_OFFSET=-28800000,DST_OFFSET=3600000]
y = java.util.GregorianCalendar[time=1636272000000,areFieldsSet=true,areAllFieldsSet=true,lenient=true,zone=sun.util.calendar.ZoneInfo[id="America/Los_Angeles",offset=-28800000,dstSavings=3600000,useDaylight=true,transitions=185,lastRule=java.util.SimpleTimeZone[id=America/Los_Angeles,offset=-28800000,dstSavings=3600000,useDaylight=true,startYear=0,startMode=3,startMonth=2,startDay=8,startDayOfWeek=1,startTime=7200000,startTimeMode=0,endMode=3,endMonth=10,endDay=1,endDayOfWeek=1,endTime=7200000,endTimeMode=0]],firstDayOfWeek=2,minimalDaysInFirstWeek=4,ERA=1,YEAR=2021,MONTH=10,WEEK_OF_YEAR=44,WEEK_OF_MONTH=1,DAY_OF_MONTH=7,DAY_OF_YEAR=311,DAY_OF_WEEK=1,DAY_OF_WEEK_IN_MONTH=1,AM_PM=0,HOUR=1,HOUR_OF_DAY=1,MINUTE=0,SECOND=0,MILLISECOND=0,ZONE_OFFSET=-28800000,DST_OFFSET=3600000]
z = java.util.GregorianCalendar[time=1636275600000,areFieldsSet=true,areAllFieldsSet=true,lenient=true,zone=sun.util.calendar.ZoneInfo[id="America/Los_Angeles",offset=-28800000,dstSavings=3600000,useDaylight=true,transitions=185,lastRule=java.util.SimpleTimeZone[id=America/Los_Angeles,offset=-28800000,dstSavings=3600000,useDaylight=true,startYear=0,startMode=3,startMonth=2,startDay=8,startDayOfWeek=1,startTime=7200000,startTimeMode=0,endMode=3,endMonth=10,endDay=1,endDayOfWeek=1,endTime=7200000,endTimeMode=0]],firstDayOfWeek=2,minimalDaysInFirstWeek=4,ERA=1,YEAR=2021,MONTH=10,WEEK_OF_YEAR=44,WEEK_OF_MONTH=1,DAY_OF_MONTH=7,DAY_OF_YEAR=311,DAY_OF_WEEK=1,DAY_OF_WEEK_IN_MONTH=1,AM_PM=0,HOUR=1,HOUR_OF_DAY=1,MINUTE=0,SECOND=0,MILLISECOND=0,ZONE_OFFSET=-28800000,DST_OFFSET=0]
cutover_Addition = 2021-11-07T01:00-08:00[America/Los_Angeles]
millisSinceEpoch_Cutover = 1636275600000
Duration.between( firstMomentOfThe7thInLosAngelesAsSeenInUtc , cutover_Addition.toInstant() ) = PT2H
Duration.between( oneAmOnThe7thLosAngelesAsSeenInUtc , cutover_Addition.toInstant() ) = PT1H
cutover_OverlapEarlier = 2021-11-07T01:00-07:00[America/Los_Angeles]
cutover_OverlapLater = 2021-11-07T01:00-08:00[America/Los_Angeles]

java.time

这是预期的行为吗?不是。我认为这是一个错误。

有没有办法防止这种情况? 是的,您已经提到或至少暗示的方式:使用 ZonedDateTime 而不是 Calendar。 Basil Bourque 已经说过了。作为适度的补充,我想展示从 CalendarZonedDateTime 的完整往返,将分钟设置为 0 并转换回 Calendar。如果您需要它来与遗留代码进行互操作。

    GregorianCalendar oneAmPdt = new GregorianCalendar(TimeZone.getTimeZone(ZoneId.of("America/Los_Angeles")));
    oneAmPdt.clear();
    oneAmPdt.set(2021, Calendar.NOVEMBER, 7, 0, 0);
    oneAmPdt.add(Calendar.HOUR_OF_DAY, 1);
    System.out.println(oneAmPdt.getTime());

    ZonedDateTime zdt = oneAmPdt.toZonedDateTime();

    // Minute is already 0 so no change should occur... RIGHT!?
    zdt = zdt.withMinute(0);

    oneAmPdt = GregorianCalendar.from(zdt);

    System.out.println(oneAmPdt.getTime());

输出:

Sun Nov 07 01:00:00 PDT 2021
Sun Nov 07 01:00:00 PDT 2021

但是我用的是GregorianCalendar,不是Calendar?你也是。 GregorianCalendar 是您从 Calendar.getIntance() 得到的 Calendar 的子 class。在某些环境中,你会得到一个不同的 subclass 来反映那里使用的日历系统,而你对 set 的初始调用不会给你预期的结果。在这种情况下,您 想要 GregorianCalendar(如果您不能从一开始就拥有 ZonedDateTime)。

在修改我们的旧代码时,即使不是为了规避旧 CalendarGregorianCalendar class 中的错误,我也可能会按照上述方式进行操作。这是 运行 向 java.time 过渡的一小步。