Java Period
Представляйте календарные промежутки (годы, месяцы, дни) в Java с помощью Period.
Period — это «календарный» брат Duration. Если Duration — это «X секунд плюс Y наносекунд», то Period — это «X лет, Y месяцев, Z дней». Это правильный тип для любого промежутка, который вы выразили бы в календарных единицах: «пробный период 30 дней», «годовая подписка», «двухмесячный срок уведомления», «прибавить один платёжный цикл к дате продления».
Эти два типа никогда не смешиваются. Duration.ofDays(30) — это ровно 30 × 24 × 3600 секунд. Period.ofDays(30) — это 30 календарных дней, которые обычно, но не всегда равны 30 × 24 часам (при переходе на летнее/зимнее время добавляется или убирается час). Для «точного количества секунд» используйте Duration. Для «календарного дня, который наступит через N дней», — Period.
Создание
Period.ofDays(30);
Period.ofWeeks(2); // stored as 14 days
Period.ofMonths(3);
Period.ofYears(1);
Period.of(1, 6, 0); // 1 year, 6 months, 0 days
Period.between(startDate, endDate); // takes LocalDate (not LocalDateTime)
Period.parse("P1Y2M3D"); // ISO-8601: P[years]Y[months]M[days]DСтроковый формат — PnYnMnD: P1Y2M3D — один год, два месяца, три дня. Префикс P обязателен. Буква T не используется (это сделало бы строку Duration); часы, минуты и секунды не поддерживаются (они здесь неуместны).
Period.between(start, end) принимает два значения LocalDate и возвращает разбивку разницы:
Period age = Period.between(LocalDate.of(1990, 3, 15), LocalDate.of(2025, 11, 4));
// P35Y7M20D — 35 years, 7 months, 20 daysЭто стандартный способ «вычислить возраст». Результат — разбивка, а не одно число: в нём 35 лет, затем 7 месяцев сверху, затем 20 дней. Чтобы свести к одному значению, используйте ChronoUnit.YEARS.between(...), возвращающий long.
Инспекция
Period p = Period.of(1, 6, 14);
p.getYears(); // 1
p.getMonths(); // 6
p.getDays(); // 14
p.toTotalMonths(); // 1 * 12 + 6 = 18 (years + months, ignoring days)
p.isZero(); // false
p.isNegative(); // true if any component is negativeТри метода доступа для трёх компонентов, плюс toTotalMonths для быстрой агрегации. Метода toTotalDays нет — для этого потребовался бы календарный контекст (год содержит 365 или 366 дней; месяц — от 28 до 31).
Арифметика
p.plus(Period.ofMonths(1));
p.plusYears(1);
p.plusMonths(6);
p.plusDays(14);
p.minus(Period.ofDays(7));
p.multipliedBy(3);
p.negated();
p.normalized(); // collapse extra months into yearsnormalized() — интересный метод: он преобразует любое количество месяцев от 12 и более в годы. Period.of(0, 14, 0).normalized() даёт Period.of(1, 2, 0). Дни он не затрагивает — «нормализовать 31 день в 1 месяц и 1 день» невозможно, потому что месяцы имеют разную длину.
Прибавление к дате
Period реализует TemporalAmount. Любой «датоподобный» Temporal принимает его:
LocalDate maturity = LocalDate.of(2025, 11, 4).plus(Period.ofMonths(6));
LocalDate retirement = LocalDate.of(1990, 3, 15).plus(Period.ofYears(65));
LocalDateTime renewal = LocalDateTime.of(2025, 11, 4, 9, 0).plus(Period.ofYears(1));
ZonedDateTime nextBill = zdt.plus(Period.ofMonths(1));Прибавление месячной или годовой части Period к Instant выбрасывает UnsupportedTemporalTypeException — Instant является точкой на временной шкале без календаря, поэтому JDK отказывается вычислять «момент через один месяц» без часового пояса. (Дневная часть допустима: Instant.plus(Period.ofDays(1)) работает, потому что JDK считает день ровно 86 400 секундами. Только месяцы и годы не имеют фиксированной длины и потому не имеют смысла для Instant.) Когда нужна календарная арифметика, выполните конвертацию через ZonedDateTime:
Instant nextMonth = inst.atZone(ZoneId.of("UTC"))
.plus(Period.ofMonths(1))
.toInstant();Эта намеренная многословность — место, где вы предоставляете недостающий календарный контекст.
Правило усечения plusMonths из главы LocalDate применяется к арифметике с Period точно так же: 31 января + Period.ofMonths(1) даёт 28 февраля, а не 3 марта.
Period не нормализует компоненты между собой
Тонкое поведение: Period.of(1, 0, 365) не равен Period.of(2, 0, 0), даже если при прибавлении к обычной дате они описывают одинаковый промежуток. Класс хранит разбивку дословно и сравнивает по структуре:
Period.of(1, 0, 365).equals(Period.of(2, 0, 0)); // false
Period.of(0, 14, 0).equals(Period.of(1, 2, 0)); // false (until normalized())
Period.of(0, 14, 0).normalized().equals(Period.of(1, 2, 0)); // trueЧтобы проверить «является ли этот период хотя бы годом независимо от разбивки», сравнивайте по датам: start.plus(p1).isEqual(start.plus(p2)) — единственная полностью корректная проверка.
Разница: Period.between против ChronoUnit.between
Period diff = Period.between(start, end); // calendar breakdown
long days = ChronoUnit.DAYS.between(start, end); // single long
long months = ChronoUnit.MONTHS.between(start, end);
long years = ChronoUnit.YEARS.between(start, end);Оба отвечают на разные вопросы:
Period.between(start, end)возвращает «1 год, 6 месяцев, 14 дней» — полезно, когда нужно отобразить разбивку.ChronoUnit.DAYS.between(start, end)возвращает567(или столько дней, сколько есть в действительности) — полезно для сравнения или накопления.
Используйте второй вариант, когда нужно выполнять арифметические операции с результатом. Используйте первый, когда хотите показать пользователю.
Практический пример: подписки, пробные периоды и возраст
Программа ниже использует Period для небольшого сценария подписки: пробный период заканчивается через месяц после регистрации, дата продления повторяется ежегодно, возраст клиента вычисляется по дате рождения, а поведение усечения на границах месяцев показано явно. Также демонстрируется контраст с Duration для «того же промежутка в реальном времени».
Что следует вынести из запуска:
- Прибавление
Period.ofMonths(1)к 31 января дало 28 февраля — то же правило усечения, что и в LocalDate.Period.plusMonths(1).minusMonths(1)не всегда является тождественным преобразованием. Если вы вычисляете даты выставления счёта вблизи конца месяца, учитывайте усечение явно (например, всегда выставляйте счёт 1-го числа следующего месяца), а не рассчитывайте на симметрию туда-обратно. Period.between(birth, today)вернул календарную разбивку — годы, месяцы, дни. Чтобы проверить «является ли человек совершеннолетним», используйтеChronoUnit.YEARS.between(birth, today) >= 18, а неage.getYears() >= 18. В данном случае оба дают одинаковый ответ, но в общем они отвечают на разные вопросы —ChronoUnit.YEARS.betweenвозвращает общее количество полных лет,age.getYears()— компонент лет в разбивке.Period.of(0, 14, 0).normalized()сталPeriod.of(1, 2, 0). Количество дней не изменилось — дни нельзя нормализовать, не зная, какие месяцы задействованы. Если вы формируетеPeriodарифметически и хотите получить «чистое» представление, вызовитеnormalizedперед сохранением или отображением.P1Y.equals(P12M)вернулfalse, иP1Y.equals(P365D)тожеfalse. Равенство структурное, а не по длине. Применённые к2024-01-31(високосному году),+ P1Yи+ P12Mоба дали2025-01-31, а+ P365Dдал2025-01-30— на день меньше, потому что в 2024 году 366 дней. Так что даже «одинаковая длина» зависит от даты применения. Если вам действительно нужно «дают ли эти два Period одинаковую конечную дату?», вычислите обе конечные даты и сравните сLocalDate.isEqual. Форма.normalized()решает проблему год/месяц, но никогда — проблему дней.- Вызов
inst.plus(Period.ofMonths(1))выбросилUnsupportedTemporalTypeException.Instantне имеет календаря, поэтому месяц (чья длина меняется) не имеет смысла для него. Дневная частьPeriodдопустима дляInstant— день — это ровно 86 400 секунд — но месяцы и годы нет. Сначала выполните конвертацию черезZonedDateTime; система типов заставляет явно выбрать часовой пояс. Зеркальная ошибка из главы о Duration (DurationдляLocalDate) — та же концепция: JDK отказывается придумывать недостающий контекст.
Что дальше
Period завершает пару «промежутки времени». Следующие две главы посвящены границе строка ↔ значение: Форматирование дат в Java — преобразование значений java.time в строки, и Разбор дат в Java — обратная операция. Обе используют DateTimeFormatter — современную потокобезопасную замену устаревшего SimpleDateFormat.