Образ 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) — не переписаны по памяти.
В статье
- Records: данные без boilerplate
- Sealed-иерархии и exhaustive switch
- Pattern matching: instanceof, switch, guarded when
- Unnamed variables:
_ - Virtual threads: синтаксис
- String templates: удалены, а не «пока preview»
- Что ещё в пути на JDK 25
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
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 обычными классами (Shape → Circle, 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() в main — false. Синтаксически разница между 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.
Документация и первоисточники
- JEP 395: Records
- JEP 409: Sealed Classes
- JEP 440: Record Patterns
- JEP 441: Pattern Matching for switch
- JEP 456: Unnamed Variables & Patterns
- JEP 444: Virtual Threads
- JEP 430: String Templates (Preview)
- JEP 507: Primitive Types in Patterns, instanceof, and switch (Preview)
- JEP 505: Structured Concurrency (Fifth Preview)
- Kotlin для backend — соседняя статья серии про язык с другим набором компромиссов на той же JVM
- Конкурентность на JVM: virtual threads, корутины и reactiveготовится, с 30 июля — детали и бенчмарк virtual threads
- Java для backend и контейнеров — вводная статья серии
Комментарии