Java LocalDateTime
Объединяйте дату и время без информации о часовом поясе в Java с помощью LocalDateTime.
LocalDateTime — это LocalDate и LocalTime, склеенные вместе: календарная дата и время суток, при этом по-прежнему без часового пояса. Это естественный тип для значений вида «это событие произошло в 14:30 4 ноября», и он является наиболее используемым типом java.time в реальных проектах, которые не имеют дела с глобальными пользователями.
Fluent API по форме идентичен обеим половинам — те же фабричные методы now/of/parse, те же модификаторы plusX/minusX/withX, те же сравнения isBefore/isAfter. Интересны именно новые возможности: объединение с датой или временем, обратное разложение и явное преобразование в ZonedDateTime, когда часовой пояс наконец становится важен.
Создание
LocalDateTime now = LocalDateTime.now(); // JVM default zone
LocalDateTime made = LocalDateTime.of(2025, 11, 4, 14, 30);
LocalDateTime made2 = LocalDateTime.of(2025, Month.NOVEMBER, 4, 14, 30, 15);
LocalDateTime made3 = LocalDateTime.of(LocalDate.of(2025, 11, 4), LocalTime.of(14, 30));
LocalDateTime parsed = LocalDateTime.parse("2025-11-04T14:30:00"); // ISO-8601Форма ISO-8601 содержит литеральный символ T между датой и временем — это стандартный разделитель. LocalDateTime.parse("2025-11-04 14:30:00") (пробел вместо T) не будет разобран с настройками по умолчанию; для этого потребуется пользовательский DateTimeFormatter.
Разложение
LocalDate date = dt.toLocalDate();
LocalTime time = dt.toLocalTime();Эти методы являются обратными к LocalDate.atTime(time) / LocalTime.atDate(date). Оба — чистая проекция: без потери информации, без введения часового пояса.
Все аксессоры сразу
LocalDateTime наследует меню аксессоров от обеих половин:
dt.getYear(); dt.getMonth(); dt.getMonthValue();
dt.getDayOfMonth(); dt.getDayOfWeek(); dt.getDayOfYear();
dt.getHour(); dt.getMinute(); dt.getSecond(); dt.getNano();Те же имена, та же семантика. Вам не нужно вызывать toLocalDate() или toLocalTime(), чтобы получить доступ к отдельным компонентам — они все доступны напрямую.
Арифметика через границу
Ключевое отличие от LocalTime: LocalDateTime.plusHours(3) на значении 23:00 не переполняется молча. Оно переходит на следующий день:
LocalDateTime late = LocalDateTime.of(2025, 11, 4, 23, 0);
late.plusHours(3); // 2025-11-05T02:00 — date advanced as expectedИменно поэтому следует использовать LocalDateTime вместо LocalTime для любых вычислений, которые могут пересекать полночь. Математика согласуется с тем, что вы ожидаете от настоящих часов, которые знают, какой сегодня день.
dt.plusDays(7); dt.plusHours(36); dt.plusMinutes(150);
dt.minusYears(1); dt.minusSeconds(45);
dt.withYear(2026); dt.withHour(0); dt.withMinute(0);Правило усечения plusMonths из LocalDate применяется и здесь: LocalDateTime.of(2025, 1, 31, 12, 0).plusMonths(1) даёт 2025-02-28T12:00, а не 2025-03-03T12:00. Усечение применяется только к компоненту даты; время остаётся неизменным.
Часовой пояс намеренно отсутствует
LocalDateTime — это не момент на глобальной временной шкале. LocalDateTime.of(2025, 11, 4, 9, 0) может означать 9 утра в Нью-Йорке, 9 утра в Берлине или 9 утра в Токио — три совершенно разных Instant, и LocalDateTime не скажет вам, какой именно. Если два LocalDateTime равны, это означает равенство строк даты и времени; это не означает равенство лежащих в основе моментов.
Это особенность, а не ошибка. Для «контракт подписывается в 14:00 по местному времени там, где находится подписант» LocalDateTime — именно правильный тип. Для «сервер получил запрос в...» — неправильный тип: используйте Instant. Для «встреча начинается в 14:00 по нью-йоркскому времени» — тоже неправильный: используйте ZonedDateTime.
Чтобы преобразовать значение в момент с часовым поясом, необходимо явно добавить пояс:
ZonedDateTime ny = ldt.atZone(ZoneId.of("America/New_York"));
Instant inst = ldt.atZone(ZoneId.systemDefault()).toInstant();Вызов atZone(...) является ключевым — именно в этот момент система типов заставляет вас решить, какой часовой пояс вы имеете в виду. После принятия решения преобразование в Instant становится механическим. Следующие две главы (ZonedDateTime, Instant) подробно рассматривают типы с часовым поясом и глобальные типы.
Сравнение
dt.isBefore(other);
dt.isAfter(other);
dt.isEqual(other);
dt.compareTo(other);Порядок лексикографический по (date, time). То же предупреждение, что и прежде: два LocalDateTime сравниваются по строковым представлениям даты и времени, а не по лежащим в основе моментам — потому что без часового пояса никаких «лежащих в основе моментов» не существует.
Расстояние
ChronoUnit.X.between работает напрямую:
long minutes = ChronoUnit.MINUTES.between(start, end);
long days = ChronoUnit.DAYS.between(start, end);
Duration d = Duration.between(start, end);Duration.between работает с LocalDateTime (как и с любым Temporal). Для чисто календарной арифметики — «сколько месяцев между двумя LocalDateTime» — используйте ChronoUnit.MONTHS.between, который возвращает long, или Period.between(start.toLocalDate(), end.toLocalDate()) для разбивки в календарном виде.
Практический пример: планирование через полночь
Программа ниже использует LocalDateTime для небольшого кода планирования: ночная смена начинается в 22:00 и заканчивается в 06:00, продолжительность вычисляется корректно через полночь; «сейчас» округляется вверх до следующей четверти часа; находится следующее вхождение повторяющейся встречи в 09:30; а также демонстрируется правило «зону необходимо добавить явно» при преобразовании в момент времени.
Что важно вынести из запуска:
Duration.between(startShift, endShift)вернулPT8H. Смена пересекла полночь, и компоненты даты выполнили перенос — никакой неоднозначности. То же вычисление на чистыхLocalTimeвернуло быPT-16H(ловушка LocalTime из предыдущей главы). Для арифметики, которая может пересекать полночь,LocalDateTime— правильный тип.- Начиная с
20:00,plusHours(3)осталось на11-04(23:00, полночь не пересечена);plusHours(5)перешло вперёд на11-05T01:00. Семействоplus/minusуLocalDateTimeкорректно распространяет переносы по всей цепочкеY/M/D/h/m/s/ns. Никакой специальной обработки в вашем коде не требуется. - Блок «следующая встреча в 09:30» построил сегодняшнее значение 09:30 тремя вызовами
withX, затем выбрал между «сегодня» и «завтра» на основеisBefore. Это стандартный шаблон для «следующего повторяющегося события в данное время суток» — достаточно компактный, чтобы встроить его inline, и достаточно распространённый, чтобы вынести в вспомогательный метод, если таких случаев много. - Блок «одинаковый LocalDateTime, разные зоны» дал два разных
Instantс разницей в шесть часов. Это главная причина, по которойLocalDateTimeне претендует на то, чтобы быть моментом. Класс отказывается делать вид, что даты и времени вместе достаточно; это надпись на настенных часах где-то, а на каких именно — зависит от предоставленного вами часового пояса. - Итоговая проверка иммутабельности показала, что
nowне изменился послеplusDays(7).withHour(0).withMinute(0). Эта гарантия сохраняется для каждой операции, каждой цепочки, каждого вспомогательного метода — не существует способа изменитьLocalDateTime. Передавайте его свободно, используйте совместно в потоках, храните вMap.
Что дальше
LocalDateTime — последний из трёх «Local»-типов: без часового пояса, без претензий быть моментом. Следующая глава, Java ZonedDateTime, явно добавляет часовой пояс: LocalDateTime плюс ZoneId плюс разрешённое смещение для данного местного времени в данном поясе — вместе они фиксируют реальный момент на глобальной временной шкале.