First, I want to state that I know the Java Calendar class is being supplanted by other libraries that are arguably better. Perhaps I’ve stumbled upon one of the reasons Calendar has fallen out of favor.
I ran into frustrating behavior in Calendar as it regards to the overlapping hour at the end of daylight savings time.
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" }
Following along with the comments you’ll see that clearing the MINUTE field in the calendar actually moved it up an hour! The HECK!?
This also occurs when I use oneAMPDT.set(Calendar.MINUTE, 0)
Is this expected behavior? Is there a way to prevent this?
Advertisement
Answer
Avoid legacy date-time classes; convert if needed
As you noted, Calendar
was supplanted years ago by the java.time classes defined in JSR 310 (unanimously adopted). And as you note there are many reasons to avoid using Calendar
& Date
etc.
If you must have a Calendar
object to interoperate with old code not yet updated to java.time, convert after doing your work in java.time.
java.time
Specify your desired time zone. Note that US/Pacific
is merely an alias for the actual time zone, America/Los_Angeles
.
ZoneId zLosAngeles = ZoneId.of( "America/Los_Angeles" ) ;
Specify your desired moment.
LocalDate ld = LocalDate.of( 2021 , Month.NOVEMBER , 7 ) ;
In your code, you seem to assume the first moment of the day occurs at 00:00. That is not always the case. Some dates in some time zones may start at another time. So let java.time determine the first moment of the day.
ZonedDateTime firstMomentOfThe7thInLosAngeles = ld.atStartOfDay( zLosAngeles ) ;
firstMomentOfThe7thInLosAngeles.toString(): 2021-11-07T00:00-07:00[America/Los_Angeles]
But then you jumped to another moment, to 1 AM.
ZonedDateTime oneAmOnThe7thLosAngeles = firstMomentOfThe7thInLosAngeles.with( LocalTime.of( 1 , 0 ) ) ;
oneAmOnThe7thLosAngeles.toString(): 2021-11-07T01:00-07:00[America/Los_Angeles]
That time-of-day may or may not exist on that date in that zone. The ZonedDateTime
class will adjust if need be.
You used the name midnightPDT
for a variable. I suggest avoiding the term midnight
as its use confuses date-time handling with out a precise definition. I recommend using the term “first moment of the day” if that is what you mean.
You extract a count of milliseconds since the epoch reference of first moment of 1970 as seen in UTC, 1970-01-01T00:00Z.
Instant firstMomentOfThe7thInLosAngelesAsSeenInUtc = firstMomentOfThe7thInLosAngeles.toInstant() ; long millisSinceEpoch_FirstMomentOf7thLosAngeles = firstMomentOfThe7thInLosAngelesAsSeenInUtc.toEpochMilli() ;
firstMomentOfThe7thInLosAngelesAsSeenInUtc.toString(): 2021-11-07T07:00:00Z
millisSinceEpoch_FirstMomentOf7thLosAngeles = 1636268400000
And you do the same for our 1 AM moment.
Instant oneAmOnThe7thLosAngelesAsSeenInUtc = oneAmOnThe7thLosAngeles.toInstant() ; long millisSinceEpoch_OneAmOn7thLosAngeles = oneAmOnThe7thLosAngelesAsSeenInUtc.toEpochMilli() ;
oneAmOnThe7thLosAngelesAsSeenInUtc.toString(): 2021-11-07T08:00:00Z
millisSinceEpoch_OneAmOn7thLosAngeles = 1636272000000
We should see a difference of one hour. An hour = 3,600,000 = 60 * 60 * 1,000.
long diff = ( millisSinceEpoch_OneAmOn7thLosAngeles - millisSinceEpoch_FirstMomentOf7thLosAngeles ); // 3,600,000 = 60 * 60 * 1,000.
diff = 3600000
Cutover
Then you go on to mention the Daylight Saving Time (DST) cutover. The cutover for DST in the United States on that date was 2 AM, not 1 AM. At the moment of 2 AM arriving, the clocks swung back to 1 AM, for a second 1:00-2:00 AM hour.
To get to that point of cutover, let’s add an hour.
ZonedDateTime cutover_Addition = oneAmOnThe7thLosAngeles.plusHours( 1 );
cutover_Addition = 2021-11-07T01:00-08:00[America/Los_Angeles]
Notice that the time-of-day shows the same (1 AM), but the offset-from-UTC has changed from being 7 hours behind UTC to now 8 hours behind UTC. There lies the hour difference you seek.
Let’s get the count of milliseconds since epoch for this third moment. Before we had first moment of the day (00:00), then the first occurring 1 AM, and now we have the second occurring 1 AM on this “Fall-Back” date of November 7, 2021.
long millisSinceEpoch_Cutover = cutover_Addition.toInstant().toEpochMilli();
1636275600000
Duration.between( firstMomentOfThe7thInLosAngelesAsSeenInUtc , cutover_Addition.toInstant() ) = PT2H
Duration.between( oneAmOnThe7thLosAngelesAsSeenInUtc , cutover_Addition.toInstant() ) = PT1H
The ZonedDateTime
class does offer a pair of methods of use at these moments of cutover: withEarlierOffsetAtOverlap
and withLaterOffsetAtOverlap
.
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
If you really need a Calendar
object, just convert.
Calendar x = GregorianCalendar.from( firstMomentOfThe7thInLosAngeles ) ; Calendar y = GregorianCalendar.from( oneAmOnThe7thLosAngeles ) ; Calendar z = GregorianCalendar.from( cutover_Addition );
If you goal is simply struggling with understanding Calendar
class behavior, I suggest you stop the masochism. There is no point. Sun, Oracle, and the JCP community all gave up on those terrible legacy date-time classes. I suggest you do the same.
Example code
Pulling together all that code above.
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();
Convert to legacy classes, if need be.
Calendar x = GregorianCalendar.from( firstMomentOfThe7thInLosAngeles ); Calendar y = GregorianCalendar.from( oneAmOnThe7thLosAngeles ); Calendar z = GregorianCalendar.from( cutover_Addition );
Dump to console.
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 );
When run.
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]