ZonedDateTime — immutable класс из пакета java.time, который хранит дату и время вместе с информацией о часовом поясе (ZoneId). В отличие от LocalDateTime, ZonedDateTime однозначно идентифицирует момент на временной шкале. По данным спецификации Oracle Java 17 (2024), класс корректно обрабатывает переход на летнее время (DST) через правила зоны из базы данных IANA Time Zone Database.
Главное
ZonedDateTime — один из ключевых классов пакета java.time, представляющий дату и время с полной информацией о часовом поясе. Он объединяет три компонента: LocalDateTime (дата и время), ZoneId (идентификатор зоны) и ZoneOffset (смещение относительно UTC).
В отличие от LocalDateTime, который хранит только "настенные часы" (wall-clock time) без привязки к зоне, ZonedDateTime однозначно идентифицирует момент. Два одинаковых LocalDateTime в разных часовых поясах представляют разные моменты времени. Два одинаковых ZonedDateTime — один и тот же момент.
Класс полностью immutable и thread-safe. Все арифметические операции возвращают новый объект. ZonedDateTime реализует интерфейс ChronoZonedDateTime и может использоваться везде, где требуется работа с зональным временем в Java.
По данным спецификации Oracle Java 17, ZonedDateTime поддерживает работу с любой зоной из IANA Time Zone Database, которая включает более 600 часовых поясов.
Главное различие — ZonedDateTime содержит часовой пояс, а LocalDateTime — нет. Это фундаментальное отличие определяет область применения каждого класса.
LocalDateTime используется для локальных событий: время концерта, расписание занятий, дата рождения. Если событие происходит в Москве в 15:00, LocalDateTime зафиксирует 15:00 без привязки. Если переместить сервер в Нью-Йорк, время останется 15:00 — но это будет уже другое физическое время.
ZonedDateTime применяется для глобальных данных: логи сервера, метки времени в API, международные встречи. Если встреча назначена на 15:00 MSK, ZonedDateTime сохранит и время, и зону. В Нью-Йорке это будет корректно отображаться как 8:00 EST. По данным Baeldung (2024), выбор между LocalDateTime и ZonedDateTime — самое частое архитектурное решение при работе с датами.
Практическое правило: если данные хранятся для одного региона — используйте LocalDateTime. Если данные пересекают границы часовых поясов — используйте ZonedDateTime. Если нужно передать абсолютный момент — используйте Instant.
Часовой пояс в java.time представлен классом ZoneId. ZoneId — это идентификатор зоны в формате "continent/region", например "Europe/Moscow", "America/New_York", "Asia/Tokyo". ZoneId получают через статический метод of(String zoneId) или через системный часовой пояс по умолчанию.
ZoneId делится на два типа: fixed offset (фиксированное смещение, например "+03:00") и region-based (региональные зоны, например "Europe/London"). Региональные зоны содержат правила перехода на летнее время и исторические изменения. Fixed offset — просто фиксированное смещение.
Для получения текущего смещения ZoneId в конкретный момент используется метод getRules(), который возвращает ZoneRules. ZoneRules содержит все переходы и смещения для данной зоны. Это ключевой механизм для корректной обработки DST.
Все часовые пояса поставляются с JDK через файлы tzdata (IANA Time Zone Database) и регулярно обновляются. На 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 можно преобразовать в ZonedDateTime через метод atZone(ZoneId). 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"))
Переход на летнее время создаёт две проблемы: "дырки" (gap) и "наложения" (overlap). Дырка возникает весной, когда часы переводятся вперёд — определённого времени не существует. Наложение — осенью, когда время переводится назад — одно и то же время существует дважды.
ZonedDateTime обрабатывает эти ситуации через стратегию resolve. При создании объекта во время дырки java.time автоматически сдвигает время на величину смещения. При создании во время наложения выбирается первый вариант (до перевода). Поведение можно изменить через withZoneSameInstant.
Проверить, находится ли время в зоне DST, можно через zone.getRules().isDaylightSavings(instant). Метод 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 offset: $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
)
Первый пример — отображение времени встречи для пользователя в его часовом поясе. Сервер отдаёт ZonedDateTime в UTC, клиент конвертирует в локальный часовой пояс устройства.
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)
}
Второй пример — расчёт времени до следующего события с учётом часового пояса. Используем 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"
}
Третий пример — работа с API Retrofit. Сервер возвращает строку в 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()
)
}
}
Первая ошибка — использование ZoneId.systemDefault() в серверном коде. Часовой пояс сервера может отличаться от клиентского, и использование системного пояса на сервере приводит к неверным расчётам. Всегда явно указывайте зону или используйте UTC в качестве эталона.
Вторая ошибка — игнорирование DST при расчёте длительности. Duration.between() корректно обрабатывает переходы, но если вы вычитаете timestamp вручную, переход на летнее время может дать ошибку в 1 час. Используйте методы ChronoUnit.HOURS.between() вместо ручной математики.
Третья ошибка — путаница между withZoneSameInstant и withZoneSameLocal. Первый меняет зону, сохраняя момент — время сдвигается. Второй меняет зону, сохраняя локальное время — момент меняется. Выбор неправильного метода — одна из самых частых ошибок по данным SonarSource (2024).
Четвёртая ошибка — предположение, что часовой пояс устройства — это всегда то же самое, что и часовой пояс пользователя. Пользователь может путешествовать и ожидать, что приложение покажет время в его "домашнем" поясе, а не в текущем. В этом случае нужно предоставить выбор зоны через интерфейс.
Часто задаваемые вопросы
ZonedDateTime содержит региональный идентификатор зоны (например, "Europe/Moscow") и обрабатывает DST. OffsetDateTime хранит только фиксированное смещение (+03:00) без региональных правил. Для хранения в базе данных рекомендуется OffsetDateTime.
Используйте ZonedDateTime.now(ZoneOffset.UTC) или Instant.now().atZone(ZoneOffset.UTC). Оба варианта возвращают текущий момент с нулевым смещением. Для простой метки времени используйте Instant.now() без привязки к зоне.
Да, но требуется кастомный адаптер. Gson не поддерживает ZonedDateTime по умолчанию. Moshi — поддерживает через адаптер Rfc3339DateJsonAdapter. Рекомендуется использовать Kotlinx Serialization или библиотеку JavaTimeModule для Jackson.
java.time автоматически сдвигает время вперёд на величину смещения. Например, если время 02:30 не существует при переводе на 03:00, ZonedDateTime создаст объект на 03:30. Проверить наличие дырки можно через ZoneRules.getTransition(instant).
JDBC 4.2 поддерживает OffsetDateTime, но не ZonedDateTime напрямую. ZonedDateTime содержит региональную зону, которая не имеет аналога в SQL. Рекомендуется сохранять OffsetDateTime или Instant, а зону хранить отдельной колонкой.
Итоги
Мы разработаем мобильное приложение под ключ
IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также