Java ZonedDateTime
Работа с датой и временем с учётом часового пояса в Java через ZonedDateTime и ZoneId.
ZonedDateTime — это LocalDateTime с прикреплённым ZoneId. Он означает: «эта календарная дата и время в этом месте». Комбинация однозначно определяет единственный момент на глобальной временной шкале — 2025-11-04T14:00 [America/New_York] — это ровно один Instant, отличный от 2025-11-04T14:00 [Europe/Berlin].
Этот класс нужен, когда важно местное время события в конкретном месте. Календари встреч. Запланированные задания, которые должны срабатывать «в 9 утра по часовому поясу пользователя». Всё, что должно корректно пережить переход на летнее время. LocalDateTime не знает часового пояса; Instant хранит время в UTC и не несёт понятной человеку метки зоны. ZonedDateTime — это оба сразу.
ZoneId: каталог часовых поясов
Прежде чем перейти к ZonedDateTime, познакомимся с ZoneId — часовой пояс идентифицируется через ZoneId, который получают вызовом ZoneId.of(...):
ZoneId ny = ZoneId.of("America/New_York");
ZoneId de = ZoneId.of("Europe/Berlin");
ZoneId tokyo = ZoneId.of("Asia/Tokyo");
ZoneId utc = ZoneId.of("UTC");
ZoneId sys = ZoneId.systemDefault();Строки — это идентификаторы базы данных часовых поясов IANA (Регион/Город). Полный список возвращает ZoneId.getAvailableZoneIds() — около 600 записей, обновляемых по мере того, как страны меняют свои зоны или правила перехода на летнее время. ZoneId хранит историческую информацию из IANA, поэтому даты в 1985 году используют правила, действовавшие в 1985 году.
Не используйте ZoneOffset (фиксированное смещение ±HH:MM), когда имеете в виду реальный часовой пояс. ZoneOffset.of("-05:00") правилен для Нью-Йорка в ноябре, но неверен в июне; ZoneId.of("America/New_York") корректен круглый год.
Трёхбуквенные названия зон, такие как "EST" и "PST", в основном являются псевдонимами, неоднозначными (это Eastern Standard или Eastern Australia?) и тихо устаревшими. Используйте Регион/Город. Значения "UTC" и "GMT" — исключения и допустимы.
Создание
ZonedDateTime now = ZonedDateTime.now(); // system zone
ZonedDateTime nowNY = ZonedDateTime.now(ZoneId.of("America/New_York"));
ZonedDateTime made = ZonedDateTime.of(2025, 11, 4, 14, 0, 0, 0, ZoneId.of("America/New_York"));
ZonedDateTime parsed = ZonedDateTime.parse("2025-11-04T14:00:00-05:00[America/New_York]");Самый распространённый путь создания — «есть LocalDateTime, есть ZoneId, соединить их»:
LocalDateTime ldt = LocalDateTime.of(2025, 11, 4, 14, 0);
ZonedDateTime zdt = ldt.atZone(ZoneId.of("America/New_York"));atZone(zone) — это единственный вызов, переходящий от показания местных часов к зонированному моменту. Он также обрабатывает два граничных случая, которые вносит переход на летнее время.
Переход на летнее время: когда часы пропускают или повторяют время
Дважды в год в зонах, соблюдающих переход на летнее время, часы перепрыгивают вперёд или назад. Когда часы переводят вперёд — в США 02:00 прыгает к 03:00 в воскресенье в марте — времена между 02:00 и 03:00 не существуют в этот день. Когда часы переводят назад, времена между 01:00 и 02:00 происходят дважды. ZonedDateTime должен что-то делать в обоих случаях, и это задокументировано:
- Пропущенное время (пробел):
atZoneвозвращает время после перехода.LocalDateTime.of(2025, 3, 9, 2, 30).atZone(ZoneId.of("America/New_York"))становится03:30-04:00— JDK сдвинул время вперёд на час, чтобы попасть на допустимое значение. - Повторяющееся время (перекрытие):
atZoneвозвращает более ранний из двух допустимых моментов (тот, что до изменения смещения). ИспользуйтеwithEarlierOffsetAtOverlap()илиwithLaterOffsetAtOverlap()для явного выбора.
ZonedDateTime ambiguous = LocalDateTime.of(2025, 11, 2, 1, 30)
.atZone(ZoneId.of("America/New_York")); // 01:30 EDT (earlier)
ZonedDateTime explicit = ambiguous.withLaterOffsetAtOverlap(); // 01:30 EST (later)Два ZonedDateTime имеют одинаковый LocalDateTime, но разные смещения и разные Instant. Это единственное место в java.time, где одно и то же показание местных часов законно соответствует двум моментам — и именно здесь кроется источник ошибок, связанных с летним временем. Будьте внимательны, когда перекрытие имеет значение.
Разбор на части
ZoneId zone = zdt.getZone();
ZoneOffset offset = zdt.getOffset();
LocalDateTime ldt = zdt.toLocalDateTime();
LocalDate date = zdt.toLocalDate();
LocalTime time = zdt.toLocalTime();
Instant inst = zdt.toInstant();
OffsetDateTime odt = zdt.toOffsetDateTime();Методы доступа делятся на три группы: зонная половина (getZone, getOffset), половина местных часов (toLocalDateTime, toLocalDate, toLocalTime) и половина глобального момента (toInstant). Все три одновременно справедливы для одного ZonedDateTime; вы выбираете нужную проекцию.
OffsetDateTime — связанный тип: LocalDateTime плюс ZoneOffset (без зоны, без перехода на летнее время). Он удобен для сериализации «2025-11-04T14:00-05:00» без привязки к именованной зоне (часто это то, что нужно временным меткам JSON); для кода, требующего арифметики с учётом летнего времени, сохраняйте ZonedDateTime.
Два смысла понятия «следующий день»
У ZonedDateTime есть два похожих, но разных метода:
zdt.plusDays(1); // add 1 day to the local clock reading
zdt.plus(Duration.ofHours(24)); // add exactly 24 hoursВ день перехода на летнее время они дают разный результат. В день, когда часы переводят вперёд, plusDays(1) приводит к тому же местному времени завтра (до которого всего 23 часа реального времени). plus(Duration.ofHours(24)) приводит к показанию часов на час позже вчерашнего.
| Цель | Метод |
|---|---|
| «То же время завтра» (календарь) | plusDays(1) |
| «Ровно через 24 часа» (продолжительность) | plus(Duration.ofHours(24)) |
Оба корректны; они отвечают на разные вопросы. Выбирайте осознанно.
Сравнение и равенство
zdt1.isBefore(zdt2); // compares Instants
zdt1.isAfter(zdt2);
zdt1.isEqual(zdt2); // compares Instants
zdt1.equals(zdt2); // compares LocalDateTime + Zone + OffsetРазличие принципиальное:
isBefore/isAfter/isEqualсравнивают лежащие в основе моменты (Instant).equalsсравнивает полную структуру — дваZonedDateTime, представляющих один и тот же момент, но в разных зонах, не являютсяequal.
Чтобы проверить «одинаковый ли момент вне зависимости от зоны», используйте isEqual или преобразуйте оба в Instant и сравните.
Практический пример: встреча с участием трёх офисов
Программа ниже планирует встречу на 14:00 по берлинскому времени и вычисляет, которое время это будет в офисах Нью-Йорка и Токио. Затем она планирует еженедельную встречу, которая корректно проходит через переход на летнее время, демонстрируя разницу между plusDays(7) и plus(Duration.ofDays(7)) на переходной неделе.
Что следует вынести из запуска:
withZoneSameInstant(otherZone)— это операция «который час в их офисе?»: она фиксирует момент и отображает показание часов в новой зоне. Её аналогwithZoneSameLocal(otherZone)фиксирует показание часов и изменяет момент (встреча перемещается). Названия легко перепутать; разница в том, что остаётся неизменным. Внимательно читайте их, когда пишете.berlin.equals(ny)далfalse, хотя оба представляли один момент.equalsсравнивает полную структуру (локальные дата-время + зона). Для проверки «одинаковый ли момент вне зависимости от метки» используйтеisEqualили сравнивайтеInstant. Это то же самое различие, чтоLocalDate.equalsиisEqual:equals— для «одинаковых объектов-значений»,isEqual— для «одинаковых точек во времени».- Пробел перехода на летнее время (
2025-03-09 02:30в NY) был разрешён перемещением к03:30-04:00. JDK не выбросил исключение; он выбрал момент после перехода. Если вам принципиально важно обнаружить, что вы передали невозможное время, используйтеZoneRules.getTransition(localDateTime)и проверьте, является ли возвращённый объект пробелом. - Перекрытие перехода на летнее время (
2025-11-02 01:30в NY) дало два разныхZonedDateTimeс одинаковыми локальными полями и разными смещениями —EDTиEST, с разницей в один час.withLaterOffsetAtOverlap()иwithEarlierOffsetAtOverlap()позволяют выбрать нужный. Если вы храните запланированные события, заранее определите, какой из двух имеет в виду пользователь, и применяйте правильный метод при разборе. plusDays(1)иplus(Duration.ofHours(24))дали разный результат в день перевода часов вперёд — 23 часа реального времени против 24, приведя к разным показаниям местных часов. ИспользуйтеplusDays/plusWeeksдля планирования по календарю («то же время завтра»), иplus(Duration)для арифметики прошедшего времени («будильник через 24 часа»). Выбор почти всегда соответствует намерению, понятному пользователю.
Что дальше
ZonedDateTime — это «человеческая» сторона понятия «момент с меткой». Следующая глава, Java Instant, — это «машинная» сторона: момент как количество наносекунд с начала эпохи, без зоны, без календаря — тип, который используют все распределённые системы при передаче данных.