Современная Java: records, sealed-классы, pattern matching и virtual threads

Как изменился язык Java к 2026: records и sealed-классы, pattern matching для switch, текстовые блоки и virtual threads (Project Loom) — что из этого реально меняет стиль backend-кода

Образ Java как «многословного энтерпрайза из 2000-х» устарел лет на десять. Records, sealed-классы, pattern matching и virtual threads изменили и то, как пишется код, и то, как он исполняется под нагрузкой. Вопрос не в списке фич, а в том, что из них меняет привычные решения в backend.

Все фичи ниже — не «экспериментальный синтаксис завтрашнего дня». Records — с JDK 16, sealed-классы — с JDK 17, полноценный pattern matching для switch и exhaustive-проверка — с JDK 21, unnamed variables (_) — с JDK 22. К моменту JDK 25 (LTS) весь этот набор давно финализирован — ни один пример в статье не требует флага --enable-preview. Единственное исключение с противоположным знаком — string templates, которые часто вспоминают в одном списке с остальным: они не финализированы никогда, а на JDK 25 удалены из языка вовсе.

Эта статья — про современный язык Java как инструмент backend-разработчика: что стоит начать использовать сразу, а что подождёт, и что не стоит использовать вообще, потому что этого больше нет. Все примеры ниже реально скомпилированы и прогнаны на JDK 25.0.3 (Temurin) — не переписаны по памяти.

Современная Java: records, sealed-типы и pattern matching — язык больше не verbose

В статье

Records: данные без boilerplate

record — неизменяемый носитель данных: компилятор сам генерирует канонический конструктор, приватные final-поля, публичные accessor’ы (x(), а не getX()), equals/hashCode/toString по значению всех компонентов. Пятьдесят строк ручного DTO-boilerplate сжимаются в одну строку объявления.

// Обычный record: неявные accessor'ы (x(), y()), equals/hashCode/toString,
// канонический конструктор генерируется компилятором.
record Point(int x, int y) {}

// Compact constructor: валидация без повторения списка параметров
// и без явного присваивания полей (компилятор доассигнует их сам).
record Range(int lo, int hi) {
    Range {
        if (lo > hi) {
            throw new IllegalArgumentException("lo=%d > hi=%d".formatted(lo, hi));
        }
    }
}

Compact constructor — не отдельная фича, а часть той же JEP 395 (records, finalized в JDK 16): тело без списка параметров, поля присваиваются неявно после выполнения тела. Это единственное место, где стоит держать инварианты записи: new Range(10, 1) в стенде реально бросает IllegalArgumentException ещё до того, как объект собран.

Отдельная, более поздняя фича (JEP 440, finalized в JDK 21, preview был в 19/20) — record patterns: деконструкция record’а прямо в instanceof или switch, в том числе вложенная, на несколько уровней:

record Line(Point from, Point to) {}

sealed interface Shape permits Circle, Rectangle {}
record Circle(Point center, int radius) implements Shape {}
record Rectangle(Point topLeft, Point bottomRight) implements Shape {}

static String describe(Object o) {
    // record pattern в switch: деконструкция + guarded pattern (when) в одном выражении.
    return switch (o) {
        case Point(int x, int y) when x == 0 && y == 0 -> "точка в начале координат";
        case Point(int x, int y) -> "точка (%d, %d)".formatted(x, y);
        case Line(Point(var x1, var y1), Point(var x2, var y2)) ->
                "отрезок (%d,%d)-(%d,%d)".formatted(x1, y1, x2, y2);
        case Circle(Point c, int r) -> "круг радиуса %d в (%d,%d)".formatted(r, c.x(), c.y());
        case Rectangle r -> "прямоугольник %s-%s".formatted(r.topLeft(), r.bottomRight());
        default -> "нечто иное: " + o;
    };
}

Line(Point(var x1, var y1), Point(var x2, var y2)) деконструирует сразу два уровня вложенности — Line внутрь двух Point, а те — в координаты — за один case, без каскада instanceof и явных касетов. И describe, и compact-constructor инвариант из стенда RecordsDemo реально прогнаны на JDK 25.0.3: Range(10, 1) ловится компакт-конструктором, equals у Point работает по значению, а не по ссылке.

Sealed-иерархии и exhaustive switch

sealed (JEP 409, finalized в JDK 17, preview был в 15/16) закрывает иерархию: интерфейс или класс явно перечисляет через permits, кто имеет право быть наследником. Это не просто документация — компилятор знает полный список вариантов и использует это знание при проверке switch.

