サマータイムというと、オリンピックの暑さ対策としてサマータイムを導入しようという話が出たことがありました。さすがに無理だよねとなってそのままこの話は流れました。日本にいる限りはサマータイムなんて関係ないと思っていたのですが、日本でサマータイムに起因するバグを踏んでしましました。この記事はその顛末です。
なぜか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 データ)
が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)
- 実際には1日は23時間になったり25時間になったりする。
さすがに常識だと思ってました。このバグを踏むまでは。。。
最後に
これからも日本にサマータイムが導入されることがありませんように。