Оглавление
- Введение
- POJO vs Record: сокращение шаблонного кода
- instanceof + Cast vs Pattern Matching для типов
- Условная логика: традиционный switch vs выражения switch
- Сопоставление в
switch
: замена цепочек if/instanceof - Ограниченное наследование с помощью sealed-классов
- S
witch
по sealed-иерархии (Pattern Matching + Sealed) - Record Patterns
- Локальная типизация:
var
вместо объявлений типов - Многострочные текстовые блоки (Text Blocks)
- Фабричные методы для коллекций (List.of, Set.of, Map.of)
- Улучшения Stream API:
takeWhile
,dropWhile
,ofNullable
и др. - Более выразительная работа с Optional
- Виртуальные потоки (Project Loom): тысячи потоков без боли
- Scoped Values: безопасный контекст вместо ThreadLocal
- Заключение
Введение
Многие Java-разработчики продолжают писать код в стиле Java 8, даже когда Java 21 предлагает богатый набор новых возможностей для упрощения разработки. По состоянию на конец 2023 года около 40% проектов все еще работали на Java 8, хотя к началу 2025 эта доля снизилась до ~23%. Это значит, что значительная часть кода не пользуется улучшениями последних релизов Java. В этой статье мы рассмотрим 14 практических примеров, демонстрируя решения на Java 8 (устаревший подход) и на Java 21 (с использованием современных функций языка). Вы увидите, как новые возможности – Record-классы, pattern matching (сопоставление с шаблонами), sealed-классы, улучшенный switch
, расширенный Stream API, Scoped Values, виртуальные потоки (virtual threads) и др. – делают код более лаконичным, безопасным и поддерживаемым.
В статье «Используете ли вы Java 21 или просто обновили JDK?» я уже затрагивал новые возможности языка, но тогда не хватало практических сравнений, демонстрирующих их преимущества. В этой публикации я компенсировал этот недостаток с помощью конкретных примеров.
POJO vs Record: сокращение шаблонного кода
Одно из первых нововведений – Record-классы, появившиеся в Java 16. Они предназначены для хранения данных и автоматически генерируют конструктор, геттеры, equals()
, hashCode()
и toString()
на основе полей. В Java 8 для таких целей приходилось писать шаблонный код вручную.
Java 8: Для класса данных (POJO) нужно объявлять поля, конструкторы, методы доступа и переопределять методы equals/hashCode
. Это лишний бойлерплейт, усложняющий поддержку. Например, создадим класс Person
с двумя полями:
// Java 8: класс с полями, конструктором и методами
public class Person {
private String name;
private int age;
public Person(String name, int age) { // конструктор
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
@Override
public boolean equals(Object o) { /* ...ручная реализация... */ }
@Override
public int hashCode() { /* ...ручная реализация... */ }
@Override
public String toString() { /* ...ручная реализация... */ }
}
Java 21: То же самое можно определить одной строкой с помощью record
. Record-класс неизменяемый по умолчанию, а необходимые методы генерируются автоматически:
// Java 21: Record-класс автоматически реализует конструктор и методы
public record Person(String name, int age) { }
В результате мы избавляемся от шаблонного кода и снижаем риск ошибок. Компилятор сам обеспечит правильную реализацию методов. Records упрощают создание классов данных, устраняя бойлерплейт и повышая читаемость. Код становится короче и прозрачнее: разработчик описывает что хранится, а не как. Также record-классы поощряют иммутабельность объектов, что улучшает безопасность и понимание кода.
Преимущества подхода Java 21:
- Меньше кода: все стандартные методы создаются автоматически.
- Лучшая читаемость: структура данных описывается лаконично.
- Иммутабельность по умолчанию: поля record являются
final
, что упрощает поддержку и отладку состояния объекта.
instanceof + Cast vs Pattern Matching для типов
Частая задача – проверить тип объекта и затем использовать его. В Java 8 это делалось через instanceof
с явным приведением типа. В Java 16+ появился pattern matching для instanceof
, который объединяет эти шаги.
Java 8: При проверке типа приходилось сначала убедиться, что объект нужного класса, затем делать кастинг:
Object obj = getValue();
if (obj instanceof String) {
String s = (String) obj; // явное приведение типа
System.out.println("Длина: " + s.length());
} else if (obj instanceof Integer) {
Integer num = (Integer) obj;
System.out.println("Квадрат: " + (num * num));
}
Здесь код громоздкий: мы дважды упоминаем тип (String
/Integer
), существует риск ClassCastException при неправильном приведении.
Java 21: Благодаря pattern matching, переменная нужного типа автоматически привязывается при проверке через instanceof
. Нет необходимости явно кастовать:
Object obj = getValue();
if (obj instanceof String s) {
System.out.println("Длина: " + s.length());
} else if (obj instanceof Integer num) {
System.out.println("Квадрат: " + (num * num));
}
Компилятор сам вставляет приведение, и переменные s
и num
доступны только внутри соответствующих блоков. Такой код короче и безопаснее – ошибка приведения невозможна. Pattern matching для instanceof
позволяет избежать явных кастов, сочетая проверку типа и извлечение значения в одном шаге.
Почему это лучше:
- Исключается шаблонный код приведения типов (меньше повторений типов в коде).
- Повышается безопасность: компилятор гарантирует, что внутри блока переменная уже нужного типа, и вы не забудете проверку перед использованием.
- Код чище и логичнее выглядит, проще читать и поддерживать.
Условная логика: традиционный switch vs выражения switch
Оператор switch
в Java претерпел большие изменения. В Java 8 он мог работать только с ограниченным набором типов (примитивы, String
, enum) и требовал явного break
. В современных версиях (начиная с Java 14) появился switch-выражение с более лаконичным синтаксисом и поддержкой возврата значения.
Java 8: Классический switch
– многословный и подвержен ошибкам fall-through (протекания). Например, определим простое преобразование строки ответа в булево значение:
String answer = "yes";
boolean isYes;
switch (answer.toLowerCase()) {
case "yes":
case "y":
isYes = true;
break;
case "no":
case "n":
isYes = false;
break;
default:
throw new IllegalArgumentException("Неверный ответ");
}
System.out.println(isYes ? "Принято" : "Отклонено");
Обратите внимание: нужно не забыть каждое break
, иначе выполнение «провалится» в следующий кейс. Переменная isYes
должна быть объявлена заранее.
Java 21: Современный switch
может возвращать значение прямо, используя стрелочный синтаксис ->
(или ключевое слово yield
для многострочных блоков). Fall-through отключен по умолчанию, что предотвращает целый класс ошибок:
String answer = "yes";
boolean isYes = switch (answer.toLowerCase()) {
case "yes", "y" -> true;
case "no", "n" -> false;
default -> throw new IllegalArgumentException("Неверный ответ");
};
System.out.println(isYes ? "Принято" : "Отклонено");
Здесь switch
возвращает значение, которое мы присваиваем переменной isYes
. Нет необходимости в break
– каждая стрелочная ветка завершает выражение автоматически. Мы также можем перечислять несколько меток через запятую (например, "yes", "y"
), избегая дублирования кода. Такой код более декларативен и менее шумный.
Преимущества нового switch:
- Лаконичность: меньше шаблонного кода (
break
, присвоения переменных). - Безопасность: невозможны случайные падения в следующий case; компилятор требует покрыть все варианты (либо указать
default
). - Выражение: можно сразу получить результат, упрощая использование в методах и цепочках логики.
Сопоставление в switch
: замена цепочек if/instanceof
Еще более мощное расширение – pattern matching в switch
(финализировано в Java 21). Он позволяет switch
напрямую проверять типы и даже распаковывать данные, что было недоступно в Java 8.
Java 8: Когда нужно выполнить различные действия в зависимости от типа объекта, обычно писали цепочку if/else
с instanceof
. Например, для объекта Object
выведем информацию, если это String
или Integer
:
Object obj = getValue();
if (obj == null) {
System.out.println("Null!");
} else if (obj instanceof Integer) {
Integer num = (Integer) obj;
System.out.println("int " + num);
} else if (obj instanceof String) {
String s = (String) obj;
System.out.println("String " + s);
} else {
System.out.println("Unknown!");
}
Этот код работает, но с ростом числа типов становится громоздким.
Java 21: Теперь можно передавать в switch
сам объект, а в case
указывать шаблоны типов. Компилятор проверит последовательность case
и выполнит первую подходящую:
Object obj = getValue();
String result = switch (obj) {
case null -> "Null!";
case Integer i -> "int " + i;
case String s -> "String " + s;
default -> "Unknown!";
};
System.out.println(result);
Здесь мы комбинируем pattern matching с лаконичным синтаксисом switch
-выражения. Каждая ветка case
одновременно проверяет тип и извлекает значение в новую переменную (i
, s
). Код получился гораздо короче и понятнее.
Важно, что если obj
равен null
, сработает явно указанный case null
(в старом switch null бы вызвал NPE). Pattern matching в switch
делает код более удобочитаемым и декларативным – по сути, мы описываем шаблоны, которым должен соответствовать объект, как в функциональных языках (Scala, Kotlin).
Кроме того, если набор типов ограничен (см. sealed-классы далее), компилятор проверит исчерпываемость switch
: все возможные варианты покрыты, и default
не нужен. Это повышает надежность за счет проверки на этапе компиляции.
Ограниченное наследование с помощью sealed-классов
Java 8 не позволяла явно ограничить набор подклассов, что иногда осложняло дизайн API. Sealed-классы и интерфейсы (стабильны с Java 17) вводят контроль над наследованием. Класс или интерфейс объявляется как sealed
и явно перечисляет, кем может быть расширен (через ключевое слово permits
).
Java 8: Предположим, у нас есть интерфейс Shape
и несколько реализаций (Circle, Rectangle, Triangle). В Java 8 нельзя предотвратить создание новых реализаций Shape
за пределами нашего кода. Любой мог имплементировать интерфейс, даже если логически других вариантов быть не должно. Это мешает полагаться на исчерпывающий перечисление типов в алгоритмах.
// Java 8
interface Shape { /* ... */ }
class Circle implements Shape { /* ... */ }
class Rectangle implements Shape { /* ... */ }
// (теоретически кто угодно может добавить new class Triangle implements Shape)
Java 21: Теперь мы можем явно закрыть иерархию. Достаточно объявить интерфейс или класс как sealed
и указать разрешенные подклассы. Например, ограничим Shape
тремя вариантами:
sealed interface Shape permits Circle, Rectangle, Triangle { }
final class Circle implements Shape { /* ... */ }
final class Rectangle implements Shape { /* ... */ }
final class Triangle implements Shape { /* ... */ }
Здесь Shape
sealed и может иметь реализации только Circle
, Rectangle
, Triangle
. Каждый из них объявлен final
(т.е. больше не наследуется) – это требование для потомков sealed-типа, если они не решили сами продолжить иерархию (sealed
или non-sealed
). Теперь компилятор знает все варианты Shape.
Преимущества sealed-подхода:
- Контроль API: Вы как автор класса контролируете, какие расширения допускаются, сохраняя целостность абстракции. Внешний код не сможет реализовать Shape несанкционированно.
- Предсказуемость: Код, работающий с
Shape
, может рассчитывать только на перечисленные варианты. Исключается неопределенность открытой иерархии, что улучшает анализ кода и тестирование. - Полная проверка случаев: в сочетании с pattern matching, компилятор может проверить, что вы обработали все подклассы. Например,
switch
по sealed-интерфейсу безdefault
будет компилироваться, только если покрыты все дозволенные варианты.
Таким образом, sealed-классы помогают явно выражать доменную модель, ограничивая расширения там, где это логически оправдано. Это повышает безопасность и качество архитектуры (следование принципу SOLID Open-Closed: интерфейс открыт для известных расширений, но закрыт для прочих).
Switch
по sealed-иерархии (Pattern Matching + Sealed)
Объединим сразу несколько возможностей: sealed-классы, record-классы и pattern matching. Часто на собеседованиях просят реализовать, например, вычисление площади фигур или обработку узлов AST (abstract syntax tree). В Java 8 для этого мог использоваться шаблон Visitor или instanceof-цепочки. В Java 21 все гораздо проще.
Представим, у нас есть sealed-интерфейс Shape
с тремя реализациями – возьмем для примера record-классы (чтобы сразу показать их удобство):
sealed interface Shape permits Circle, Rectangle, Triangle { }
public record Circle(double radius) implements Shape { }
public record Rectangle(double width, double height) implements Shape { }
public record Triangle(double base, double height) implements Shape { }
Задача: написать функцию area(Shape shape)
, возвращающую площадь фигуры.
Java 8: Пришлось бы либо добавить метод area()
в интерфейс Shape и реализовать в подклассах, либо написать внешний if/else instanceof
. Например, вариант с внешней обработкой:
double area(Shape shape) {
if (shape instanceof Circle) {
Circle c = (Circle) shape;
return Math.PI * c.getRadius() * c.getRadius();
} else if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return r.getWidth() * r.getHeight();
} else if (shape instanceof Triangle) {
Triangle t = (Triangle) shape;
return 0.5 * t.getBase() * t.getHeight();
} else {
throw new IllegalStateException("Unknown Shape");
}
}
Это довольно многословно, легко ошибиться при приведении типов или забыть обработать новый вид фигуры (особенно если Shape
не ограничен sealed-механизмом).
Java 21: Используя pattern matching в switch
, код становится выразительным и исчерпывающим. Компилятор знает, что других наследников Shape
нет, поэтому мы можем не писать default
– он проверит, что все варианты перечислены:
double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
};
}
Каждый case
сразу раскладывает объект нужного класса в переменную (c
, r
, t
). Мы вызываем методы-аксессоры record-классов (radius()
, width()
и т.д.) для вычисления площади. Ключевое преимущество – если завтра добавится новый подкласс Shape
, компилятор выдаст ошибку, что switch
не покрывает все варианты. Это заставит обновить код обработки, исключая скрытые баги.
Таким образом, комбинация sealed + pattern matching дает в Java то, что в функциональных языках называют алгебраическими типами данных и сопоставлением с образцом: мощный способ выразить логику для различных вариантов объекта без лишнего шаблонного кода и с гарантией проверки полноты условий на этапе компиляции.
Record Patterns
Java 21 финализировала Record Patterns – возможность распаковки компонентов record-классов прямо в условии instanceof
или switch
. Это далее снижает шаблонный код при работе со сложными данными.
Java 8: Если объект имеет внутреннюю структуру (например, Point с координатами x,y), для извлечения полей после проверки типа нужно писать несколько строк. Пример: функция, печатающая сумму координат, если объект – точка:
static void printSum(Object obj) {
if (obj instanceof Point) {
Point p = (Point) obj;
int x = p.getX();
int y = p.getY();
System.out.println(x + y);
}
}
Тут мы трижды упоминаем p
и каждый компонент извлекаем вручную.
Java 21: С record-шаблонами можно сделать это в одной строке, без явных геттеров! Шаблон Point(int x, int y)
одновременно проверяет, что obj
– экземпляр Point, и распаковывает его поля в переменные x
и y
:
static void printSum(Object obj) {
if (obj instanceof Point(int x, int y)) {
System.out.println(x + y);
}
}
Java сама извлекла x
и y
через методы доступа record-класса. Мы сразу получили примитивные int
переменные, готовые к использованию. Это не только сокращает код, но и устраняет возможные опечатки (например, использование разных объектов для x и y по ошибке). Код выглядит как шаблон данных, который должен соответствовать объект: очень наглядно.
Record-шаблоны работают и внутри switch
. Например:
return switch(shape) {
case Point(int x, int y) -> String.format("Point(%d,%d)", x, y);
// ... другие кейсы ...
};
Такая декларативность упрощает написание алгоритмов разбора структур данных (например, разбор JSON, деревьев) без громоздких посетителей. Как отметил один обзор, record patterns позволяют избавиться от «мусорного кода» распаковки полей через instanceof, заменив его компактным и надежным синтаксисом.
Важно: на момент Java 21 record-шаблоны можно использовать в instanceof
и switch
. Они не поддерживают изменяемые классы – только record
(неизменяемые по дизайну) или набор встроенных типов. Это часть движения Java к более функциональному стилю, где код описывает структуру данных, которую мы ожидаем, а не явные пошаговые действия.
Локальная типизация: var
вместо объявлений типов
Java 10 представила ключевое слово var
для вывода типа локальной переменной. Многие опытные разработчики из привычки продолжают явно указывать тип, как в Java 8, но использование var
может упростить код.
Java 8: Тип переменной всегда явно прописывается. В простых случаях это очевидно из инициализации, но приходится повторяться:
List<String> names = new ArrayList<String>();
Map<String, Integer> nameToAge = new HashMap<String, Integer>();
Благодаря diamond-оператору (<>
) в Java 7 повторение типа справа можно опустить, но слева все равно дублируется List<String>
vs new ArrayList<>
. В более сложных случаях с обобщениями объявления становятся длинными.
Java 21 (начиная с Java 10): Используем var
– компилятор сам выведет тип из правой части:
var names = new ArrayList<String>();
var nameToAge = new HashMap<String, Integer>();
Переменная names
автоматически получит тип ArrayList<String>
(который можно присвоить List<String>
при необходимости). В примере выше экономия символов небольшая, но var
особенно полезен при длинных типах:
Map<String, List<User>> usersByCity = getUsersGrouped();
можно записать как:
var usersByCity = getUsersGrouped(); // тип выводится как Map<String, List<User>>
Это улучшает читабельность, убирая «визуальный шум». Главное – тип по-прежнему статически определен, просто выводится компилятором.
Когда var
полезен:
- Сложные типы: меньше бо?льших generic-выражений в коде, упрощается объявление стримов, коллекций и т.п.
- Инициализация очевидна: если справа явно вызывается конструктор или метод с понятным возвращаемым типом,
var
делает код короче без потери понимания. - Итерации: в циклах
for
по коллекцииfor (var user : users) {...}
избавляет от длинного типа элемента.
Конечно, злоупотреблять var
не стоит в ситуациях, где тип неочевиден – баланс важен для читаемости. Но в большинстве случаев var
помогает сфокусироваться на логике, а не на шаблонных частях объявления переменных.
Многострочные текстовые блоки (Text Blocks)
Форматирование многострочных строк в Java 8 было неудобным – приходилось вручную вставлять \n
или конкатенировать строки. В Java 15 появились Text Blocks (текстовые блоки), позволяющие записывать многострочный текст в коде почти без экранирования.
Java 8: Например, нужно представить JSON или SQL-запрос в виде строки. Приходилось делать так:
String json = "{\n" +
" \"name\": \"Alice\",\n" +
" \"age\": 30\n" +
"}";
Или вызывать методы вроде StringBuilder
и append
. В обоих случаях код трудно читать: смешиваются данные и технические символы перевода строки, требуется экранировать кавычки внутри текста.
Java 21: C текстовыми блоками можно оформить строковый литерал между тройными кавычками """
. Всё, что напишем внутри, идет в строку как есть (за исключением управляющей последовательности для самой """
при необходимости). Пример выше на text block:
String json = """
{
"name": "Alice",
"age": 30
}
""";
Строка воспринимается в коде точно так, как выглядит, включая переносы и отступы. Это существенно повышает читаемость многострочного текста и облегчает поддержку (легче сверять с исходником, например, сравнить с реальным JSON).
Как отмечается в обзорах, с Java 15 мы наконец можем создавать многострочные литералы без \n
, StringBuilder
и прочих ухищрений. Текстовые блоки автоматически управляют отступами – общий отступ в начале строк отсекается, чтобы строка не содержала лишних пробелов слева.
Преимущества text blocks:
- Код, представляющий разметку (JSON, XML, HTML, SQL), выглядит практически так же, как итоговый текст, что упрощает восприятие.
- Меньше экранирования: кавычки, новые строки не нужно предварять слэшами, вероятность ошибок снижается.
- Поддержка встроенных выражений: (в Java 21 появились String Templates как превью, но даже без них text block можно конкатенировать с переменными, и это уже гораздо чище, чем в Java 8).
В результате, разработчики могут комфортнее работать с шаблонами сообщений, кодом на других языках внутри строк и т.д., не тратя времени на борьбу с синтаксисом языка.
Фабричные методы для коллекций (List.of, Set.of, Map.of)
Нередко нужно быстро создать небольшую коллекцию из известных элементов (например, список констант). В Java 8 приходилось делать это вручную, тогда как Java 9 представила удобные статические методы List.of()
, Set.of()
, Map.of()
для этих целей.
Java 8: Создание неизменяемого списка или карты занимало несколько строк:
List<String> roles = new ArrayList<>();
roles.add("USER");
roles.add("ADMIN");
roles = Collections.unmodifiableList(roles);
Map<Integer, String> idToName = new HashMap<>();
idToName.put(1, "Alice");
idToName.put(2, "Bob");
idToName = Collections.unmodifiableMap(idToName);
Даже без неизменяемости, заполнение коллекции «на лету» требовало вызвать add/put
для каждого элемента. Код разрастается, особенно в статически инициализированных структурах.
Java 21 (начиная с Java 9): Теперь можно создать и заполнить коллекцию в одной строке. Например:
List<String> roles = List.of("USER", "ADMIN");
Map<Integer, String> idToName = Map.of(1, "Alice", 2, "Bob");
Методы of
возвращают неизменяемые коллекции, что обычно предпочтительно для константных наборов данных. Если нужна изменяемая – можно завернуть в new ArrayList<>(List.of(...))
. Но во многих случаях именно неизменяемость предотвращает случайные изменения и повышает безопасность.
Выигрыши от фабричных методов:
- Скорость разработки: за один вызов получаем готовую коллекцию. Код намного короче и нагляднее.
- Иммутабельность по умолчанию:
List.of
и др. дают неизменяемые объекты. В Java 8 приходилось отдельно оборачивать черезCollections.unmodifiableList
(как выше). Теперь из коробки выходят коллекции, которые нельзя модифицировать (при попытке бросается исключение), что явным образом сигнализирует о константности набора. - Читаемость: сразу видно, какие элементы входят в коллекцию. Особенно полезно для Map, где метод
Map.of(k,v, k2,v2, ...)
четко показывает пары ключ-значение.
Кроме того, эти методы оптимизированы для небольшого количества элементов и возвращают специальные компактные реализации. В итоге, написание тестовых данных, параметров, константных словарей стало значительно проще и элегантнее.
Улучшения Stream API: takeWhile
, dropWhile
, ofNullable
и др.
Java 8 принесла Streams – удобный API для функциональной обработки коллекций. В последующих версиях его еще усилили. В Java 9 добавлены методы takeWhile
, dropWhile
, упрощен iterate
, а также методы для работы с Optional
(например, Optional.stream()
). Рассмотрим, как новые методы облегчают типичные задачи.
Java 8: Допустим, у нас есть отсортированный список чисел, и мы хотим получить все положительные элементы до первого отрицательного. В Java 8 без takeWhile
приходилось вручную прерывать стрим или использовать цикл:
List<Integer> numbers = List.of(3, 7, 2, -4, 5, 1);
List<Integer> positives = new ArrayList<>();
for (int n : numbers) {
if (n < 0) break;
positives.add(n);
}
// positives: [3, 7, 2]
Стримами это тоже не тривиально: приходилось либо самому отслеживать состояние через флаг внутри .filter()
, либо комбинировать индексы.
Java 21 (начиная с Java 9): Метод takeWhile
делает то же самое декларативно:
List<Integer> numbers = List.of(3, 7, 2, -4, 5, 1);
List<Integer> positives = numbers.stream()
.takeWhile(n -> n >= 0)
.toList(); // (метод toList() появился в Java 16)
Эта конструкция прочитывается сразу: «брать элементы, пока n >= 0
». Как только встретится первый элемент, не удовлетворяющий предикату (в нашем примере -4
), стрим завершится. Результат – [3, 7, 2]
. Аналогично, метод dropWhile
пропускает начальную часть элементов, пока предикат истинен, и затем берет остаток. Эти методы особенно полезны на отсортированных или упорядоченных данных, где известно условие разделения.
Другие новинки Stream API:
Stream.iterate
получил перегрузку с условием завершения:Stream.iterate(seed, predicate, next)
. Раньше приходилось генерировать бесконечный стрим и обрезать методом.limit(...)
. Теперь можно, например,Stream.iterate(1, n -> n < 100, n -> n * 2)
– это последовательность1, 2, 4, 8, ...
пока меньше 100, без лишних операций.Stream.ofNullable(value)
– вернет пустой стрим, еслиvalue
равен null, или стрим из одного элемента, если не null. Удобно, чтобы не делать отдельно проверку на null перед созданием стрима.- Метод
toList()
у стрима (Java 16) – теперь можно напрямую получитьList
без использования громоздкого Collectors:stream.toList()
эквивалентен.collect(Collectors.toList())
.
Кроме того, Optional получил метод stream()
, позволяющий преобразовать Optional в стрим из 0 или 1 элемента. Это упрощает встроение Optional в цепочки стримов. Также появились методы ifPresentOrElse
, or
и orElseThrow()
без аргументов (Java 10) для более выразительного управления отсутствием значения.
Все эти улучшения делают функциональный стиль в Java более гибким. Многие конструкции, для которых в Java 8 пришлось бы писать цикл или дополнительную логику, теперь выражаются готовым методом. Это приводит к более короткому и ясному коду. Важное преимущество – мы описываем намерение (например, «взять элементы пока условие истинно»), а не детали реализации, как в императивном коде.
Более выразительная работа с Optional
Класс Optional
появился в Java 8 для борьбы с NullPointerException
. Однако изначальный API был ограниченным: приходилось использовать isPresent()/get()
или несколько методов для простых вещей. В Java 9+ Optional обзавелся удобными методами, которые многие упускают из виду.
Java 8: Рассмотрим задачу: получить пользователя по ID, иначе создать нового. На Java 8 без специальных методов:
Optional<User> optUser = findUser(id);
User user;
if (optUser.isPresent()) {
user = optUser.get();
} else {
user = new User(id);
}
process(user);
Либо приходилось использовать комбинации orElse
/orElseGet
, но если еще нужно выполнить действие при отсутствии – приходилось писать if.
Java 21 (начиная с Java 9): Используем Optional.ifPresentOrElse
для запуска альтернативной логики:
findUser(id).ifPresentOrElse(
user -> process(user),
() -> {
User newUser = new User(id);
saveUser(newUser);
process(newUser);
}
);
Этот метод сразу воспринимается: «если значение есть – делаем одно, если пусто – другое». Нет необходимости явно вызывать get()
или писать два отдельных блока. Также, если нам просто нужен объект, можно сделать:
User user = findUser(id).orElseGet(() -> new User(id));
process(user);
Метод orElseGet
принимает лямбду, которая вызовется только при отсутствии значения (в отличие от orElse
, который всегда создает объект, даже если он не понадобится).
Другой пример – объединение Optional: метод Optional.or(...)
(Java 9) позволяет указать альтернативный источник Optional, если текущий пуст. Раньше для этого приходилось делать вложенные тернарные операторы или цепочки if.
Optional.stream(): как упоминалось, превращает Optional в стрим. Например, у нас Stream<Optional<User>>
и надо получить Stream<User>
без пустых элементов:
Stream<Optional<User>> optionalStream = Stream.of(opt1, opt2, opt3);
Stream<User> users = optionalStream.flatMap(Optional::stream);
На Java 8 для этого надо было фильтровать isPresent и делать map get.
Все эти усовершенствования делают работу с Optional более лаконичной и идиоматичной. В результате код, обрабатывающий возможное отсутствие значения, больше напоминает обычную бизнес-логику, без шумового шаблона. Разработчики, привыкшие к функциональным языкам, ценят такие конструкции, как метод ifPresentOrElse
– он явно разделяет два сценария, улучшая понимание кода.
Виртуальные потоки (Project Loom): тысячи потоков без боли
Одним из самых революционных изменений Java 21 стали виртуальные потоки. Это легковесные потоки, реализованные на уровне JVM, позволяющие создавать десятки тысяч параллельных задач без overhead обычных потоков. При этом модель разработки остается прежней – поток на запрос/задачу, что привычно и внятно для разработчиков.
Java 8: Классический Thread
связан напрямую с потоками ОС. Запуск тысячи потоков ОС обычно нецелесообразен: они потребляют много памяти (стек) и ресурсов планировщика. Поэтому приходилось использовать ограниченные пулы потоков (например, FixedThreadPool), чтобы не создавать слишком много. Это усложняло код (нужно управлять очередями задач) и приводило к рискам истощения потоков при блокирующих операциях. Например, сервер мог создать пул на 100 потоков и обслуживать соединения, но если входило больше запросов, они ждали в очереди, даже если большую часть времени потоки простаивали в ожидании I/O.
Java 21: Виртуальные потоки выглядят как Thread
, но планируются внутри JVM поверх малого числа реальных потоков-носителей. Это значит, мы можем запускать тысячи, десятки тысяч потоков параллельно без перегрузки ОС. Каждый виртуальный поток при блокировке (например, чтение из сокета или БД) освобождает поток-носитель для других задач. Код при этом остается императивным, последовательным – как будто у нас достаточно потоков на каждый запрос.
Например, создать Executor, который для каждой задачи порождает виртуальный поток:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Runnable task : tasks) {
executor.submit(task);
}
} // ExecutorService автоматически закрывается
В Java 8 аналогичный код требовал бы пула фиксированного размера (например, на число ядер). С виртуальными потоками мы просто запускаем все задачи – JVM сама распланирует их исполнять попеременно на ограниченном количестве ОС-потоков. Практически исчезает проблема блокирующих вызовов: можно писать понятный код с sleep()
, read()
и прочими блокирующими операциями, не опасаясь «заморозить» драгоценный поток – виртуальный поток при блокировке просто приостановится, уступив выполнение другим.
Преимущества Loom:
- Масштабируемость: можно эффективно обслуживать огромное число параллельных операций (например, соединений) простым способом. Web-приложения на виртуальных потоках способны держать на порядок больше коннекций без сложностей с обратными вызовами или реактивностью.
- Простота кода: модель «один запрос = один поток» возвращается. Не нужны сложные асинхронные конструкции, колбеки или Reactive Streams, если задачи естественно блочатся. Код линейный, легко пишется и отлаживается (стек трейсы читабельны).
- Производительность: за счет более полного использования ресурсов. В типичном веб-приложении потоки часто ждут ответа БД. Виртуальные потоки позволяют другим задачам использовать это время, что потенциально может увеличить пропускную способность приложения в разы без добавления железа.
Важный момент – переход на виртуальные потоки часто не требует изменения бизнес-логики. Как отмечено в обзоре, все основные фреймворки (например, Spring) уже поддерживают Loom, и ускорение может произойти «магически», стоит только переключиться на Java 21. Для существующего кода достаточно запустить его на новой версии и, например, настроить пул в сервере приложений на использование виртуальных потоков. Java 21 дала возможность писать высокопроизводительный конкурентный код, оставаясь при этом в привычной парадигме thread-per-request.
Scoped Values: безопасный контекст вместо ThreadLocal
Когда речь заходит о передаче контекста (например, текущий пользователь, ID сессии) в глубину потока выполнения, в Java 8 для этого часто использовали ThreadLocal
. Однако у ThreadLocal есть недостатки: глобальность, возможность утечек памяти, сложность с наследованием значения при переключении потоков (например, в пулах). Scoped Values (JEP 446) в Java 21 (пока превью) предлагают современную альтернативу для локального, безопасного хранения данных потока.
Java 8: ThreadLocal
позволяет привязать некоторое значение к потоку. Например, мы хотим, чтобы в логах или глубоко в коде был доступен текущий userId, не передавая его через все методы. Решение:
// Глобальная переменная ThreadLocal
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
void processRequest(String userId) {
try {
USER_ID.set(userId);
// ... выполнить операции, методы внутри могут вызвать USER_ID.get()
} finally {
USER_ID.remove();
}
}
Это работает, но ThreadLocal
хранит данные как изменяемое состояние потока. Если поток из пула переиспользуется, важно очищать значение. К тому же при создании новых потоков значение неявно копируется (для дочерних потоков) или нет – можно запутаться. И главное: с появлением виртуальных потоков, которые очень легкие и могут создаваться тысячами, использование ThreadLocal становится тяжелым (копирование значений на каждый виртуальный поток и риск утечек).
Java 21: Scoped Values задумывались как более легковесная и безопасная замена ThreadLocal. Главное отличие – ScopedValue задается на время выполнения определенного участка кода и является неизменяемым. Пример использования Scoped Values для той же задачи:
// Объявление ScopedValue
private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
void processRequest(String userId) {
ScopedValue.where(USER_ID, userId) // привязываем значение
.run(() -> performActions()); // выполняем код в этом scope
}
void performActions() {
// ... любой код, возможно в глубине вызывает:
log("Processing user " + USER_ID.get());
}
Мы создаем ScopedValue.newInstance()
, затем вызываем ScopedValue.where(value, context).run(runnable)
. Все вызовы внутри run
могут через USER_ID.get()
получить привязанное значение. После завершения run
значение автоматически недоступно, out of scope. Нет нужды вручную сбрасывать, нет риска утечки за пределами блока. Также Scoped Values не допускают изменения значения после установки – то есть внутри выполняемого кода нельзя поменять USER_ID, оно как бы финально для этого контекста.
Почему Scoped Values предпочтительнее ThreadLocal, особенно с Loom:
- Иммутабельность и безопасность: Значение устанавливается один раз на запуск блока и не может быть изменено, исключая «фоновые» побочные эффекты, когда кто-то в другом месте неожиданно поменяет контекст. Это делает поведение более предсказуемым (никакого “spooky action at a distance”).
- Ограниченный срок жизни: Значение существует только в рамках вызова
.run()
. После выхода оно гарантированно недоступно, что предотвращает утечки (ThreadLocal иногда забывают убрать и объект висит, не позволяя сборщику мусора очистить контекст потока). - Лучшая работа с виртуальными потоками: Scoped Values спроектированы с учетом Loom и работают эффективно даже при большом числе виртуальных потоков. ThreadLocal же на тысячи короткоживущих потоков тратит много памяти (копирует значения на каждый поток) и может привести к неожиданным проблемам. Scoped Values потребляют меньше памяти и проще в управлении.
- Простота отладки: Поскольку значение передается явно через
ScopedValue.where(...).run(...)
, вы видите четко границы, где контекст действует. Это лучше, чем глобальный ThreadLocal, который может быть установлен где-то далеко и влиять на поведение функции, если она вызывается в том же потоке.
Scoped Values все еще в режиме предварительного просмотра, но уже сейчас понятно, что они делают многопоточный код более понятным и безопасным. В связке с виртуальными потоками эта функциональность позволяет писать высокопроизводительный код, не отказываясь от удобства иметь «контекст» вызова, и при этом не рискуя стабильностью и утечками памяти. Фактически, мы получаем что-то похожее на inline-параметры для целого вызовного стека: как будто передали дополнительный аргумент в каждый метод, но без изменения сигнатур методов. Это мощный инструмент для реализации, например, трассировки запросов, параметров безопасности, транзакционного контекста и т.д.
Заключение
Мы рассмотрели 14 примеров, где современные возможности Java существенно улучшают код по сравнению с подходами эпохи Java 8. Итого, какие тенденции мы видим?
- Уменьшение шаблонного кода: Record-классы, текстовые блоки, фабричные методы коллекций,
var
и улучшенные конструкции (switch
-выражения, pattern matching) устраняют бойлерплейт, позволяя писать меньше, а получать больше. Код становится короче, но информативнее. - Безопасность и надежность: Immutability by default (в record,
List.of
), контроль наследования (sealed), проверка исчерпывающих условий, Scoped Values вместо глобальных переменных – все это снижает вероятность ошибок времени выполнения и упрощает контроль над поведением программы. Компилятор и рантайм берут на себя больше проверок, облегчая жизнь разработчика. - Читаемость и поддерживаемость: Новый синтаксис (pattern matching, text blocks) ближе к естественному описанию задачи, проще воспринимается. Код на Java 21 зачастую выглядит более декларативно: вы описываете, что хотите получить, а не расписываете детально, как это сделать. Это облегчает участие новых разработчиков в проекте и уменьшает “когнитивную нагрузку” при чтении кода.
- Производительность без усложнения архитектуры: Виртуальные потоки дают масштабирование, сохраняя простую модель потоков, вместо ввода сложных async-фреймворков. Structured concurrency (в Java 21 как preview) и связанные с ним Scoped Values позволяют писать многопоточный код, который легко читать как последовательный, но он эффективно выполняется параллельно. Проекты, перешедшие на Java 21, могут получить выигрыш в производительности буквально за счет одной переключенной опции, без полной переработки под реактивные модели – что очень ценно.
Конечно, каждую новую возможность нужно изучить и правильно применять. Но примеры, подобные приведенным, показывают, что время, потраченное на освоение Java 21, окупается сторицей. Java эволюционирует и предлагает инструменты, делающие код ближе к бизнес-логике и дальше от низкоуровневой рутины. Если ваш проект все еще на Java 8 или просто использует старый стиль кода, самое время оценить преимущества современного Java. Как отмечают эксперты, 2023 год стал поворотным моментом, когда оставаться на Java 8 уже нецелесообразно – миграция на актуальные LTS-релизы идет полным ходом.
Переход на Java 21 – это не только про новые API, но про иной, более эффективный стиль программирования. Используя возможности последних версий, вы пишете менее объемный, более понятный и более правильный код. Это повышает скорость разработки и качество продукта, а значит, приносит пользу и разработчикам, и бизнесу. Самое время попробовать современные фичи в своем проекте и убедиться, насколько они упрощают жизнь Java-программиста!