sealed interface PaymentEvent permits Authorized, Captured, Refunded, Failed {}

record Authorized(String orderId, long amountCents) implements PaymentEvent {}
record Captured(String orderId, long amountCents) implements PaymentEvent {}
record Refunded(String orderId, long amountCents, String reason) implements PaymentEvent {}
record Failed(String orderId, String errorCode) implements PaymentEvent {}

// switch-выражение БЕЗ default: компилятор проверяет, что покрыты все 4
// permits-варианта. Закомментируй любую ветку — получишь ошибку компиляции
// "the switch expression does not cover all possible input values".
static String describe(PaymentEvent event) {
    return switch (event) {
        case Authorized(var orderId, var amount) ->
                "заказ %s авторизован на %d коп.".formatted(orderId, amount);
        case Captured(var orderId, var amount) ->
                "заказ %s списан на %d коп.".formatted(orderId, amount);
        case Refunded(var orderId, var amount, var reason) ->
                "заказ %s возвращён (%d коп., причина: %s)".formatted(orderId, amount, reason);
        case Failed(var orderId, var code) ->
                "заказ %s упал с кодом %s".formatted(orderId, code);
    };
}

Здесь нет default, и это осознанный выбор, а не недосмотр: exhaustiveness-проверка для switch над sealed-иерархией — часть pattern matching for switch (JEP 441, finalized в JDK 21). Добавь пятый вариант в permits — и describe перестанет компилироваться в каждом месте, где switch считался исчерпывающим, пока ты не допишешь ветку. Это ошибка компиляции, а не runtime-сюрприз, как было бы с обычным полиморфизмом и цепочкой if-else:

flowchart TD P["sealed interface PaymentEvent\npermits Authorized, Captured, Refunded, Failed"] P --> A["record Authorized\n(orderId, amountCents)"] P --> C["record Captured\n(orderId, amountCents)"] P --> R["record Refunded\n(orderId, amountCents, reason)"] P --> F["record Failed\n(orderId, errorCode)"] A --> SW["switch без default —\nкомпилятор проверяет полноту"] C --> SW R --> SW F --> SW style P fill:#f9f3e3,stroke:#8b7355 style SW fill:#c9e4c5,stroke:#5b8a5e

flowchart TD
  P["sealed interface PaymentEvent\npermits Authorized, Captured, Refunded, Failed"]
  P --> A["record Authorized\n(orderId, amountCents)"]
  P --> C["record Captured\n(orderId, amountCents)"]
  P --> R["record Refunded\n(orderId, amountCents, reason)"]
  P --> F["record Failed\n(orderId, errorCode)"]
  A --> SW["switch без default —\nкомпилятор проверяет полноту"]
  C --> SW
  R --> SW
  F --> SW

  style P fill:#f9f3e3,stroke:#8b7355
  style SW fill:#c9e4c5,stroke:#5b8a5e
Sealed-иерархия PaymentEvent: компилятор знает все 4 варианта наперёд

sealed работает и с обычными классами, не только с record’ами и интерфейсами — например, абстрактный sealed class Shape permits Circle, Square (это отдельный демо-тип из SealedDemo, не путать с sealed interface Shape из примера про record patterns выше — совпало только имя, а живут они в разных демо-классах), где ветки switch — обычные (не record) type patterns, но проверка исчерпаемости та же. В стенде SealedDemo прогнаны оба варианта: sealed interface с 4 наследниками (PaymentEvent) и abstract sealed class с 2 обычными классами (ShapeCircle, Square).

Pattern matching: instanceof, switch, guarded when

Pattern matching for instanceof (JEP 394, finalized в JDK 16) убирает отдельный явный каст: тип-переменная становится доступна прямо в true-ветке условия.

static String formatLength(Object o) {
    if (o instanceof String s && !s.isEmpty()) {
        return "строка длиной " + s.length();
    }
    if (o instanceof List<?> list) {
        return "список из " + list.size() + " элементов";
    }
    return "неизвестный тип: " + o;
}

Pattern matching for switch (JEP 441, finalized в JDK 21, после preview в 17–20) переносит ту же идею в switch: type patterns, явный case null и guarded patterns через when.

