← назад к разделу

Java долго ругали за многословность: чтобы описать простой класс-«коробку» с тремя полями, приходилось писать конструктор, геттеры, equals, hashCode и toString руками. За последние версии (17–21) язык заметно подтянулся. Разберём возможности, которые делают современный код короче и безопаснее, и начнём с самой полезной — record.

одна строка объявления — остальное дописывает компилятор record Point(int x, int y) то, что пишете вы компилятор и дописывает за вас Point(int, int) x() y() equals(Object) hashCode() toString() new Point(3, 4).equals(new Point(3, 4)) → true: сравниваются значения полей

Вы объявляете только компоненты. Конструктор, методы доступа, 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.

вызов findById map(address) map(city) orElse id 42 Optional[Анна] Optional[адрес] Optional[Москва] «Москва» id 7 Optional.empty Optional.empty Optional.empty «неизвестен»

Одна и та же цепочка на двух входах: смотрите на нижний ряд, где пустая коробка проходит все 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).

Что почитать дальше