Java 21 — версия с долгосрочной поддержкой, она вышла в сентябре 2023 года. Переход во многих проектах закончился сменой цифры в файле сборки: код внутри остался тем же, что был на Java 11. Цепочки instanceof с кастами, пулы потоков на двести задач, ручной обход LinkedHashSet ради последнего элемента. Всё компилируется и работает — просто читается хуже, чем могло бы, и такие вещи регулярно всплывают на технических собеседованиях.
Ниже пять возможностей, финализированных в Java 21, без флага preview. Их можно начинать применять хоть в понедельник.
1. Виртуальные потоки: масштаб вместо скорости
Обычный Thread — обёртка над потоком операционной системы. Создавать их дорого, и их не так много, поэтому годами все жили с ограниченными пулами и асинхронным кодом.
JEP 444 финализирует виртуальные потоки. Это по-прежнему экземпляры java.lang.Thread, но JVM планирует их поверх нескольких потоков ОС. Когда виртуальный поток блокируется на операции ввода-вывода из JDK, он размонтируется и освобождает носитель под другую задачу.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(task); }
Главное, что понимают не все: это не «быстрые потоки». Растёт пропускная способность там, где тысячи задач большую часть времени ждут — HTTP-запросы, обращения к базе. На нагрузке, упирающейся в процессор, выигрыша не будет вообще.
Подводный камень именно в Java 21: внутри блока synchronized виртуальный поток пиннится и держит носитель. В Java 24 это исправили через JEP 491, а на 21-й стоит пройтись по synchronized, которые оборачивают сетевые вызовы, и заменить их на ReentrantLock.
2. Паттерны в switch
Раньше выбор действия по типу объекта — это лестница if / else if с instanceof. JEP 441 финализирует паттерны в switch после четырёх раундов preview, начиная с Java 17.
static String formatter(Object obj) { return switch (obj) { case Integer i -> String.format("int %d", i); case Long l -> String.format("long %d", l); case String s -> String.format("String %s", s); default -> obj.toString(); }; }
Селектором может быть любой ссылочный тип, каждый case принимает типовой паттерн, как в instanceof. Побеждает первый совпавший case в порядке написания.
Ключевой момент: сравнение идёт по фактическому типу объекта. Значение роли не играет — Object, в котором лежит Long, не подойдёт под case Integer i, даже если число помещается в int. Зато компилятор сообщит, если какой-то case недостижим, потому что его уже перекрыл предыдущий.
3. case null и охранные условия when
Switch с null-селектором раньше бросал NullPointerException, поэтому проверка жила снаружи, в if. Теперь её можно занести внутрь через case null, а к каждому case добавить условие через when:
switch (s) { case null -> System.out.println("Oops!"); case String v when v.equalsIgnoreCase("YES") -> ...; case String v -> ...; }
Guard срабатывает, только если паттерн совпал и условие истинно. Если нет — switch продолжает перебирать следующие case. Порядок важен: охраняемый case ставится раньше общего, иначе общий его перекроет, и компилятор сообщит об ошибке.
И ещё одно: guard бывает только у паттернов. case "hello" when ... не скомпилируется — константа паттерном не является.
4. Паттерны записей
Pattern matching в instanceof избавляет от каста, но вызовы p.x() и p.y() остаются. JEP 440 финализирует record patterns, которые достают компоненты прямо в паттерне.
record Point(int x, int y) {} ... if (obj instanceof Point(int x, int y)) { System.out.println(x + y); }
Паттерн проверяет тип и, если совпало, вызывает аксессоры записи. Имена переменных не обязаны совпадать с именами компонентов, в каждой позиции допустим var, работает всё одинаково и в instanceof, и в метках case. Паттерны вкладываются друг в друга: совпасть должно сразу всё, и если часть не совпала — исключения не будет, ветка просто не выполнится.
В связке со switch по sealed-иерархии каждая форма данных разбирается одной строкой, без кастов, а компилятор проверяет, что ни один случай не пропущен.
5. Sequenced-коллекции
Достать первый и последний элемент раньше зависело от типа: list.get(0) и list.get(list.size() - 1) у списка, deque.getFirst() у Deque, sortedSet.first() у SortedSet. А у LinkedHashSet последний элемент приходилось искать полным обходом.
JEP 431 добавляет SequencedCollection, SequencedSet и SequencedMap — общий контракт для коллекций с определённым порядком, встроенный в существующую иерархию.
String first = list.getFirst(); String last = list.getLast(); String lastInSet = linkedHashSet.getLast();
Кроме getFirst и getLast есть addFirst, addLast, removeFirst, removeLast и reversed(). С последним осторожно: он не копирует коллекцию, а отдаёт представление в обратном порядке. И на пустой коллекции getFirst() с getLast() бросают NoSuchElementException — ровно как это всегда делал Deque.
Шпаргалка
| Возможность | JEP | Что убирает из кода | Где не поможет |
|---|---|---|---|
| Виртуальные потоки | 444 | ограниченные пулы, асинхронные обёртки | задачи, упирающиеся в процессор |
| Паттерны в switch | 441 | цепочки instanceof и явные касты | когда логика зависит не от типа |
| case null и when | 441 | проверку на null перед switch | у константных case — guard туда не поставить |
| Паттерны записей | 440 | касты и вызовы аксессоров | объекты обычных классов, не record |
| Sequenced-коллекции | 431 | get(0), get(size()-1), полный обход | коллекции без порядка, например HashSet |
Что ломается на практике
- Ждут ускорения от виртуальных потоков на CPU-bound нагрузке и получают те же цифры, что были.
- Оставляют synchronized вокруг сетевых вызовов, а потом удивляются, почему носители всё равно упираются в потолок.
- Ставят общий case раньше охраняемого и ловят ошибку компиляции.
- Рассчитывают, что case Integer i поймает Long с подходящим значением. Не поймает: сравнивается тип.
- Думают, что reversed() создаёт новую коллекцию, и меняют исходный список, ожидая независимости.
Проверочная задача на null
Есть метод, который разбирает объект через switch с default, но без case null:
static String describe(Object o) { return switch (o) { case String s -> "text"; case Integer i -> "number"; default -> "something else"; }; }
Вызов describe(null) не вернёт "something else" и не отдаст "number". Он бросит NullPointerException: default null не ловит, до него дело не доходит. Чтобы обработать null, нужен явный case null — или комбинированный case null, default, если уместно свалить его в общую ветку. Код при этом компилируется без единого предупреждения, поэтому такие места стоит искать глазами.
С чего начать
Порядок внедрения зависит от риска, а не от красоты синтаксиса. Sequenced-коллекции и паттерны записей почти не меняют поведение кода — это локальные правки, их можно раскатывать по файлу за файлом. Дальше switch с паттернами и case null: тут уже появляется риск поймать null в новом месте, зато исчезают лестницы из instanceof. Виртуальные потоки — в последнюю очередь, потому что перед ними нужно пройтись по synchronized вокруг всего, что ходит в сеть или в базу.
И отдельно про интервью: вопросы про пиннинг виртуальных потоков, про то, что case Integer i не ловит Long, и про то, копирует ли reversed() коллекцию, встречаются регулярно. Ответы на них — ровно те пять пунктов выше.
Комментарии (0)
Войдите, чтобы комментировать.
Пока нет комментариев. Будьте первым.