// Явный "case null" — тоже часть JEP 441: без него switch над ссылочным
// типом по-прежнему бросает NullPointerException на null, как и раньше.
static String classify(Object o) {
    return switch (o) {
        case null -> "null";
        case Integer i when i < 0 -> "отрицательное целое: " + i;
        case Integer i when i == 0 -> "ноль";
        case Integer i -> "положительное целое: " + i;
        case String s when s.isBlank() -> "пустая/пробельная строка";
        case String s -> "строка: \"" + s + "\"";
        default -> "прочее: " + o;
    };
}

Без case null этот же switch бросил бы NullPointerException на null-значении — так было и до JEP 441, это поведение не изменилось. Изменилось то, что теперь можно явно завести для null отдельную ветку, а не отлавливать его до входа в switch. when в guarded pattern — не отдельная мини-фича, а часть того же JEP: без него пришлось бы разбивать Integer i when i < 0 на case Integer i с ручным if внутри тела ветки, теряя выразительность выражения-switch.

Unnamed variables: _

Unnamed variables & patterns (JEP 456, finalized в JDK 22, preview был в 21 под JEP 443) — _ для мест, где значение по языку обязано существовать, но реально не используется. Компилятор запрещает читать _ — это не идентификатор, а маркер «не называем и не трогаем», и его можно повторно использовать в одной области видимости сколько угодно раз.

// 1) catch-параметр: тип исключения важен, а сам объект исключения — нет.
try {
    Integer.parseInt("not-a-number");
} catch (NumberFormatException _) {
    System.out.println("поймали NumberFormatException, детали не нужны");
}

// 2) unnamed variable в enhanced-for: важно количество итераций, не элемент.
int count = 0;
for (String _ : items) {
    count++;
}

// 3) unnamed pattern-компонент в деконструкции record: нужен только x.
if (o instanceof Point(int x, int _)) {
    System.out.println("x=" + x + " (y деконструирован, но не назван)");
}

// 4) unnamed lambda-параметр: BiFunction, где второй аргумент не нужен телу.
BiFunction<Integer, Integer, Integer> firstOnly = (a, _) -> a * 10;

// 5) unnamed local variable для игнорирования результата вызова.
var _ = list.add(42); // add() возвращает boolean  явно не используем

Каждый из пяти сценариев — реально прогнанный код из UnnamedVariablesDemo: catch-параметр, элемент цикла, компонент record pattern, параметр лямбды, локальная переменная под результат вызова. Практический эффект небольшой, но заметный в code review: _ явно говорит «это значение осознанно проигнорировано», а не «переменная, о которой автор забыл».

Virtual threads: синтаксис

Virtual threads (JEP 444, finalized в JDK 21, preview был в 19/20) — это модель конкурентности, а не просто языковый синтаксис, поэтому подробный разбор механизма (continuations, carrier-потоки, pinning) и живой бенчмарк throughput/latency/памяти против platform-пула и reactive — в отдельной статье про конкурентность на JVMготовится, с 30 июля. Здесь — только API, с которым вы реально столкнётесь в коде.

// try-with-resources закрывает executor и дожидается завершения задач (close() блокирующий).
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Callable<Integer>> tasks = IntStream.range(0, 1000)
            .<Callable<Integer>>mapToObj(i -> () -> {
                Thread.sleep(Duration.ofMillis(1));
                return i;
            })
            .toList();

    List<Future<Integer>> futures = executor.invokeAll(tasks);
    int sum = 0;
    for (Future<Integer> f : futures) {
        sum += f.get();
    }
    System.out.println("1000 virtual-thread задач (invokeAll), сумма индексов = " + sum);
}

// Thread.ofVirtual(): точечный запуск одного виртуального потока, без пула —
// планировщик виртуальных потоков сам мультиплексирует их на carrier-потоки.
Thread vt = Thread.ofVirtual()
        .name("demo-virtual-thread")
        .start(() -> System.out.println("виртуальный поток: " + Thread.currentThread()));
vt.join();

В стенде VirtualThreadsDemo 1000 задач через invokeAll реально возвращают сумму индексов 499 500 (корректно для диапазона 0..999), а vt.isVirtual()true при Thread.currentThread().isVirtual() в mainfalse. Синтаксически разница между virtual threads и обычным platform-пулом — буквально смена одной реализации ExecutorService, весь остальной код задачи не меняется. Именно поэтому «блокирующий код снова в порядке»: Thread.sleep(), блокирующий JDBC-драйвер, HttpClient.send() — работают без reactive-цепочек. Но у модели есть и границы — pinning на synchronized-блоках, нативные вызовы — они разобраны отдельно в статье про конкурентность на JVM, ссылка выше.

