Java долго ругали за многословность: чтобы описать простой класс-«коробку» с тремя полями, приходилось писать конструктор, геттеры, equals, hashCode и toString руками. За последние версии (17–21) язык заметно подтянулся. Разберём возможности, которые делают современный код короче и безопаснее, и начнём с самой полезной — record.
Вы объявляете только компоненты. Конструктор, методы доступа, equals, hashCode и toString компилятор пишет сам — и equals сравнивает по значениям, а не по ссылке.
Зачем это всё: проблема многословности
Представьте класс, который просто хранит данные — точку на карте, строку заказа, координаты. Раньше это выглядело так:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
@Override
public boolean equals(Object o) { /* ... 10 строк ... */ }
@Override
public int hashCode() { /* ... */ }
@Override
public String toString() { /* ... */ }
}
Тридцать строк ради двух чисел. Логики тут ноль — одна рутина, которую легко написать с ошибкой (забыть поле в equals). Именно эту боль и убирает record.
Как читать версии Java
Дальше в тексте будут «с Java 9», «с Java 15», «Java 21», и чтобы в номерах не потеряться, сначала про то, как они устроены.
Модель релизов. С Java 9 версии выходят каждые полгода, но не равнозначны. Раз в два года выпускают LTS (long-term support) — версию с долгой поддержкой, на которую и переходят в проде: 8, 11, 17, 21, а с сентября 2025-го и 25 (следующая будет 29). Промежуточные (18, 19, 20…) — витрина новых возможностей, часто в статусе preview. Поэтому «мы на 21 LTS» — нормальная позиция: гнаться за каждым полугодовым релизом не нужно.
Линии, а не номера. Фичи не случайны — они складываются в несколько линий, и держать в голове полезнее их, а не «что в какой версии»:
- Меньше рутины и value-семантика —
record,var,List.of: язык сам пишет то, что раньше писали руками. - Безопасное моделирование —
sealed+ pattern matching +switch-выражение: компилятор проверяет, что разобраны все варианты. - Масштабируемый ввод-вывод — виртуальные потоки (см. многопоточность): модель «поток на задачу» снова дешёвая.
- Низкая задержка сборки мусора — современные сборщики G1 и ZGC (см. сборку мусора).
record подробнее
record — это специальный вид класса для хранения неизменяемых данных. Вы объявляете только поля, а компилятор сам генерирует конструктор, методы доступа, equals, hashCode и toString.
public record Point(int x, int y) {}
Одна строка делает всё то же, что 30 строк выше. Пользоваться так:
живой пример
public class RecordDemo {
record Point(int x, int y) {}
public static void main(String[] args) {
Point p = new Point(3, 4);
System.out.println(p.x()); // 3 — метод доступа называется как поле, без get
System.out.println(p); // Point[x=3, y=4] — готовый toString
System.out.println(p.equals(new Point(3, 4))); // true — сравнение по значениям полей
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Ключевые свойства record:
- Неизменяемость. Поля —
final, после создания их не поменять. Чтобы «изменить» точку, создают новую. - Методы доступа называются как поля:
p.x(), а неp.getX(). equals/hashCodeсравнивают по значению всех полей. Это делает record идеальным ключом дляMapили элементомSet.
Можно добавить свои методы и проверки в конструкторе. Компактный конструктор позволяет проверить аргументы без перечисления присваиваний:
public record Point(int x, int y) {
public Point { // компактный конструктор — без скобок с параметрами
if (x < 0 || y < 0) {
throw new IllegalArgumentException("Координаты не могут быть отрицательными");
}
// присваивание this.x = x; компилятор добавит сам
}
public double distanceToOrigin() { // свой метод — пожалуйста
return Math.sqrt(x * x + y * y);
}
}
Используйте record для DTO, ключей, координат — любых неизменяемых «коробок со значениями». Нужны изменяемое состояние или наследование — берите обычный класс.
Чего record не умеет, стоит знать до того, как он попадёт в проект. Он не может наследоваться от класса: скрытый родитель у него всегда java.lang.Record, и сам он final, наследников у record тоже нет; интерфейсы реализовывать можно. Поля нельзя сделать изменяемыми и нельзя добавить своё поле помимо компонентов, только статические. С JSON всё хорошо: Jackson поддерживает record с версии 2.12 и сериализует по компонентам без настроек, поэтому для тел запросов и ответов record годится сразу. А вот сущностью JPA record быть не может: сущности нужен конструктор без аргументов и изменяемые поля, которые Hibernate заполняет после создания, и оба требования record нарушает по замыслу. В слое хранения record живёт как результат запроса на чтение (проекция) и как объект-значение, а сущность остаётся классом.
Неизменяемость — только у ссылок
record делает final поля, а не объекты за ними. Если компонент — массив или изменяемая коллекция, его содержимое по-прежнему можно поменять снаружи, а сгенерированные equals и hashCode для массива сравнивают ссылки, а не элементы:
живой пример
import java.util.List;
public class RecordArrayDemo {
record Bad(int[] coords) {} // так не надо: массив в компоненте
record Good(List<Integer> coords) { // так надо: неизменяемый тип
Good {
coords = List.copyOf(coords); // защитная копия на входе
}
}
public static void main(String[] args) {
System.out.println(new Bad(new int[]{1, 2}).equals(new Bad(new int[]{1, 2}))); // false
System.out.println(new Good(List.of(1, 2)).equals(new Good(List.of(1, 2)))); // true
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Правило: компоненты record — неизменяемые типы. Вместо массива — List<Integer>, а в компактном конструкторе — защитная копия List.copyOf; тогда и сравнение по содержимому, и внешний код не сможет изменить точку. Если массив нужен по производительности, придётся переопределить equals, hashCode и toString через Arrays.* и копировать массив на входе и в методе доступа.
Optional: как перестать бояться NPE
NullPointerException (NPE) — самая частая ошибка времени выполнения в Java. Она случается, когда вы вызываете метод у переменной, которая на самом деле null. Метод, возвращающий «может быть значение, а может ничего», раньше возвращал null — и про проверку легко было забыть.
Optional<T> — это «коробка», которая либо содержит значение, либо пуста. Она явно говорит вызывающему: «здесь может не быть результата, обработай оба случая».
Optional<User> found = repository.findById(42); // явно: может не найтись
// безопасно достать значение или подставить запасное
User user = found.orElse(User.guest());
// или среагировать только если значение есть
found.ifPresent(u -> System.out.println("Привет, " + u.name()));
Сила Optional — в цепочках преобразований без россыпи if (x != null):
живой пример
import java.util.Map;
import java.util.Optional;
public class OptionalDemo {
record Address(String city) {}
record User(String name, Address address) {}
static final Map<Integer, User> USERS = Map.of(42, new User("Анна", new Address("Москва")));
static Optional<User> findById(int id) {
return Optional.ofNullable(USERS.get(id));
}
static String cityOf(int id) {
return findById(id) // Optional<User>
.map(User::address) // Optional<Address>
.map(Address::city) // Optional<String>
.orElse("неизвестен"); // String, без единого NPE
}
public static void main(String[] args) {
System.out.println(cityOf(42)); // Москва
System.out.println(cityOf(7)); // неизвестен — такого пользователя нет
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Если на любом шаге значения нет — цепочка спокойно «проваливается» в пустой Optional.
Одна и та же цепочка на двух входах: смотрите на нижний ряд, где пустая коробка проходит все map нетронутой и превращается в значение только на orElse.
В сервисном коде чаще всего нужен не запасной вариант, а ошибка: пользователя нет, и это не «неизвестен», а 404. Для этого есть orElseThrow, и это основной способ достать значение из Optional в обработчике запроса:
User user = repository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
Лямбда создаёт исключение только тогда, когда коробка пуста, поэтому цена в успешном случае нулевая. Форма без аргумента, orElseThrow(), бросает NoSuchElementException и годится там, где отсутствие значения это ошибка программиста, а не пользователя. Как такая ошибка превращается в ответ с правильным кодом, разбирает раздел про обработку ошибок дальше в программе.
Ещё Optional умеет обернуться потоком: opt.stream() даёт поток из одного элемента или пустой. Это ровно то, чем разворачивают Stream<Optional<User>> в Stream<User>: .flatMap(Optional::stream) — пустые исчезают сами, без единой проверки на null.
Антипаттерны Optional
Optional легко применить не туда. Чего не делать:
- Не путайте
orElseиorElseGet. АргументorElseвычисляется всегда — даже когда значение в коробке есть и подставлять ничего не надо. СтрокуorElse("неизвестен")это не портит, а вотorElse(loadDefaultFromDb())сходит в базу на каждом вызове, включая те, где результат не нужен. Дорогой запасной вариант передают черезorElseGet(() -> loadDefaultFromDb()): лямбда вызовется, только если коробка пуста. - Не вызывайте
.get()без проверки.opt.get()на пустом Optional бросит исключение — это тот же NPE, только сбоку. ИспользуйтеorElse,orElseThrow,ifPresent,map. - Не делайте поля класса
Optional. Optional придуман для возвращаемых значений методов, а не для хранения. Поле — пусть будет обычным, возможноnullвнутри. - Не передавайте
Optionalв параметры метода. Это запутывает вызов. Лучше сделайте перегрузку метода или принимайте обычное значение. - Не оборачивайте коллекции. Пустой
Listуже означает «ничего нет» —Optional<List<...>>лишний. Возвращайте пустой список.
switch expressions
Старый switch был оператором: он выполнял действия, требовал break (забыли — провалитесь в следующую ветку) и не возвращал значение. Современный switch умеет быть выражением — то есть возвращать результат, который сразу присваивают переменной.
живой пример
public class SwitchDemo {
enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }
public static void main(String[] args) {
for (Day dayOfWeek : Day.values()) {
String day = switch (dayOfWeek) { // без break, без сквозного проваливания
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "будни";
case SATURDAY, SUNDAY -> "выходные";
};
System.out.println(dayOfWeek + " — " + day);
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Преимущества: несколько меток через запятую, всё выражение возвращает значение. Для enum компилятор ещё и проверит, что разобраны все варианты. Если ветка требует нескольких строк, используйте блок с yield:
int code = switch (status) {
case ACTIVE -> 1;
case BLOCKED -> {
log.warn("Заблокированный статус");
yield 0; // yield возвращает значение из блока
}
};
sealed-классы и pattern matching
sealed-класс (или интерфейс) ограничивает список наследников: только перечисленные типы могут его реализовать. Это говорит и компилятору, и читателю: «вариантов ровно столько, других не будет».
Вместе с pattern matching в switch это даёт компактный и безопасный разбор по типам. Раньше писали if (s instanceof Circle) { Circle c = (Circle) s; ... } — с явным приведением. Теперь переменная объявляется прямо в проверке:
живой пример
public class ShapeDemo {
sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius(); // c уже Circle, без приведения
case Square s -> s.side() * s.side();
};
}
public static void main(String[] args) {
System.out.println(area(new Circle(1))); // 3.141592653589793
System.out.println(area(new Square(3))); // 9.0
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Поскольку Shape — sealed, компилятор знает все варианты и не потребует ветку default. Добавите третью фигуру — он сам подскажет, где switch стал неполным.
У sealed есть два требования, о которые спотыкаются при первом применении в настоящем проекте. Все наследники из permits должны жить в том же модуле, а если проект без module-info, в том же пакете: интерфейс в shop.api и реализация в shop.impl не скомпилируются. Если наследники объявлены в том же файле, permits можно не писать, компилятор найдёт их сам. И каждый наследник обязан сказать, что он делает с цепочкой дальше: final (наследников нет, record всегда такой), sealed (свой закрытый список) или non-sealed (открыт для любого наследования, и на этой ветке проверка полноты switch заканчивается). Без одного из этих слов класс-наследник не компилируется.
В Java 21 разбор пошёл дальше. В case теперь можно не просто назвать тип, а сразу разложить record на составляющие и повесить на ветку условие через when:
живой пример
public class PatternDemo {
sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
static String describe(Shape shape) {
return switch (shape) {
case Circle c when c.radius() > 100 -> "огромный круг";
case Circle(double radius) -> "круг радиуса " + radius;
case Square(double side) -> "квадрат со стороной " + side;
};
}
public static void main(String[] args) {
System.out.println(describe(new Circle(1))); // круг радиуса 1.0
System.out.println(describe(new Circle(500))); // огромный круг
System.out.println(describe(new Square(3))); // квадрат со стороной 3.0
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
case Circle(double radius) — это record-паттерн: компилятор сам достаёт компонент и кладёт его в переменную, вызывать c.radius() не нужно. А when добавляет к ветке условие; ветки проверяются сверху вниз, поэтому «огромный круг» и стоит выше общего случая. Раскладывать так можно и вложенные record — case Order(Customer(String name), var total).
Pattern matching работает и в обычном instanceof:
if (obj instanceof String str && !str.isBlank()) {
System.out.println(str.length()); // str доступна и уже String
}
text blocks
Многострочные строки (JSON, SQL, HTML) раньше склеивали из кусков с \n и экранированными кавычками \" — читать невозможно. Text block — строка в тройных кавычках, которая сохраняет переносы и кавычки как есть:
живой пример
public class TextBlockDemo {
public static void main(String[] args) {
String json = """
{
"name": "Иван",
"city": "Москва"
}
""";
System.out.print(json);
System.out.println("длина: " + json.length() + " — отступ слева обрезан");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Отступ слева компилятор обрежет сам — и вот тут ждёт сюрприз. В расчёт идёт не только текст, но и строка с закрывающими """. Сдвиньте её к левому краю — и обрезать станет нечего: общий отступ окажется нулевым, и каждая строка JSON начнётся с тех самых пробелов, которыми вы её выровняли в коде. Поэтому закрывающие кавычки держат на том же отступе, что и текст, а не «где получилось». В остальном удобно для встроенных SQL-запросов и тестовых данных.
Что ещё появилось в современной Java
Несколько мелочей, которые встретятся сразу. Версию рядом стоит запомнить: если проект собирается на более старой, эти вещи просто не скомпилируются.
var(Java 10) — вывод типа локальной переменной:var users = new ArrayList<User>();. Тип всё равно статический, просто его не пишут дважды. Только для локальных переменных, где тип и так очевиден из правой части.List.of,Map.of(Java 9) — быстрые неизменяемые коллекции:List.of("a", "b").- Понятные NullPointerException (Java 14, по умолчанию с 15) — сообщение об NPE теперь точно называет, какая именно переменная оказалась
null. - Виртуальные потоки (Java 21) — лёгкие потоки для масштабируемого ввода-вывода; подробности — тема раздела про многопоточность, здесь просто знайте, что они есть.
Мелочей на самом деле больше, и часть из них вы уже встречали в соседних статьях фазы, не зная, что они недавние. Чтобы не принять их за что-то древнее и не искать в старом проекте:
Files.readStringиwriteString(Java 11) — прочитать и записать файл одной строкой, иHttpClient(Java 11) — встроенный HTTP-клиент вместо сторонних библиотек.switch-выражение (Java 14), text blocks (15),record(16),instanceofс образцом (16),sealed(17) — то, о чём эта статья.Stream.toList()(Java 16) — короткая заменаcollect(Collectors.toList()), возвращает неизменяемый список.String.formatted(Java 15) —"%s: %d".formatted(name, count)вместоString.format.- record-паттерны и
whenвswitch(Java 21) — разбор record на компоненты прямо вcase.
Глубже: дата и время: java.timeрасширенное
Дальше по программе PostgreSQL с timestamptz, планировщики и логи, и везде время; Java-сторона этого нигде в фазе не объяснена. Пакет java.time (с Java 8) отвечает на три разных вопроса тремя типами, и путаница между ними это половина ошибок со временем.
Instant это момент на шкале времени без часового пояса, число наносекунд от начала 1970 года в UTC; «событие произошло тогда-то» это Instant, и в базе ему соответствует timestamptz. LocalDateTime это дата и время «на стене», без указания, где эта стена: 1 сентября 09:00 в Москве и во Владивостоке это разные моменты, но одинаковый LocalDateTime; годится для расписаний («магазин открывается в 9:00») и не годится для отметок событий. ZonedDateTime это стена плюс пояс, и он нужен, чтобы превратить одно в другое. Отдельно LocalDate для дат без времени (день рождения, дата доставки) и Duration с Period для интервалов.
живой пример
import java.time.*;
import java.time.format.DateTimeFormatter;
public class Times {
public static void main(String[] args) {
Instant moment = Instant.parse("2026-03-29T00:30:00Z");
ZoneId moscow = ZoneId.of("Europe/Moscow");
ZonedDateTime inMoscow = moment.atZone(moscow);
System.out.println(inMoscow);
System.out.println(inMoscow.plusHours(1).toInstant() + " | " + Duration.between(moment, inMoscow.plusHours(1).toInstant()));
LocalDate delivery = LocalDate.of(2026, 1, 31).plusMonths(1);
System.out.println(delivery + " " + delivery.getDayOfWeek());
System.out.println(LocalDateTime.of(2026, 9, 1, 9, 0).atZone(moscow).toInstant());
System.out.println(inMoscow.format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Что показывает пример. Момент в UTC в Москве это три часа утра, и обратный перевод всегда через пояс. Прибавить месяц к 31 января даёт 28 февраля, а не ошибку и не 3 марта: арифметика дат подгоняет под конец месяца. Расписание в LocalDateTime превращается в момент только вместе с поясом. Форматирование через DateTimeFormatter, и шаблон yyyy это год, а YYYY это год недели, который в конце декабря даёт следующий год, классическая ошибка в отчётах.
Правила для сервиса. Внутри и в базе Instant в UTC, пояс появляется только на границе с человеком (показать, разобрать введённое). Сервер работает в UTC, чтобы LocalDateTime.now() не зависел от машины; ещё лучше не вызывать now() в бизнес-логике вовсе, а получать Clock, о чём статьи про тестирование. Старые java.util.Date и Calendar не используют: они изменяемы, считают месяцы с нуля и путают пояса; в старом API их конвертируют на границе через toInstant(). Пояс задают именем региона (Europe/Moscow), а не смещением (+03:00): смещение меняется с переводом часов и законами, регион знает историю. И в JSON время отдают строкой ISO 8601 с зоной (2026-03-29T00:30:00Z), о чём статья про формат ответа в разделе REST.
Коротко
recordгенерирует конструктор, методы доступа,equals/hashCode/toString— используйте для неизменяемых классов-данных (DTO, ключи, value object).Optional<T>— честный тип возврата «значение может отсутствовать»; доставайте черезorElse/map/ifPresent, не через.get().- Антипаттерны Optional:
.get()без проверки,orElseс дорогим аргументом вместоorElseGet, Optional в полях и параметрах, обёртка над коллекциями. - switch expression возвращает значение, не требует
break, проверяет полноту дляenum. sealed+ pattern matching дают безопасный разбор по фиксированному набору типов без ручного приведения; в Java 21 к этому добавились record-паттерны (case Circle(double r)) и условияwhen.- Text block (
""") — читаемые многострочные строки без экранирования. var,List.of, понятные NPE и виртуальные потоки — приятные мелочи современного Java.- Время:
Instantдля моментов (в базеtimestamptz),LocalDateTimeдля расписаний без пояса,ZonedDateTimeдля перевода между ними; внутри UTC, пояс на границе с человеком, регион вместо смещения,yyyyа неYYYY. - Record не наследуется от класса и сам
final; Jackson читает его без настроек, сущностью JPA он быть не может (только проекцией и объектом-значением);orElseThrow(() -> new NotFound(id))— основной способ достать значение в сервисе. - Наследники
sealed-типа живут в том же модуле или пакете и объявляют себяfinal,sealedилиnon-sealed; недавнее, что легко принять за древнее:Files.readString(11),switch-выражение (14), text blocks (15),recordиStream.toList(16),sealed(17), record-паттерны (21).
Что почитать дальше
- Лямбды и Stream API — где
Optional.mapиswitchвстречаются чаще всего. - ООП в Java — чем
recordиsealedотличаются от обычных классов и наследования. - Инструменты и сборка — как собрать и запустить проект на актуальной версии языка.
- Зачем Java-разработчику Kotlin — второй язык той же JVM: что он даёт там, где
recordиsealedразрыв не закрыли.