
Что нового в Java 24. Часть 1
Выход Java 24 назначен на март. В JDK 24 нас ожидает 24 JEP-фичи, включая 8 совершенно новых. Две из новых возможностей связаны с долгожданными проектами OpenJDK: Project Leyden и Project Lilliput.
Из-за количества JEP в релизе мы решили разделить статью на две части. В первой части мы рассмотрим восемь JEP, которые вошли в новую Java.
Содержание
- Как начать использовать фичи в предварительной и экспериментальной версиях
- Новые фичи
- Финализированные фичи
- Улучшенные фичи
- Заключение
Как начать использовать фичи в предварительной и экспериментальной версиях
Чтобы попробовать экспериментальные фичи или фичи в предварительной версии, нужно их явно включить. Вы можете это сделать как через командную строку, так и настроить в интегрированной среде разработки (IDE). Некоторые фичи можно включать на уровне javac
, а не только при запуске java
.
В командной строке включите фичу в предварительной версии одним из следующих способов:
- Скомпилируйте программу с помощью
javac --release 24 --enable-preview Main.java
и запустите ее с помощьюjava --enable-preview Main
. - При использовании source code launcher запустите программу с
java --enable-preview Main.java
. - При использовании jshell запустите его с помощью
jshell --enable-preview
.
Новые фичи
JEP 404: Generational Shenandoah GC (экспериментальная версия)
В JEP 404 добавился режим поколений для сборщика мусора Shenandoah GC.
Shenandoah GC — это сборщик мусора с низкой задержкой, который выполняет большую часть своей работы параллельно с приложением, включая одновременную компактизацию, что позволяет сохранять минимальное время пауз независимо от размера кучи. Изначально Shenandoah был non-generational сборщиком, то есть он не разделял объекты на молодое и старое поколения, как это делают G1 GC, Parallel GC и, начиная с JDK 23, ZGC. Это вынуждало разработчиков выделять больше свободной памяти для Shenandoah в non-generational режиме. Кроме того, сборщик мусора был вынужден чаще отмечать долгоживущие объекты и выполнять больше работы при их сборке.
Generational Shenandoah GC сможет поддерживать молодое и старое поколения, что позволит чаще собирать молодые объекты. Shenandoah будет собирать либо только молодые объекты, либо одновременно и молодые, и старые, подобно G1 GC. Shenandoah GC выполняет смешанные сборки (mixed collections) с учётом уникальной модели работы с регионами и барьерами. Особенность Shenandoah заключается в том, что он будет выполнять сборку мусора параллельно с потоками приложения. Это сокращает время пауз, возникающих в результате сборки мусора. Время пауз Shenandoah GC не связано с размером кучи напрямую.
Более того, Shenandoah сможет динамически адаптировать размеры поколений и связанные параметры, что позволит сохранить низкие паузы, уменьшить использование памяти и в целом повысить производительность.
На данный момент режим поколений для Shenandoah GC является экспериментальным и должен быть явно включён с помощью параметров JVM:
-XX:+UnlockExperimentalVMOptions -XX:ShenandoahGCMode=generational
Дополнительно можно настраивать параметры размеров молодого и старого поколения:
-XX:ShenandoahYoungGenSize=512m # размер молодого поколения
-XX:ShenandoahOldGenSize=2g # размер старого поколения
В будущих версиях планируется сделать режим поколений в Shenandoah GC режимом по умолчанию.
Так есть же generational ZGC? Режим поколений для ZGC был реализован в JDK 21, но он не поддерживает сжатые указатели на объекты. Однако большинство Java-куч, с которыми мы сталкиваемся (например, в облачных сервисах), имеют размер значительно меньше 32 ГБ, что позволяет эффективно использовать эту функцию для экономии памяти и улучшения производительности.
JEP 450: Компактные заголовки объектов (экспериментальная версия)
JEP 450 представляет Project Lilliput в основной ветке OpenJDK. Цель проекта — уменьшить размер заголовков Java-объектов в HotSpot JVM с 96 или 128 бит до 64 бит на 64-битных архитектурах (x64 и AArch64). Это призвано уменьшить использование CPU и/или памяти Java-приложениями.
Все объекты в куче Java имеют заголовки, которые содержат метаданные: указатели на объект, хэш-код, ссылку на класс и т.д. Заголовок состоит из:
- mark word (многозадачное поле, используемое для блокировок, данных сборщика мусора и т.п.)
- указателя на класс, который ссылается на экземпляр Klass.
На 64-битных платформах размер заголовка фиксирован — 128 бит вне зависимости от размера объекта, длины массива и содержимого.
Результаты экспериментов, проведённые в рамках Project Lilliput, показывают, что большинство объектов в Java маленькие (256–512 бит), и заголовки могут занимать более 20% памяти. Первые пользователи Project Lilliput, протестировавшие его в реальных приложениях, подтверждают, что объём живых данных сокращается на 10–20%.
Project Lilliput объединяет mark word и указатель на класс, сжимая итоговый заголовок до 64 бит. Уменьшение размера заголовков объектов снизит объём используемых данных в памяти, что потенциально уменьшит нагрузку на CPU и снизит потребление памяти в Java-программах. На самом деле, результат зависит от того, какие у вас объекты. Если у вас много маленьких объектов, то эффект будет ощутимым, а если объекты большие (данные больше заголовка), то ощутимого эффекта не будет.
Функция является экспериментальной и может быть включена с помощью следующих параметров:
-XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders
В планах сделать её включенной по умолчанию в будущих версиях.
Финализированные фичи
JEP 484: Class-File API
Стандартное API для парсинга, генерации и трансформации class-файлов, которое появилось в Java 22, окончательно оформилось в Java 24. Новое API находится в пакете java.lang.classfile.
Цели этого JEP:
- Предоставить API для обработки class-файлов, который поддерживает формат class-файлов, определённый спецификацией JVM.
- Обеспечить миграцию компонентов JDK на стандартный API и удалить внутреннюю копию библиотеки ASM стороннего производителя.
Главная проблема популярных библиотек для работы с class-файлами, таких как ASM, BCEL и Javassist — они обновляются с задержкой относительно новых релизов JDK (раз в полгода), которые приводят к изменению формата и версии class-файла. Эти библиотеки не успевают за обновлениями формата class-файлов, что создаёт проблемы несовместимости и ошибок.
Из-за того, что JDK использует стороннюю библиотеку для работы с class-файлами, возникает задержка во внедрении новых фич class-файлов в экосистему Java. Отсюда возникает необходимость в стандартном API для работы с class-файлами, которое будет развиваться одновременно с форматом class-файлов в JDK.
Благодаря Class-File API фреймворки и инструменты, использующие стандартный API, будут автоматически поддерживать class-файлы из последней версии JDK, что позволит быстро и легко внедрять новые возможности языка и VM, представленные в class-файлах.
В дизайн Class-File API также заложена поддержка таких возможностей Java, как lambda-выражения, pattern matching, sealed-классы и записи (record). Cтандартное API теперь обновляется одновременно с форматом class-файлов, что делает его более надёжным и совместимым с будущими версиями JVM.
JEP 485: Stream Gatherers
Stream Gatherers появились в JDK 22 для расширения Stream API поддержкой пользовательских intermediate-операторов. В JDK 24 Stream Gatherers становится постоянной фичей.
Стримы (streams) широко используются в экосистеме Java и подходят для большинства задач. Однако фиксированный набор промежуточных операций, таких как map
, filter
, distinct
и др., ограничивает применение стримов в сложных сценариях, где нужной операции либо нет, либо она недостаточно подходит. До создания Stream Gatherers, если нужная операция отсутствовала, нужно было использовать сложные обходные пути или манипуляции с данными, что делало код перегруженным и трудным для поддержки.
Например, нам нужно сгруппировать элементы в группы по три и оставить только первые две группы, т.е. из этого [0, 1, 2, 3, 4, 5, 6, …] мы должны получить это [[0, 1, 2], [3, 4, 5]].
Ни одна встроенная промежуточная операция в Stream API, ни их ряд не позволяет решить эту задачу напрямую. Придётся делать группировки фиксированного размера в терминирующей операции с использованием collect
и пользовательского Collector
:
var result
= Stream.iterate(0, i -> i + 1)
.limit(3 * 2)
.collect(Collector.of(
() -> new ArrayList<ArrayList<Integer>>(),
(groups, element) -> {
if (groups.isEmpty() || groups.getLast().size() == 3) {
var current = new ArrayList<Integer>();
current.add(element);
groups.addLast(current);
} else {
groups.getLast().add(element);
}
},
(left, right) -> {
throw new UnsupportedOperationException("Cannot be parallelized");
}
));
// result ==> [[0, 1, 2], [3, 4, 5]]
Итоговый вариант получился сложным для понимания. Stream Gatherers позволяют решить эту задачу сильно проще:
var result = Stream.of(1,2,3,4,5,6,7,8).gather(Gatherers.windowFixed(3)).limit(2).toList();
// result ==> [[0, 1, 2], [3, 4, 5]]
Gatherer — это экземпляр интерфейса java.util.stream.Gatherer, задающий логику преобразования элементов стрима. На дизайн Gatherer
повлиял интерфейс Collector
. Stream::gather(Gatherer) — промежуточная операция над стримами, которая преобразует элементы стрима, применяя к ним определённую пользователем сущность gatherer. Stream::gather(Gatherer)
для промежуточных операций то же, что Stream::collect(Collector)
для терминальных операций. Gatherers не заменяют коллекторы, а расширяют возможности промежуточной обработки в Stream API.
В java.util.stream.Gatherers есть несколько встроенных операций, например:
- mapConcurrent — вызывает переданную функцию для каждого элемента потока параллельно, до указанного лимита.
- windowFixed — группирует элементы потока в списки заданного размера и передаёт их, когда они заполняются.
- windowSliding — группирует элементы потока в окна заданного размера, где каждое последующее окно создаётся путем сдвига предыдущего на один элемент.
Stream Gatherers преодолевают ограничения Stream API, делая его мощным инструментом для реализации широкого спектра задач. Это снижает сложность кода и повышает его читаемость.
Улучшенные фичи
JEP 487: Scoped Values (четвёртая предварительная версия)
Scoped Values появились в инкубаторе в Java 20, остались на второе и третье превью и уходят на четвёртое превью в Java 24.
В четвёртом превью методы callWhere
и runWhere
были удалены из класса ScopedValue
, сделав API полностью fluent, т.е. таким, чтобы методы могли вызываться друг за другом в цепочке. Единственный способ использовать одно или несколько связанных scoped-значений — через методы ScopedValue.Carrier.call
и ScopedValue.Carrier.run
.
Цель этого JEP — собрать больше обратной связи от разработчиков.
При разработке Java-приложений нередко возникает задача передать данные между методами. Например, веб-фреймворк принимает входящие HTTP-запросы и вызывает обработчик приложения, чтобы их обработать. Обработчик приложения снова обращается к фреймворку, чтобы получить данные из базы данных или вызвать другой HTTP-сервис.
Фреймворк может использовать объект FrameworkContext
, который содержит ID аутентифицированного пользователя, ID транзакции и др., и связывать его с текущей транзакцией. Все операции фреймворка работают с объектом FrameworkContext
, но для пользовательского кода этот объект не имеет значения.
Обычно в таком случае используют переменные типа ThreadLocal
:
public class Framework {
private final Application application;
public Framework(Application app) { this.application = app; }
private static final ThreadLocal<FrameworkContext> CONTEXT
= new ThreadLocal<>(); // объявляем переменную типа ThreadLocal
void serve(Request request, Response response) {
var context = createContext(request);
CONTEXT.set(context); // записываем значение FrameworkContext в переменную
Application.handle(request, response);
}
public PersistedObject readKey(String key) {
var context = CONTEXT.get(); // считываем значение переменной, чтобы получить FrameworkContext
var db = getDBConnection(context);
db.readKey(key);
}
}
Переменная типа Threadlocal
служит скрытым параметром метода. Поток, в котором вызывается CONTEXT.set
в Framework.serve
, а затем CONTEXT.get
в Framework.readKey
, автоматически увидит локальную копию переменной CONTEXT
. По сути, поле ThreadLocal
— ключ для поиска значения FrameworkContext
для текущего потока.
Однако у ThreadLocal
есть несколько недостатков:
- Неконтролируемая изменяемость. Любую переменную типа
ThreadLocal
можно изменить в любое время через методset()
, даже если она хранит неизменяемый объект. - Неограниченный срок жизни. Значение переменной
ThreadLocal
сохраняется до завершения исполнения потока или до явного вызова методаremove()
, про который часто забывают. Это может привести к утечкам памяти, особенно при пуле потоков. Если переменные активно изменяются, будет сложно определить, когда безопасно вызватьremove()
. - Дорогое наследование. Когда дочерние потоки наследуют переменные
ThreadLocal
родительского потока, для каждого дочернего потока требуется отдельное хранилище для всех переменныхThreadLocal
родителя. Это увеличивает использование памяти и ресурсов, даже если эти переменные редко изменяются в дочерних потоках.
Использование виртуальных потоков усложнит ситуацию, поскольку число виртуальных потоков может быть сильно больше, чем обычных потоков.
Проблемы использования ThreadLocal
решаются ScopedValue
. Значение в ScopedValue
в отличие от ThreadLocal
записывается один раз и доступно только в течение ограниченного периода времени во время исполнения потока.
1) Создаётся объект ScopedValue
, не связанный ни с одним потоком.
2) В коде вызывается метод where()
, который принимает значение (scoped value) и объект ScopedValue
.
3) Затем метод run()
привязывает значение к объекту, создавая копию scoped value для текущего потока, и запускает переданное лямбда-выражение. Пока выполняется метод run()
, лямбда-выражение и любые методы, вызванные из него напрямую или косвенно, могут получать доступ к scoped value через метод get()
. После завершения метода run()
scoped value отвязывается от объекта.
С помощью ScopedValue
пример с фреймворком можно переписать следующим образом:
class Framework {
private static final ScopedValue<FrameworkContext> CONTEXT
= ScopedValue.newInstance(); // объявляем переменную типа ScopedValue
void serve(Request request, Response response) {
var context = createContext(request);
where(CONTEXT, context) // вызываем метод where...run вместо метода ThreadLocal.set()
.run(() -> Application.handle(request, response));
}
public PersistedObject readKey(String key) {
var context = CONTEXT.get(); // считываем значение переменной
var db = getDBConnection(context);
db.readKey(key);
}
}
В общем случае, рекомендуется переходить c ThreadLocal
на ScopedValue
, когда цель использования — однонаправленная передача неизменяемых данных. Однако есть несколько сценариев, в которых лучше использовать ThreadLocal
. Например, кэширование объектов, которые дорого создавать и использовать.
JEP 488: Примитивные типы в patterns, instanceof и switch (вторая предварительная версия)
Появившаяся в Java 23, эта фича входит в Java 24 в составе JEP 488 без изменений.
Ранее выражение-селектор, управляющее оператором switch
, должно было соответствовать одному из следующих типов:
byte
,short
,int
,char;
- Их обёрткам
Byte
,Short
,Integer
,Character
; - перечислением
enum
; String
(с версии JDK 7).
switch
теперь поддерживает boolean, float, double и long.
Теперь можно использовать switch
с boolean, что даёт больше гибкости, чем тернарный оператор. Например, код ниже использует boolean с switch-выражением для логирования при false:
startProcessing(OrderStatus.NEW, switch (user.isLoggedIn()) {
case true -> user.id();
case false -> { log("Unrecognized user"); yield -1; }
});
Раньше для обработки long приходилось писать if-конструкции, но теперь switch
может обрабатывать long:
long v = ...;
switch (v) {
case 1L -> ...;
case 2L -> ...;
case 10_000_000_000L -> ...;
case 20_000_000_000L -> ...;
case long x -> ... x ...;
}
Также пример ниже с switch-выражением:
switch (x.getStatus()) {
case 0 -> "okay";
case 1 -> "warning";
case 2 -> "error";
default -> "unknown status: " + x.getStatus();
Можно переписать таким образом, заменив default
на case
с паттерном примитивного типа:
switch (x.getStatus()) {
case 0 -> "okay";
case 1 -> "warning";
case 2 -> "error";
case int i -> "unknown status: " + i;
Ранее в instanceof
можно было использовать только ссылочные (reference) типы и записи (record patterns в Java 21+). Это ограничение делало проверку примитивных значений сложной и небезопасной.
Например, в Java 17 безопасное приведение int
в byte
требовало явного диапазонного контроля:
if (i >= Byte.MIN_VALUE && i <= Byte.MAX_VALUE) {
byte b = (byte) i;
// работа с b
}
В то же время автоматическое преобразование int
в float
может быть потерей информации без предупреждений:
int getPopulation() { ... }
float pop = getPopulation(); // потенциально теряется точность
Теперь instanceof
может проверять, можно ли безопасно привести один примитивный тип к другому без потери информации и привести их. Предыдущие два примера можно переписать так:
if (getPopulation() instanceof float pop) {
// безопасное использование pop
}
if (i instanceof byte b) {
// b уже безопасно приведённый к byte
}
Это объединяет удобство присваивания и безопасность приведения типов, а также устраняет необходимость ручных проверок диапазона.
Если instanceof
применяется без шаблона (T t
), он теперь может проверять совместимость приведения типов без приведения:
if (i instanceof byte) { // проверяем, помещается ли i в byte
byte b = (byte) i; // приведение остаётся явным
}
Эти улучшения делают instanceof
и switch
более удобными для работы с примитивными типами данных:
- Упрощается проверка и приведение примитивных типов без потерь информации.
instanceof
теперь поддерживает примитивные шаблоны и проверку совместимости типов.switch
становится более выразительным, поддерживая новые типы (boolean, long, float, double).
JEP 489: Vector API (девятый инкубатор)
Vector API появилось в JDK 16 и пока остаётся в инкубационном периоде. В девятом инкубаторе всё без изменений.
Vector API повышает производительность расчётов на массивах однотипных данных, которые компилируются в векторные инструкции во время исполнения приложения.
Vector API зависит от некоторых фич Project Valhalla, который находится в разработке. Как только эти функциональности станут доступны в предварительной версии, векторное API так же перейдёт в предварительную версию.
Vector API позволяет работать с векторными операциями, обеспечивая более предсказуемую и производительную векторизацию по сравнению с авто-векторизацией (auto-vectorization) HotSpot. Оно даёт возможность писать высокопроизводительные алгоритмы, использующие SIMD (Single Instruction Multiple Data), что особенно полезно в таких областях, как машинное обучение, линейная алгебра, криптография и финансы.
Основные понятия:
- Вектор (Vector): Абстрактный класс, представляющий последовательность значений примитивного типа (byte, short, int, long, float, double).
- Форма (Shape): Определяет размер вектора (64, 128, 256, 512 бит или максимальный для платформы размер).
- Вид вектора (VectorSpecies): Комбинация типа элемента и формы.
Операции:
- Lane-wise: Независимые операции для каждого элемента (слота) вектора (сложение, умножение и т. д.).
- Cross-lane: Операции, изменяющие всю структуру вектора (перестановка, редукция).
- Маски (VectorMask): Позволяют выборочно применять операции к отдельным элементам вектора.
- Перестановки (VectorShuffle): Определяют, как элементы одного вектора будут распределены в другом векторе.
Рассмотрим скалярный код, вычисляющий выражение (a[i] * a[i] + b[i] * b[i]) * -1.0f
:
void scalarComputation(float[] a, float[] b, float[] c) {
for (int i = 0; i < a.length; i++) {
c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
}
}
Теперь преобразуем этот код, используя Vector API:
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
void vectorComputation(float[] a, float[] b, float[] c) {
int i = 0;
int upperBound = SPECIES.loopBound(a.length);
for (; i < upperBound; i += SPECIES.length()) {
var va = FloatVector.fromArray(SPECIES, a, i);
var vb = FloatVector.fromArray(SPECIES, b, i);
var vc = va.mul(va)
.add(vb.mul(vb))
.neg();
vc.intoArray(c, i);
}
for (; i < a.length; i++) {
c[i] = (a[i] * a[i] + b[i] * b[i]) * -1.0f;
}
}
Сначала мы получаем предпочтительный вид из FloatVector
, оптимальный для текущей архитектуры, и сохраняем его в поле static final
, чтобы компилятор рассматривал его как константу для лучшей оптимизации вычислений. Затем основной цикл проходит по массивам с шагом, равным длине вектора (т.е. длине SPECIES
), загружает векторы типа float из массивов a и b, выполняет арифметические операции и сохраняет результат в массив c. Остаток элементов после последней итерации обрабатывается обычным скалярным циклом.
На архитектурах ARM Scalable Vector Extensions и Intel AVX-512 с поддержкой предикатных регистров можно обойтись без скалярного кода для обработки оставшихся элементов:
void vectorComputation(float[] a, float[] b, float[] c) {
for (int i = 0; i < a.length; i += SPECIES.length()) {
var m = SPECIES.indexInRange(i, a.length);
var va = FloatVector.fromArray(SPECIES, a, i, m);
var vb = FloatVector.fromArray(SPECIES, b, i, m);
var vc = va.mul(va)
.add(vb.mul(vb))
.neg();
vc.intoArray(c, i, m);
}
}
Маска m
гарантирует, что операции выполняются только для допустимых индексов, предотвращая выход за границы массива.
На больших массивах данных этот подход достигает оптимальной производительности. Компилятор HotSpot C2 генерирует машинный код, похожий на следующий, на процессоре Intel x64 с поддержкой AVX:
0.43% / │ 0x0000000113d43890: vmovdqu 0x10(%r8,%rbx,4),%ymm0
7.38% │ │ 0x0000000113d43897: vmovdqu 0x10(%r10,%rbx,4),%ymm1
8.70% │ │ 0x0000000113d4389e: vmulps %ymm0,%ymm0,%ymm0
5.60% │ │ 0x0000000113d438a2: vmulps %ymm1,%ymm1,%ymm1
13.16% │ │ 0x0000000113d438a6: vaddps %ymm0,%ymm1,%ymm0
21.86% │ │ 0x0000000113d438aa: vxorps -0x7ad76b2(%rip),%ymm0,%ymm0
7.66% │ │ 0x0000000113d438b2: vmovdqu %ymm0,0x10(%r9,%rbx,4)
26.20% │ │ 0x0000000113d438b9: add $0x8,%ebx
6.44% │ │ 0x0000000113d438bc: cmp %r11d,%ebx
\ │ 0x0000000113d438bf: jl 0x0000000113d43890
Есть два варианта реализации Vector API:
- На чистом Java (функциональный, но неоптимальный вариант).
- С использованием C2-компилятора HotSpot, который заменяет операции на векторные инструкции процессора.
Для ускорения тригонометрических операций используется библиотека Intel Short Vector Math Library (SVML), оптимизированная для процессоров x64.
JEP 494: Импортирование модулей (вторая предварительная версия)
Эта фича позволяет вместо явного импортирования отдельных пакетов, классов и интерфейсов импортировать модуль со всеми его публичными классами и интерфейсами, а также модулями, от которых он зависит транзитивно. Это упростит переиспользование модульных библиотек путём одновременного импортирования модулей, снизит необходимость в множестве отдельных import-директив и упростит работу с кодом, особенно на ранних этапах разработки.
Цель этого JEP 494 — собрать обратную связь от разработчиков. Во второй предварительной версии были внесены два изменения:
-
При импорте модуля
java.se
импортируется весь API Java SE.java.se
транзитивно требуетjava.base
. -
Объявление type-import-on-demand (нпример,
import com.foo.bar.*
), имеет приоритет над объявлением импорта модулей, чтобы разрешить конфликт имён.
Например, для работы с коллекциями и потоками требовалось вручную импортировать каждую зависимость:
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
String[] fruits = { "apple", "berry", "citrus" };
Map<String, String> m = Stream.of(fruits)
.collect(Collectors.toMap(s -> s.toUpperCase().substring(0, 1), Function.identity()));
Теперь можно просто импортировать весь модуль java.base
, содержащий List
, Map
, Stream
, Path
и другие классы с помощью import module M
:
import module java.base;
String[] fruits = { "apple", "berry", "citrus" };
Map<String, String> m = Stream.of(fruits)
.collect(Collectors.toMap(s -> s.toUpperCase().substring(0, 1), Function.identity()));
Если один модуль экспортирует классы с одинаковыми именами, компилятор выдаст ошибку. Например:
import module java.base;
import module java.sql; // оба модуля содержат класс Date
Date d = new Date(); // неоднозначность, класс Date из какого модуля использовать?
Решение — использовать явный импорт класса нужного модуля:
import module java.base;
import module java.sql;
import java.sql.Date; // явно указываем нужный класс
Date d = new Date(); // компилятор понимает, что используется java.sql.Date
Импорт на уровне модулей делает код чище и удобнее, особенно при использовании больших API. Однако возможны конфликты имён, которые решаются с помощью явного импорта.
Заключение
Мы рассмотрели только часть JEP в Java 24. Во второй части мы рассмотрим остальные JEP и подытожим куда развивается Java.