String templates: удалены, а не «пока preview»

Здесь стоит быть предельно точным, потому что легко ошибиться по старой памяти или по устаревшим статьям в сети. String templates (STR."Hello \{name}") прошли preview в JDK 21 (JEP 430) и второй preview в JDK 22 (JEP 459) — но в JDK 23 фича была снята с релиза целиком, на редизайн API процессоров. Это не «не успели финализировать» — OpenJDK явно решил, что текущий дизайн API не годится, и withdrawn-статус сохраняется в JDK 24 и JDK 25. На JDK 25 синтаксиса STR."..." в языке просто нет.

Проверить это несложно: javac --release 25 на файле с STR."Hello \{name}" даёт не «preview-фича требует флага», а ошибку парсера:

StCheck.java:4: error: illegal escape character
        String s = STR."Hello \{name}";
                               ^
1 error

Важная деталь этой ошибки: компилятор 25 вообще не распознаёт STR."..." как шаблонный процессор — он парсит это как обычный строковый литерал и спотыкается на \{ внутри него как на невалидном escape-символе. Никакого частичного или деградировавшего разбора отменённого синтаксиса нет — на 25 это просто некорректная Java-строка.

Рабочие альтернативы — то, что реально стоит писать вместо STR."...", и все три реально скомпилированы и прогнаны на JDK 25 в StringTemplatesStatus:

String name = "Хорост";
int articlesCount = 42;

// Альтернатива 1: String.format — самый близкий по духу аналог %-плейсхолдеров.
String viaFormat = String.format("Привет, %s! Статей опубликовано: %d.", name, articlesCount);

// Альтернатива 2: String.formatted — тот же формат, но вызов "от строки"
// (появился в JDK 15, финализирован задолго до 25).
String viaFormatted = "Привет, %s! Статей опубликовано: %d.".formatted(name, articlesCount);

// Альтернатива 3: обычная конкатенация — без форматирования чисел/паддинга,
// но проще всего для коротких строк.
String viaConcat = "Привет, " + name + "! Статей опубликовано: " + articlesCount + ".";

// Text blocks (JEP 378, finalized в JDK 15 — НЕ связаны со string templates
// напрямую, но их часто путают) по-прежнему работают на 25 и комбинируются
// с formatted() для многострочных шаблонов:
String multiline = """
        Автор: %s
        Статей: %d
        """.formatted(name, articlesCount);

Все три альтернативы дают тот же текст, что задумывался для STR-процессора — String.format ближе всего по духу к %-плейсхолдерам, formatted() удобен, когда шаблон уже есть в виде строки (в том числе text block), конкатенация — самый простой вариант без форматирования чисел и паддинга.

Что ещё в пути на JDK 25

Все фичи выше — finalized. Но на JDK 25 есть и настоящий preview-синтаксис — просто он не входит в стенд этой статьи, потому что preview-код требует --enable-preview и по определению может измениться до финализации. Из того, что реально в процессе на JDK 25:

  • JEP 507, Primitive Types in Patterns, instanceof, and switch — третий preview на JDK 25: примитивные типы (int, double и т.д.) как patterns в instanceof и switch, без обёрточных типов.
  • JEP 505, Structured Concurrency — пятый preview на JDK 25: API для запуска и координации групп связанных задач как единого юнита, естественное продолжение virtual threads.

Оба ещё не финализированы, и число preview-итераций (3-й и 5-й соответственно) само по себе показывает, что дизайн API у этих JEP ещё не устоялся — ставить их в продакшн-код рано.

Итог

Образ Java как «verbose-языка из прошлого» держится на памяти о Java 8. Реальность JDK 25 другая: records убирают boilerplate DTO, sealed-иерархии с exhaustive switch превращают незакрытые ветки из runtime-сюрприза в ошибку компиляции, pattern matching и record patterns сворачивают каскады instanceof в одно выражение, unnamed variables _ убирают шум из кода. Все эти фичи — finalized (диапазон JDK 16–22), не требуют --enable-preview и доступны в любом продакшн-коде на 25 прямо сейчас. Единственное важное исключение — string templates: их удобно было бы иметь, но они сняты и синтаксиса STR."..." на 25 нет, поэтому для форматирования остаются String.format, formatted(), конкатенация и text blocks. А то, что ещё в пути (JEP 507/505), стоит держать в поле зрения, но не в продакшене. Соседний взгляд на ту же JVM с другим набором решений — в статье про Kotlin для backend.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии