Javaランタイムには IANA Time Zone Database (以下tzdata)が組み込まれており、標準APIのZonedDateTime
を使えば、tzdataを用いたタイムゾーンの取り扱いが便利にできる。
タイムゾーンは相応に複雑なものであり、気軽にZonedDateTime
を使うと怪我をする。java.timeパッケージJavaDocのDesign notesでも、可能であれば ZonedDateTime
の使用を避けて、LocalDateTime
等のタイムゾーンの複雑さを回避できるオブジェクトの使用を推奨している。
しかし、サマータイムや複数の地域ベースタイムゾーンを扱わなければならないなど、ZonedDateTime
を避けられないケースも多々ある。本稿ではZonedDateTime
を使う上での注意事項をまとめた。
ofメソッド
of(LocalDateTime localDateTime, ZoneId zone)は、UTCオフセットを指定する必要が無くカジュアルに使用できる。しかしながら、サマータイム切り替わり等UTCオフセットが変化する日における、wall clock(ローカル日時)上存在しない時間 (API仕様書では「ギャップ」(Gap)と呼称)や、二度繰り返される時間 (API仕様書では「重複」(Overlap)と呼称) の取り扱いにあたり、偏った挙動をするので要注意である。
ギャップに該当する時刻を指定した場合、ギャップ長 (一般的な1時間シフトの夏時間に当たる場合は1時間) だけwall clockを進めてZonedDateTimeオブジェクトが生成される。
// 2024-03-31 02:30 (中央ヨーロッパ時間)のつもりだが、その時刻は存在せず、
// 2024-03-31 03:30となる。
ZonedDateTime.of(
LocalDateTime.parse("2024-03-31T02:30"),
ZoneId.of("Europe/Berlin")
);
// => 2024-03-31T03:30+02:00[Europe/Berlin]
// 進められるのはあくまでギャップ長であり、1時間とは限らない。
// オーストラリアのロードハウ島のタイムゾーンではサマータイムが30分シフトなのでギャップ超は30分となる:
// 2023-10-01 02:15のつもりだが、
// 2023-10-01 02:45となる。
ZonedDateTime.of(
LocalDateTime.parse("2023-10-01T02:15"),
ZoneId.of("Australia/Lord_Howe")
);
// => 2023-10-01T02:45+11:00[Australia/Lord_Howe]
重複ケース (複数のUTCオフセットがあり得る曖昧なケース) では、UTCオフセット値の大きい方 (通常、夏時間) が使用される。
// 中央ヨーロッパ時間では、2023-10-29 02:30のUTCオフセットは、
// +01:00 (標準時) と +02:00 (夏時間) どちらか曖昧だが、+02:00がサイレントに使用される。
ZonedDateTime.of(
LocalDateTime.parse("2023-10-29T02:30"),
ZoneId.of("Europe/Berlin")
);
// => 2023-10-29T02:30+02:00[Europe/Berlin]
// USA太平洋時間では、2023-11-05 01:30のUTCオフセットは、
// -08:00 (標準時) と -07:00 (夏時間) どちらか曖昧だが、-07:00がサイレントに使用される。
ZonedDateTime.of(
LocalDateTime.parse("2023-11-05T01:30"),
ZoneId.of("America/Los_Angeles")
);
// => 2023-11-05T01:30-07:00[America/Los_Angeles]
なお、parse
メソッドもこれと同様の振る舞いをする (後述)。
ofStrictメソッド
ofStrict(LocalDateTime localDate, ZoneOffset offset, ZoneId zone) メソッドは、その名が示す通り時刻を厳密に取り扱う。
ギャップに該当する存在しない時刻を入力した場合、不正な入力として例外が発生する。
重複ケースについては、UTCオフセットとしてZoneOffset offset
、および地域ベースタイムゾーン等として ZoneId zone
を引数で指定しなければならないので、そもそも曖昧ケースというものが無い。ただし、指定内容に矛盾がある場合 (例えば、wall clockとして標準時しかありえない時刻に夏時間オフセットを指定した場合等)は例外が発生する。
意図せぬ不正なケースケースを例外で検出できるという点では、ofStrict
は最も安全である。ただし、UTCオフセット、タイムゾーン両方の指定が必須であり柔軟な使用はできない。UTCオフセット、タイムゾーンともに分かっている永続化データの復元等に適している。
// 存在しないギャップ時刻は、標準時、夏時間どちらも例外となる
ZonedDateTime.ofStrict(
LocalDateTime.parse("2024-03-31T02:30"),
ZoneOffset.of("+01:00"), // 標準時
ZoneId.of("Europe/Berlin")
);
// => java.time.DateTimeException: LocalDateTime '2024-03-31T02:30' does not exist in zone 'Europe/Berlin' due to a gap in the local time-line, typically caused by daylight savings
ZonedDateTime.ofStrict(
LocalDateTime.parse("2024-03-31T02:30"),
ZoneOffset.of("+02:00"), // 夏時間
ZoneId.of("Europe/Berlin")
);
// => java.time.DateTimeException: LocalDateTime '2024-03-31T02:30' does not exist in zone 'Europe/Berlin' due to a gap in the local time-line, typically caused by daylight savings
// 重複ケースは指定オフセットに基づいて生成される
ZonedDateTime.ofStrict(
LocalDateTime.parse("2023-10-29T02:30"),
ZoneOffset.of("+01:00"), // 標準時
ZoneId.of("Europe/Berlin")
); // => 2023-10-29T02:30+01:00[Europe/Berlin]
ZonedDateTime.ofStrict(
LocalDateTime.parse("2023-10-29T02:30"),
ZoneOffset.of("+02:00"), // 夏時間
ZoneId.of("Europe/Berlin")
); // => 2023-10-29T02:30+02:00[Europe/Berlin]
// 入力不正の場合は例外となる
// 中央ヨーロッパ時間では、2023-10-29 03:00は夏時間から標準時への切り替わりが完了しており、夏時間オフセット(+02:00)はありえないので無効
ZonedDateTime.ofStrict(
LocalDateTime.parse("2023-10-29T03:00"),
ZoneOffset.of("+02:00"), // 夏時間 (ありえない)
ZoneId.of("Europe/Berlin")
); // => java.time.DateTimeException: ZoneOffset '+02:00' is not valid for LocalDateTime '2023-10-29T03:00' in zone 'Europe/Berlin'
ofLocalメソッド
ofLocal(LocalDateTime localDateTime, ZoneId zone, ZoneOffset preferredOffset)メソッドは、曖昧な重複ケースに対して、どのUTCオフセットを適用するか指定したいときに利用できる。
ofLocal
は、ZoneIdとUTCオフセットをともに指定し、重複ケースの時刻に対しては、ユーザーが指定したUTCオフセットを適用する。タイムゾーン上適用できないオフセットが指定された場合は、of
と同様に、有効なオフセットのうち、UTCオフセットの大きい方が適用される。
// ofLocalはユーザー指定のオフセットが適用される
ZonedDateTime.ofLocal(
LocalDateTime.parse("2023-10-29T02:30"),
ZoneId.of("Europe/Berlin"),
ZoneOffset.of("+01:00") // 標準時
);
// => 2023-10-29T02:30+01:00[Europe/Berlin]
ZonedDateTime.ofLocal(
LocalDateTime.parse("2023-10-29T02:30"),
ZoneId.of("Europe/Berlin"),
ZoneOffset.of("+02:00") // 夏時間
);
// => 2023-10-29T02:30+02:00[Europe/Berlin]
// 同wall clockに対して+03:00(不正)を指定すると、+02:00がサイレントに適用される
ZonedDateTime.ofLocal(
LocalDateTime.parse("2023-10-29T02:30"),
ZoneId.of("Europe/Berlin"),
ZoneOffset.of("+03:00") // ありえない
);
// => 2023-10-29T02:30+02:00[Europe/Berlin]
ギャップ時刻を指定した場合は、of
と同じ挙動となる。
ZonedDateTime.ofLocal(
LocalDateTime.parse("2024-03-31T02:30"),
ZoneId.of("Europe/Berlin"),
ZoneOffset.of("+01:00")
);
// => 2024-03-31T03:30+02:00[Europe/Berlin]
ZonedDateTime.ofLocal(
LocalDateTime.parse("2024-03-31T02:30"),
ZoneId.of("Europe/Berlin"),
ZoneOffset.of("+02:00")
);
// => 2024-03-31T03:30+02:00[Europe/Berlin]
ofInstantメソッド
ofInstant(LocalDateTime localDateTime, ZoneOffset offset, ZoneId zone)は、localDateTime
と offset
を組み合わせてタイムライン上の時点 (Instant)を確定する。そのInstantを zone
引数で示されるタイムゾーンを持つ ZonedDateTime
オブジェクトを生成する。
Instantを特定してからタイムゾーン情報を付与するので、ギャップや重複等の不整合や曖昧さが排除される。さらにはoffset
とzone
の整合性すら不要である。UTCオフセットはあくまでInstantを特定するために使用されるので、生成されたZonedDateTimeオブジェクトがそのオフセットを持つことは保証されない。
より直接的な ofInstant(Instant instant, ZoneId zone)もある。
ZonedDateTime.ofInstant(
LocalDateTime.parse("2023-10-29T02:30"),
ZoneOffset.of("+01:00"), // 標準時
ZoneId.of("Europe/Berlin")
);
// => 2023-10-29T02:30+01:00[Europe/Berlin]
ZonedDateTime.ofInstant(
LocalDateTime.parse("2023-10-29T02:30"),
ZoneOffset.of("+02:00"), // 夏時間
ZoneId.of("Europe/Berlin")
);
// => 2023-10-29T02:30+02:00[Europe/Berlin]
ZonedDateTime.ofInstant(
LocalDateTime.parse("2023-10-29T02:30"),
ZoneOffset.of("+03:00"), // 本来ありえないが、Instantを先に特定するので問題無い
ZoneId.of("Europe/Berlin")
);
// => 2023-10-29T01:30+02:00[Europe/Berlin]
// 次の例は一つ上と意味的に同義である
// ZoneOffsetとZoneIdが一貫性していなくてよい理由が明確である
Instant instant = LocalDateTime.parse("2023-10-29T02:30")
.toInstant(ZoneOffset.of("+03:00"));
instant.atZone(ZoneId.of("Europe/Berlin"));
// => 2023-10-29T01:30+02:00[Europe/Berlin]
// ギャップ
// オフセットの入力と出力が逆転して見えるが、Instantベースでは間違っていない
ZonedDateTime.ofInstant(
LocalDateTime.parse("2024-03-31T02:30"),
ZoneOffset.of("+01:00"), // 標準時
ZoneId.of("Europe/Berlin")
);
// => 2024-03-31T03:30+02:00[Europe/Berlin]
ZonedDateTime.ofInstant(
LocalDateTime.parse("2024-03-31T02:30"),
ZoneOffset.of("+02:00"), // 夏時間
ZoneId.of("Europe/Berlin")
);
// => 2024-03-31T01:30+01:00[Europe/Berlin]
parseメソッド
デフォルトのparseメソッドは、DateTimeFormatter.ISO_ZONED_DATE_TIME
の書式をパースでき、UTCオフセットおよび地域ベースタイムゾーンを両方指定できるが、UTCオフセットは無視され、of
と同様に振る舞う。ResolverStyle
にはデフォルトでSTRICT
が用いられるが、UTCオフセットの取り扱いは ofStrict
のような厳格さは無い (日付レベルの矛盾は例外を上げられる。例えば非うるう年で2月29日を指定した場合等)。
解析対象の書式を明示的に指定したい場合は、parse(CharSequence text, DateTimeFormatter formatter)が利用できる。
// 重複ケース: 指定UTCオフセットに関係なく全て夏時間となる
// 標準時
ZonedDateTime.parse("2023-10-29T02:30:00+01:00[Europe/Berlin]");
// => 2023-10-29T02:30+02:00[Europe/Berlin]
// 夏時間
ZonedDateTime.parse("2023-10-29T02:30:00+02:00[Europe/Berlin]");
// => 2023-10-29T02:30+02:00[Europe/Berlin]
// ありえないオフセット
ZonedDateTime.parse("2023-10-29T02:30:00+03:00[Europe/Berlin]");
// => 2023-10-29T02:30+02:00[Europe/Berlin]
// ギャップケース: wall clockが ギャップ長の1時間加算され、UTCオフセットは無視される
// 標準時
ZonedDateTime.parse("2024-03-31T02:30:00+01:00[Europe/Berlin]");
// => 2024-03-31T03:30+02:00[Europe/Berlin]
// 夏時間
ZonedDateTime.parse("2024-03-31T02:30:00+02:00[Europe/Berlin]");
// => 2024-03-31T03:30+02:00[Europe/Berlin]
// ありえないオフセット
ZonedDateTime.parse("2024-03-31T02:30:00+03:00[Europe/Berlin]");
// => 2024-03-31T03:30+02:00[Europe/Berlin]
plusXxx/minusXxxメソッド
1日 = 24時間 という思い込みに注意
地域ベースタイムゾーンを持つZonedDateTimeを扱う限りで1日が24時間である保証はない。サマータイムの切り替わりなど時差の変更が発生するからである。
wall clock上翌日の同時刻を取得するつもりで zdt.plusHours(24)
を実行しても期待する結果が得られるとは限らない。その逆に24時間後のつもりで、zdt.plusDays(1)
と実行すると、25時間後や23時間後が得られるかもしれない。
システム仕様やビジネス要求が「1日」や「24時間」と言った場合、それが本当に「1日」や「24時間」を意図しているのか明確だろうか。たとえば「キャッシュの有効期限を1日とする」と言った場合、それは「1日」ではなく「24時間」を意図していることが多いだろう。一方で、ロンドン時間ベースで毎日9時に重要顧客との会議をスケジュールする場合、もし24時間周期でスケジュールを組んでしまえば大事なビジネスチャンスを逃してしまうかもしれない。
ZonedDateTime aDay = ZonedDateTime.of(
LocalDateTime.parse("2024-03-30T09:00"),
ZoneId.of("Europe/London")
);
// わかりやすさのためInstant化
System.out.println(aDay.toInstant());
// => 2024-03-30T09:00:00Z
// 24時間後のつもりで1日足したが、サマータイムを跨いだので23時間後になってしまった
System.out.println(aDay.plusDays(1).toInstant());
// => 2024-03-31T08:00:00Z
ZonedDateTime aDay = ZonedDateTime.of(
LocalDateTime.parse("2024-03-30T09:00"),
ZoneId.of("Europe/London")
);
// => 2024-03-30T09:00Z[Europe/London] (※Z == +00:00)
// 翌日の同時刻のつもりで24h足したが、サマータイムを跨いだので実際の時計は予定より1時間ずれてしまった
ZonedDateTime nextDay = aDay.plusHours(24);
// => 2024-03-31T10:00+01:00[Europe/London]
以下は少し次元の違うケースではあるが、1時間経つとカレンダー上2日進むという、1日 ≠ 24時間の極端な実例である。
南太平洋の日付変更線境界に位置するサモアおよびトケラウでは、それぞれ現地時間2011-12-29からその翌日への日付変更の瞬間をもって標準時のUTCオフセットを-11:00から+13:00へ24時間進める変更を実施した。24時間進んだ結果12月30日の丸一日がスキップされることとなった1。というわけで12月29日から日付を跨ぐ時間 (1時間でも1秒でも) 加算すると、カレンダー上2日後の12月31になってしまう。もちろん曜日も木曜日から土曜日へ一足飛びに変わり、金曜日が存在しない。なおサモアは時差変更発生当時サマータイム期間中だったので、その時点のUTCオフセット変更は-10:00 → +14:00である (トケラウはサマータイムを採用していないので-11:00 → +13:00)。それ以前にも、キリバスで1994年から1995年切り替わりのタイミングで同様にUTCオフセットを24時間進める変更が行われたことがある2。もちろんこれら全てIANA tzdataで管理されているので、ZonedDateTime
を使う以上は「存在しない日があるかもしれない」問題を避けて通ることはできない (将来どんなタイムゾーン変更があるかは予測困難である)。
ZonedDateTime aDay = ZonedDateTime.of(
LocalDateTime.parse("2011-12-29T23:00"),
ZoneId.of("Pacific/Apia")
);
System.out.printf("%s %s\n", aDay, aDay.getDayOfWeek());
// => 2011-12-29T23:00-10:00[Pacific/Apia] THURSDAY
// 1時間後が2日後になる。曜日も金曜日がスキップされる
ZonedDateTime plus1h = aDay.plusHours(1);
System.out.printf("%s %s\n", plus1h, plus1h.getDayOfWeek());
// => 2011-12-31T00:00+14:00[Pacific/Apia] SATURDAY
PeriodとDurationの混同に注意
Period
クラスもDuration
クラスも、どちらもTemporalAmount
インターフェースを実装した、一定期間 (時間量) を表すことができるクラスである。ともに ZonedDateTime
の時間演算に使用できる。しかしながら、Period
とDuration
とは、ベースとなる時間量の単位が異なるので、理解せずに使用すると、思わぬ事故に遭う可能性がある。
Period
は、date(日)基準の時間量を持ち、Duration
は、time(時分秒)基準の時間量を持つ (最小単位はナノ秒)。したがって、Period
ベースの翌日は、カレンダー上の翌日しか意味せず、上記のサマータイムを跨げば、それは23時間後かもしれないし、25時間後かもしれない。 一方でDuration
ベースで1日を表す Duration.ofDays(1)
は、サマータイムに関係なく正確に24時間を意味する。Duration.ofDays(1).equals(Duration.ofHours(24)) // => true
となる。したがて、Period.ofDays(1)
と Duration.ofDays(1)
は同じ1日ではない。混同して使用すると事故につながる
ZonedDateTime aDay = ZonedDateTime.of(
LocalDateTime.parse("2024-03-30T09:00"),
ZoneId.of("Europe/Berlin")
);
// => 2024-03-30T09:00+01:00[Europe/Berlin]
// 翌日の同時刻のつもりでDuration.ofDays(1)を加算したが、1時間ずれてしまった
aDay.plus(Duration.ofDays(1));
// => 2024-03-31T10:00+02:00[Europe/Berlin]
// Period.ofDays(1)なら期待通り同時刻となる。
aDay.plus(Period.ofDays(1));
// => 2024-03-31T09:00+02:00[Europe/Berlin]
-
Samoa and Tokelau skip a day for dateline change (BBC News, 30 December 2011) ↩
-
How does a country change its time zone? (BBC News, 10 May 2011) ↩