Edited at

日本にもあるサマータイム問題

サマータイムというとオリンピックの暑さ対策としてサマータイムを導入しようとう話が出たことがありました。さすがに無理だよねとなってそのままこの話は流れました。日本にいる限りはサマータイムなんて関係ないと思っていたのですが、日本でサマータイムに起因するバグを踏んでしましました。この記事はその顛末です。


なぜか1日少ない

日付に関するデータを作成するために次のようなループを回していました。

Calendar cal = new GregorianCalendar(1900,  1 - 1,  1, 0, 0, 0);

Calendar end = new GregorianCalendar(2020, 12 - 1, 31, 0, 0, 0);
long endTime = end.getTimeInMillis();

while (cal.getTimeInMills() <= endtime) {
// (日付データ作成)
cal.add(Calendar.DATE, 1);
}

1900/01/01~2020/12/31までのデータを作成しようというものです。ところが作成される日付データが1日分少ない。2020/12/31までのデータが作成されるはずが2020/12/30分までしか作成されない。そこで日付の内容を眺めてみると、

1900/01/01 00:00:00.000

1900/01/02 00:00:00.000
1900/01/03 00:00:00.000

(中略)

2020/12/28 01:00:00.000
2020/12/29 01:00:00.000
2020/12/30 01:00:00.000

どこかで1時間ずれています。endtimeは 2020/12/31 00:00:00.000 を指していたので、その結果1日分少なくなってしまったようです。


古いJDKだと発生しない

この現象が発生したのはjdk-8u191ですが、古いJDKだと発生しませんでした。

1900/01/01 00:00:00.000

1900/01/02 00:00:00.000
1900/01/03 00:00:00.000

(中略)

2020/12/28 00:00:00.000
2020/12/29 00:00:00.000
2020/12/30 00:00:00.000
2020/12/31 00:00:00.000

と期待通りの動きをします。さらに詳しく調べてみると、


  • jdk-8u161 だと発生しない

  • jdk-8u171 だと発生する

ことが分かりました。この間にJDKにデグレが混入したのか?と思って色々とぐぐって見ましたが、特にこれといったページは見つけられません。じゃあ、どこでずれているのかを調べてみると、

1948/04/29 00:00:00.000

1948/04/30 00:00:00.000
1948/05/01 00:00:00.000
1948/05/02 01:00:00.000
1948/05/03 01:00:00.000
1948/05/04 01:00:00.000

1948/05/02 でずれています。もしや、と思って日本のサマータイムでぐぐってみると

夏時間適用期間

日本でサマータイムが始まった初日 1948/5/2 と一致します。jdk-8u171で日本のサマータイムが実装されたためにこんなことが起きたのか???


JDKのChangelog

jdk-8u171のChangelog を見てみても日本のサマータイムに関する記述は見つかりません。

Timezone Data Versions in the JRE Software を見ても2014年ごろにJapan関連で変更があったことが読み取れますが、jdk-8u171 では特にこれといったものがありません。

先の jdk-8u171のChangelog をよくよくみてみると次のような記述がありました。


IANA Data 2018c

JDK 8u171 contains IANA time zone data version 2018c. For more information, refer to Timezone Data Versions in the JRE Software.


Timezone データがアップデートされています。ので、こちらを当たってみることにします。


サマータイムデータ(Timezone データ)

https://www.iana.org/time-zones

がTimezoneデータの大元のようです。tzdb-2018c とその直前のtzdb-2018b の違いをみてみると、

tzdb-2018b 2018-01-18 08:50 asia:

# Rule  NAME    FROM    TO  TYPE    IN  ON      AT      SAVE    LETTER/S

Rule Japan 1948 only - May Sun>=1 2:00 1:00 D
Rule Japan 1948 1951 - Sep Sat>=8 2:00 0 S
Rule Japan 1949 only - Apr Sun>=1 2:00 1:00 D
Rule Japan 1950 1951 - May Sun>=1 2:00 1:00 D

tzdb-2018c 2018-01-23 10:30 asia:

# Rule  NAME    FROM    TO  TYPE    IN  ON      AT      SAVE    LETTER/S

Rule Japan 1948 only - May Sat>=1 24:00 1:00 D
Rule Japan 1948 1951 - Sep Sat>=8 25:00 0 S
Rule Japan 1949 only - Apr Sat>=1 24:00 1:00 D
Rule Japan 1950 1951 - May Sat>=1 24:00 1:00 D

と確かに変更が入っています。これの読み方ですが、

# Rule  NAME    FROM    TO  TYPE    IN  ON      AT      SAVE    LETTER/S

Rule Japan 1948 only - May Sun>=1 2:00 1:00 D
(1948年の5月の第1土曜日(1日以降の最初の土曜日) の2:00に1時間進める)

ということのようです。新しい方はというと、

# Rule  NAME    FROM    TO  TYPE    IN  ON      AT      SAVE    LETTER/S

