ZonedDateTime はjava.timeパッケージの不変(immutable)クラスで、タイムゾーン情報(ZoneId)とともに日付と時刻を格納します。LocalDateTimeとは異なり、ZonedDateTimeはタイムライン上の瞬間を一意に識別します。Oracle Java 17 (2024)の仕様によると、このクラスはIANA Time Zone Databaseのゾーンルールを通じて夏時間(DST)の移行を正しく処理します。
重要なポイント
ZonedDateTime はjava.timeパッケージの主要なクラスの1つで、完全なタイムゾーン情報とともに日付と時刻を表現します。これは3つのコンポーネントを組み合わせます: LocalDateTime(日付と時刻)、ZoneId(ゾーン識別子)、ZoneOffset(UTCからのオフセット)。
LocalDateTime がタイムゾーンの結びつけなしに壁時計時間(wall-clock time)のみを格納するのとは異なり、ZonedDateTimeは瞬間を一意に識別します。異なるタイムゾーンにある2つの同一のLocalDateTimeインスタンスは、時間的に異なる瞬間を表します。2つの同一のZonedDateTimeインスタンスは同じ瞬間を表します。
このクラスは完全に不変でスレッドセーフです。すべての算術演算は新しいオブジェクトを返します。ZonedDateTimeは ChronoZonedDateTime インターフェースを実装しており、Javaでゾーン時間の処理が必要な場所ならどこでも使用できます。
Oracle Java 17の仕様によると、ZonedDateTimeは600以上のタイムゾーンを含むIANA Time Zone Databaseの任意のゾーンでの操作をサポートしています。
主な違い — ZonedDateTime にはタイムゾーンが含まれていますが、LocalDateTime には含まれていません。この基本的な違いが各クラスの適用範囲を決定します。
LocalDateTimeはローカルイベントに使用されます: コンサートの時間、授業のスケジュール、生年月日。イベントがモスクワで15:00に発生する場合、LocalDateTimeは結びつけなしに15:00を記録します。サーバーをニューヨークに移動しても、時間は15:00のままですが、それは異なる物理的な瞬間になります。
ZonedDateTimeはグローバルデータに使用されます: サーバーログ、APIのタイムスタンプ、国際会議。会議が15:00 MSKに予定されている場合、ZonedDateTimeは時間とゾーンの両方を保持します。ニューヨークでは8:00 ESTとして正しく表示されます。Baeldung (2024)によると、LocalDateTimeとZonedDateTimeの選択は、日付を扱う際の最も一般的なアーキテクチャ上の決定です。
実用的なルール: データが1つの地域に対して保存される場合 — LocalDateTimeを使用します。データがタイムゾーンの境界を越える場合 — ZonedDateTimeを使用します。絶対的な瞬間を表現する必要がある場合 — Instant を使用します。
java.timeのタイムゾーンは ZoneId クラスによって表現されます。ZoneIdは「大陸/地域」形式のゾーン識別子で、例えば「Europe/Moscow」「America/New_York」「Asia/Tokyo」などです。ZoneIdは静的メソッドof(String zoneId)またはシステムのデフォルトタイムゾーンを通じて取得されます。
ZoneIdは2つのタイプに分類されます: fixed offset(固定オフセット、例「+03:00」)と region-based(地域ゾーン、例「Europe/London」)。地域ゾーンには夏時間移行ルールと歴史的な変更が含まれています。Fixed offsetは単なる固定オフセットです。
特定の瞬間におけるZoneIdの現在のオフセットを取得するには、getRules() メソッドを使用します。これはZoneRulesを返します。ZoneRulesには、特定のゾーンのすべての移行とオフセットが含まれています。これは正しいDST処理のための重要なメカニズムです。
すべてのタイムゾーンは tzdata ファイル(IANA Time Zone Database)を介してJDKに同梱されており、定期的に更新されます。Androidでは、tzdataのバージョンはGoogle Play Servicesを介したシステムアップデートに依存します。
ZonedDateTimeはいくつかの方法で作成できます。最も簡単なのは now() で、システムのタイムゾーンでの現在時刻を返します。now(ZoneId)のバリアントは、指定されたゾーンでの現在時刻を取得できます。
of(LocalDateTime, ZoneId) メソッドは、ローカル時刻とゾーンからZonedDateTimeを作成します。of(int year, int month, int dayOfMonth, int hour, int minute, int second, int nanoOfSecond, ZoneId zone)のバリアントはコンポーネントから作成します。
LocalDateTimeは atZone(ZoneId) メソッドを介してZonedDateTimeに変換できます。Instantは Instant.atZone(ZoneId) を介して変換できます。DateはDate.toInstant().atZone(ZoneId)を介して変換できます。
val moscowZone = ZoneId.of("Europe/Moscow")
val nowInMoscow = ZonedDateTime.now(moscowZone)
val fromComponents = ZonedDateTime.of(
2026, 7, 21, 15, 30, 0, 0, moscowZone
)
val fromLocal = LocalDateTime.now().atZone(moscowZone)
val fromInstant = Instant.now().atZone(moscowZone)
主要な変換メソッドは withZoneSameInstant(ZoneId) です。これは同じ瞬間を維持しながらZonedDateTimeを別のタイムゾーンに変換します。例: 15:00 MSK → 8:00 EST。withZoneSameLocal(ZoneId)メソッドはローカル時刻を維持しながらゾーンを変更します — これは異なる瞬間をもたらします。
UTCからのオフセットを取得するには、getOffset() メソッドを使用します。これはZoneOffsetを返します。ZoneOffsetはZoneIdのサブクラスで、「+HH:mm」または「-HH:mm」形式の固定オフセットを表します。
Instantへの変換は toInstant() メソッドを介して行われます。Instantはタイムゾーンに依存しない、時間の絶対的な瞬間です。逆変換はInstant.atZone(ZoneId)です。
val moscow = ZonedDateTime.of(
2026, 7, 21, 15, 0, 0, 0,
ZoneId.of("Europe/Moscow")
)
val newYork = moscow.withZoneSameInstant(
ZoneId.of("America/New_York")
)
val utcInstant = moscow.toInstant()
val backToMoscow = utcInstant.atZone(ZoneId.of("Europe/Moscow"))
夏時間の移行は2つの問題を引き起こします: ギャップ(gap)とオーバーラップ(overlap)です。ギャップは春に時計が進められたときに発生し — 特定の時刻が存在しなくなります。オーバーラップは秋に時計が戻されたときに発生し — 同じ時刻が2回発生します。
ZonedDateTimeは 解決(resolve) 戦略を通じてこれらの状況を処理します。ギャップ中にオブジェクトを作成する場合、java.timeは自動的にオフセット量だけ時刻をシフトします。オーバーラップ中に作成する場合、最初のオプション(移行前)が選択されます。この動作はwithZoneSameInstantで変更できます。
zone.getRules().isDaylightSavings(instant) を使用して、時刻がDST内にあるかどうかを確認できます。getOffset()メソッドは特定の瞬間の実際のオフセットを表示し、getRules().getDaylightSavings(instant) はミリ秒単位のDST調整量を表示します。
fun checkDST(zdt: ZonedDateTime) {
val rules = zdt.getZone().getRules()
val instant = zdt.toInstant()
if (rules.isDaylightSavings(instant)) {
val dstAmount = rules.getDaylightSavings(instant)
Log.d("夏時間(DST)", "DSTオフセット: $dstAmount")
}
}
ZonedDateTimeをフォーマットするには DateTimeFormatter を使用します。標準のISO形式には日付、時刻、オフセットが含まれます: 「2026-07-21T15:30:00+03:00[Europe/Moscow]」。定義済みフォーマット: ISO_ZONED_DATE_TIME、ISO_OFFSET_DATE_TIME、ISO_INSTANT。
ローカライズされた出力には DateTimeFormatter.ofLocalizedDateTime(FormatStyle) を使用します。FormatStyleはSHORT、MEDIUM、LONG、FULLに設定できます。LONGにはゾーン名(「MSK」)が含まれ、FULLには完全な名前(「Moscow Standard Time」)が含まれます。
重要: ZonedDateTimeで文字列を解析する場合、形式にはゾーンまたはオフセット情報が含まれている必要があります。ゾーンが指定されていない場合は、LocalDateTime.parse()を使用してからatZone()を使用します。
val zdt = ZonedDateTime.now(ZoneId.of("Europe/Moscow"))
val iso = zdt.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)
val custom = DateTimeFormatter
.ofPattern("dd.MM.yyyy HH:mm z")
val formatted = zdt.format(custom)
val parsed = ZonedDateTime.parse(
"2026-07-21T15:30:00+03:00",
DateTimeFormatter.ISO_OFFSET_DATE_TIME
)
最初の例 — ユーザーのローカルタイムゾーンで会議時間を表示します。サーバーはUTCでZonedDateTimeを送信し、クライアントはデバイスのローカルタイムゾーンに変換します。
fun displayMeetingTime(
serverUtc: ZonedDateTime
): String {
val deviceZone = ZoneId.systemDefault()
val localTime = serverUtc.withZoneSameInstant(deviceZone)
val formatter = DateTimeFormatter
.ofPattern("dd.MM.yyyy HH:mm z")
return localTime.format(formatter)
}
2番目の例 — タイムゾーンを考慮して次のイベントまでの時間を計算します。サーバー時間には ZonedDateTime を、差分の計算には Duration.between() を使用します。
fun timeUntilEvent(eventTime: ZonedDateTime): String {
val now = ZonedDateTime.now()
val duration = Duration.between(now, eventTime)
val hours = duration.toHours()
val minutes = duration.toMinutes() % 60
return "Remaining $hours h $minutes min"
}
3番目の例 — Retrofit APIを使用します。サーバーはゾーン付きのISO-8601文字列を返します。ZonedDateTime に変換するためにカスタムデシリアライザーを使用します。
data class EventResponse(
@JsonAdapter(ZonedDateTimeAdapter::class)
val eventTime: ZonedDateTime
)
class ZonedDateTimeAdapter : JsonAdapter<ZonedDateTime>() {
override fun fromJson(reader: JsonReader): ZonedDateTime? {
return ZonedDateTime.parse(
reader.nextString()
)
}
}
1つ目の間違い — サーバーコードで ZoneId.systemDefault() を使用すること。サーバーのタイムゾーンはクライアントと異なる可能性があり、サーバーでシステムゾーンを使用すると誤った計算につながります。常にゾーンを明示的に指定するか、UTCを基準として使用してください。
2つ目の間違い — 期間の計算時にDSTを無視すること。Duration.between()は移行を正しく処理しますが、手動でタイムスタンプを減算すると、夏時間が1時間の誤差を引き起こす可能性があります。手動計算の代わりに ChronoUnit.HOURS.between() を使用してください。
3つ目の間違い — withZoneSameInstantとwithZoneSameLocalを混同すること。前者は瞬間を維持しながらゾーンを変更し — 時間がシフトします。後者はローカル時刻を維持しながらゾーンを変更し — 瞬間が変わります。SonarSource (2024) によると、誤ったメソッドの選択は最も一般的な間違いの1つです。
4つ目の間違い — デバイスのタイムゾーンが常にユーザーのタイムゾーンと同じであると想定すること。ユーザーは旅行中で、現在のゾーンではなく「自宅」のタイムゾーンで時間を表示するようアプリに期待する場合があります。この場合は、インターフェースを通じてゾーンの選択を提供してください。
よくある質問
ZonedDateTimeには地域ゾーン識別子(例: 「Europe/Moscow」)が含まれ、DSTを処理します。OffsetDateTime は地域ルールなしに固定オフセット(+03:00)のみを格納します。データベース保存にはOffsetDateTimeが推奨されます。
ZonedDateTime.now(ZoneOffset.UTC) または Instant.now().atZone(ZoneOffset.UTC) を使用します。どちらのオプションもゼロオフセットで現在の瞬間を返します。単純なタイムスタンプには、ゾーンの結びつけなしでInstant.now()を使用します。
はい、ただしカスタムアダプターが必要です。GsonはデフォルトではZonedDateTimeをサポートしていません。MoshiはRfc3339DateJsonAdapterを介してサポートしています。Jacksonには Kotlinx Serialization または JavaTimeModule ライブラリの使用が推奨されます。
java.timeは自動的にオフセット量だけ時刻を進めます。例えば、時計が03:00に進められたときに02:30が存在しない場合、ZonedDateTime は03:30でオブジェクトを作成します。ZoneRules.getTransition(instant)でギャップを確認できます。
JDBC 4.2はOffsetDateTimeをサポートしていますが、ZonedDateTimeを直接サポートしていません。ZonedDateTime にはSQLに相当するものがない地域ゾーンが含まれています。OffsetDateTimeまたはInstantを保存し、ゾーンは別のカラムに保存することをお勧めします。
まとめ
ターンキー方式のモバイルアプリケーションを開発します
IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。