Rule Japan 1948 only - May Sun>=1 24:00 1:00 D
(1948年の5月の第1土曜日(1日以降の最初の土曜日) の24:00に1時間進める)

とサマータイムの開始時刻が変更になっています。つまり、0時を1時に変更すると。あっ!


なにが起きていたのか

jdk-8u171 では日本のサマータイムに更新が入っている。これはサマータイムの開始時刻をいままでは2時だったものを0時に変更するものである。元のプログラムは 00:00:00 で回していたので


  • 1948/5/1 00:00:00 + 1日 = 1948/5/2 01:00:00

と1時間ずれた。それまでは開始時刻が午前2時だったので、


  • 1948/5/1 00:00:00 + 1日 = 1948/5/2 00:00:00 ( サマータイムなしの時間は 1948/5/1 23:00:00 )

結果的にはずれることなく日付計算ができた。


対策

原因が明らかになったので対策です。この問題をどう乗り越えるか。


終了時刻を+1日-1ミリ秒 にする

終了時刻が 2018/12/31 00:00:00 だったので1日少なくなってしまいました。これを 2018/12/31 23:59:59 にすれば問題ないのではないか。

Calendar end = new GregorianCalendar(2020, 12 - 1, 31, 0, 0, 0);

end.add(Calendar.DATE, 1)
end.add(Calendar.MILLISECOND, -1)
long endTime = end.getTimeInMillis(); // 2018/12/31 23:59:59.999

確かにこの修正で意図した通りには動きました。しかしながら、途中の日付が 01:00:00 を指しているのはやっぱりなんとも気持ち悪いです。ここではうまくいったとしても、別のところで問題を引き起こす可能性もあります。


+24時間する

cal.add(Calender.DATE, 1) とするからずれるのであって、cal.add(Calender.HOUR, 24) とすればずれないのではないか。確かにこれでもプログラムは意図した通りに動きました。しかしながら時刻をながめると

1948/04/29 00:00:00.000

1948/04/30 00:00:00.000
1948/05/01 00:00:00.000
1948/05/02 01:00:00.000
1948/05/03 01:00:00.000
1948/05/04 01:00:00.000
...
1948/09/09 01:00:00.000
1948/09/10 01:00:00.000
1948/09/11 01:00:00.000
1948/09/12 00:00:00.000
1948/09/13 00:00:00.000
1948/09/14 00:00:00.000

途中はやっぱりずれています。なので、終了日が 1948/09/09 だと同じ問題が起こります。実際問題としてはまず起こりませんが、潜在バグではあるので可能ならば避けたいです。


+1日.set(00:00:00)

+1日したあとに、必ず時刻を 00:00:00.000 にセットするのはどうでしょうか。

cal.add(Calendar.DATE, 1);

cal.set(Calendar.HOUR, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);

すると

1948/04/29 00:00:00.000

1948/04/30 00:00:00.000
1948/05/01 00:00:00.000
1948/05/02 01:00:00.000 // ここはしょうがない
1948/05/03 00:00:00.000
1948/05/04 00:00:00.000
...
1948/09/09 00:00:00.000
1948/09/10 00:00:00.000
1948/09/11 00:00:00.000
1948/09/12 00:00:00.000
1948/09/13 00:00:00.000
1948/09/14 00:00:00.000

と意図した動作になっています。ただ、毎回 00:00:00.000 をセットするのは冗長な感じがします。1948年のデータを扱うことはまれなのに、そのために毎回時刻のクリアをするのはできることならやりたくありません。


LocalDateを使う

そして辿り着いたのが LocalDate を使うものです。これはJava8 から導入された Date and Time API の一つです。LocalDateは時刻情報を持たない日付だけのクラスなのでサマータイムの影響を受けません。先のコードは

LocalDate cal = LocalDate.of(1900,  1,  1);

LocalDate end = LocalDate.of(2018, 12, 31);
long endDay = end.toEpochDay();

while (cal.toEpochDay() <= endDay) {
// (日付データ作成)
cal = cal.plusDays(1); // LocalDate は Immutable
}

と特に回避策を講じることなく、素直にそのままコードに落とし込む事が出来ます。


結局何が問題だったのか?

サマータイムがあると次のように常識が通用しなくなることが問題だと思われます。


  • +1日すると、次の日の同じ時刻になる。


    • 実際には別の時刻を指す事がある。

       calender:"1948/5/1 00:00:00.000" → calendar(Calendar.DATE, 1) :"1948/5/2 01:00:00.000"



  • 1日=24時間である。


    • 実際には1日は23時間になったり25時間になったりする。

       calender.add(Calendar.DATE, 1) ≠ calendar.add(Calendar.HOUR, 24)



さすがに常識だと思ってました。このバグを踏むまでは。。。


最後に

これからも日本にサマータイムが導入されることがありませんように。