Розбираємо реліз Java 23. Як використовувати його у ваших застосунках
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник. Хочу поділитися з вами своїм досвідом міграції проєктів з JDK 22 на JDK 23. Ця версія вийшла тиждень тому. Це короткостроковий реліз з підтримкою протягом 6 місяців (наступний довгостроковий — JDK 25), в якому немає жодної серйозної фічі в статусі «стабільна». Проте він містить цікаві зміни, заради яких варто познайомитися з оновленням.
Я вже писав про нові фічі в JDK 22, тому в цій статті пропущу ті з них, які не змінилися за ці півроку. Сподіваюся, матеріал буде корисним для всіх, хто хоче більше дізнатися про новинки в Java 23, розібратися в тому, як провести міграцію застосунків на JDK 23, і які тут можуть бути складнощі.
Що нового в Java 23
Спочатку поговоримо про те, чого немає у новій версії. Дуже рідко таке буває, але фіча String templates, яка довго перебувала в режимі preview (ознайомлення), була повністю видалена з JDK 23. Це ще раз підкреслює той факт, що не варто використовувати preview features в реальних проєктах, оскільки вони можуть не тільки кардинально змінюватись, а й зникати.
Якими новими фічами нас порадували розробники JDK:
- Примітивні типи в патернах, switch і instanceof.
- Декларації імпортів модулів.
Крім того, продовжують обкатуватися фічі, які все ще перебувають у режимі ознайомлення:
- Class-File API.
- Vector API.
- Stream Gatherers.
- Неявно визначені main-методи та класи.
- Structured Concurrency.
- Scoped Values.
- Гнучкі визначення конструкторів.
Також є ще дві фічі, про які варто поговорити окремо. Перша з них — це оголошення deprecated сумно відомого класу Unsafe:
@Deprecated(since="23", forRemoval=true) @ForceInline public int getInt(Object o, long offset) { beforeMemoryAccess(); return theInternalUnsafe.getInt(o, offset); }
Цей клас з’явився ще в 2002 році і з того часу широко використовується в різних Java-технологіях для прискорення роботи з пам’яттю (як on-heap, так і off-heap), адже пропонує пряме звернення до неї. Проблема у тому, що це внутрішній метод JDK, не створений для публічного використання. І така широка популярність заважає змінювати та розвивати його. Більше того, не всі розробники правильно використовують можливості Unsafe, оскільки він знову ж таки не документований. У Java вже багато років борються із його незаконним використанням.
Ця ініціатива з’явилася за часів роботи над JDK 9, коли було вирішено інкапсулювати всі внутрішні API. Таким чином, у JDK 9 з’явилася фіча VarHandle API, а в JDK 22 — Foreign Function & Memory API, які пропонують публічний API і фактично інкапсулюють Unsafe. Тому всі, хто все ще його використовують, повинні перейти на новий API. Правда, зробити це буде не так просто, тому що більшість бібліотек все ще перебуває у режимі сумісності з JDK 8/11, а їх перехід на JDK 21/25 — це до дуже далекого майбутнього. Зрештою, в JDK 25 всі методи Unsafe викидатимуть виключення, а в JDK 26 будуть видалені.
Підтримка Markdown
Ще одна не менш фундаментальна зміна — підтримка мови розмітки Markdown для написання JavaDoc. Історично для цього ще з самого початку в 1995 році використовувався HTML спільно з JavaDoc-тегами. Тоді HTML активно розвивався, а до появи Markdown залишалося 9 років. Але через 29 років Markdown став дуже популярним, на ньому пишуть коментарі та пости в GitHub, Stackoverflow і Reddit. Тому не дивно, що на таке зростання популярності відреагували і розробники Java.
Правда, писати JavaDocs можна за допомогою будь-якої мови розмітки. Головне, щоб був препроцесор для перетворення його у вихідний HTML. У JDK 23 для цього використовується бібліотека commonmark-java, яка підтримує специфікацію CommonMark. І тепер можна забути про HTML та багато специфічних тегів JavaDoc і перейти на Markdown.
Головна відмінність при новому підході — відмова від традиційних символів /* */ і перехід на спеціальний початок коментаря ///, щоб позбавитися багатьох обмежень, які приносить перший варіант. Тепер ви можете написати JavaDoc для методу toBoolean класу StringUtils таким чином:
///Converts [java.lang.String] value into **boolean** variable /// ///Text values that will be converted to true value: ///- true ///- TRUE /// ///Usage examples: /// ///| Argument | Returned value | ///|----------|----------------| ///| "true" | true | ///| "TRUE" | true | ///| "yes" | false | ///| "false" | false | ///| null | false | public static boolean toBoolean(String str) { return str != null && str.equalsIgnoreCase("true"); }
Тут використовується одразу чотири елементи Markdown:
- квадратні дужки для посилання на клас;
- ** для відображення жирним шрифтом;
- список за допомогою —;
- таблиця із двома стовпцями.
Утиліта javadoc згенерувала цілком коректний HTML:
Єдине, до чого можна причепитися — таблиця без ліній та кольорів.
Тепер перейдемо до нових фіч в JDK 23.
Примітивні типи в патернах, instanceof і switch
Відразу може виникнути питання, чи switch не підтримує примітивні типи. У будь-якій версії Java ви можете написати наступний блок switch:
public void print(int value) { switch (value) { case 0: System.out.println("0"); break; case 1: System.out.println("1"); break; } }
Понад те, лише примітивні типи у перших версіях і підтримувалися. Але нова фіча трошки про інше. У JDK 21 з’явилася функціональність pattern matching for switch:
public static void print(Object value) { switch (value) { case String text -> System.out.println("This is a String value"); case Integer i -> System.out.println("THis is an integer argument"); default -> {} } }
І одним із обмежень була неможливість використання примітивних типів у блоці case. Тепер це обмеження знято:
public static void print(Object value) { switch (value) { case byte b -> System.out.println("This is a byte value"); case int i -> System.out.println("This is an integer argument"); default -> System.out.println("Unknown type"); } }
Але якщо для reference типів перевірка типу на ідентичність зрозуміла, то як вона буде відбуватися для примітивних типів і значень?
Якщо цей метод передати 100:
print(100);
Здається логічним, якщо відпрацює case з byte-аргументом. Адже 100 — це те значення, яке можна присвоїти змінній типу byte. Але виведеться:
This is an integer argument
Цікаво, що якщо трохи змінити цей виклик:
byte b = 100; print(b);
То вже виведеться інший рядок:
This is a byte value
Таким чином, switch спочатку робить операцію boxing, перетворюючи примітивне значення на об’єкт, а далі порівнює цей тип з тими, які вказані в case (теж boxed). Саме тому в першому випадку відпрацював case з int, тому що 100 — це за умовчанням значення типу int. А якби написали:
print(100L);
То вивелося б:
Unknown type
Чи можна змішувати примітивні та reference-типи в switch? Можна, але при цьому типи, вказані першими в випадку, не повинні бути пов’язані з типами, вказаними після них. Тобто такий switch компілюватися не буде:
public static void print(Object value) { switch (value) { case Number n -> System.out.println("This is number"); case byte b -> System.out.println("This is a byte value"); case int i -> System.out.println("This is an integer argument"); default -> System.out.println("Unknown type"); } }
Перейдемо до instanceof. Тут ситуація трохи інша. Тепер ви можете вказувати примітивні типи в instanceof:
int i = 100; if(i instanceof byte b) { System.out.println(b); }
Але при цьому тут перевіряються не типи (на відміну від switch), а можливість значення бути присвоєним тому чи іншому примітивному типу. Так як 100 лежить в діапазоні значень, допустимих для byte, то даний instanceof поверне true.
Це дуже потрібна функціональність, тому що раніше ви могли написати такий код:
int i = 200; byte b = (byte) i;
І втратити початкове значення. Тепер же ви можете перевірити, чи дійсно можна зробити таке приведення типів без втрати інформації.
Цікава ситуація із дійсними числами. Перевірка instanceof пройде, якщо у числа фактично відсутня дробова частина:
double d = 100.0; if(d instanceof int i) { System.out.println(i); }
І не пройде, якщо дрібна частина є:
double d = 100.1; if(d instanceof int i) { System.out.println(i); }
Або значення не може бути перетворено без втрати інформації:
double d = Double.MAX_VALUE; if(d instanceof int i) { System.out.println(i); }
Ну і остання частина цієї фічі — можливість використання примітивних типів у паттернах (для switch та instanceof):
record Coordinate(int x, int y) {} public static void print(Object value) { switch (value) { case Coordinate(int x, int y) -> System.out.println("This is coordinate with x: " + x + ", y: " + y); default -> System.out.println("Unknown type"); } }
Декларації імпортів модулів
Я думаю, всі знають, що будь-який тип пакету java.lang можна використовувати в коді без необхідності явно його імпортувати:
import java.lang.String;
Але за 29 років у JDK з’явилася величезна кількість інших пакетів, які повсюдно використовуються, але їх доводиться імпортувати явно, наприклад, з пакету java.util і його підпакетів. Тому в JDK 23 з’явилася можливість імпортувати відразу всі типи, але не з пакета, як зараз можна зробити, а з модуля Java. Модулі з’явилися в JDK 9 (проєкт Jigsaw) як спроба позбавитися монолітності дистрибутива JDK. Більше того, за допомогою файлу module-info.java ви можете оголошувати свої модулі, надавати або забороняти доступ до того чи іншого модуля.
Всього в JDK 71 модуль, найголовніший з яких — java.base. І всі загальні пакети (java.util, java.text. java.time, java.io і т.д.) знаходяться саме в ньому. Таким чином, якщо вам не хочеться явно вказувати декларації імпортів для типів цих пакетів, тепер можна додати такий рядок:
import module java.base;
Аналогічно можна проімпортувати будь-який інший модуль з JDK або інших проєктів (які підтримують Java модулі), але при цьому будуть проімпортовані тільки пакети, до яких у вас є доступ (тобто вони проекспортовані):
module java.base { exports java.io; exports java.lang; exports java.lang.annotation;
Тепер поговоримо про ті preview-фічі, які проходять ще одну ітерацію (обкатку) у Java-програмістів. При чому торкнемося тих, в які були внесені зміни порівняно з JDK 22.
Flexible constructor bodies
Тут було додано важливу зміну. Уявімо ієрархію з двох класів. Person:
public class Person { private String name; public Person(String name) { this.name = name; } public String getName() { return name; } }
Та Employee:
public class Employee extends Person { private String department; public Employee(String name, String department) { Objects.nonNull(name); Objects.nonNull(department); super(name); this.department = department; } }
Ця фіча спочатку (вона з’явилася в JDK 22) дозволяла вставляти код перед викликом базового конструктора, наприклад, для перевірки вхідних аргументів. Тепер ви можете і присвоювати значення полів класу:
public Employee(String name, String department) { Objects.nonNull(name); Objects.nonNull(department); this.department = department; super(name); }
Це зроблено для випадку, коли у базовому конструкторі викликається перевизначений метод, який звертається до полів дочірнього класу. Не найкраща практика, але так інколи роблять. Ці поля при виконанні методу матимуть дефолтні значення, а не ті, які ми можемо їм надати при створенні об’єкта, що може призвести до side effects. Щоб не було такого дисонансу, і було додано таку зміну.
При цьому звертатися до таких полів перед викликом базового конструктора, як і раніше, не можна:
public Employee(String name, String department) { Objects.nonNull(name); Objects.nonNull(department); this.department = department; System.out.println(this.department); super(name); }
Неявно визначені основні методи та класи
Тут з’явилися дві важливі зміни. Перша полягає у появі нового утилітного класу-синглтону IO:
@PreviewFeature(feature = PreviewFeature.Feature.IMPLICIT_CLASSES) public final class IO { private IO() { throw new Error("no instances"); } public static void println(Object obj) { con().println(obj); }
Але головне не в тому, що такий клас з’явився, а в тому, що неявно визначені класи можуть використовувати три його статичні методи без їхнього явного імпорту, тобто приблизно так:
void main() { println("This is implicitly declared class"); }
Так давно можна було робити в утиліті jshell, тепер ця можливість з’явилася і для звичайного коду.
Друга зміна полягає в тому, що для таких класів більше не потрібно явно імпортувати типи тих пакетів, які потрапили в модуль java.base, тобто java.util, java.io, java.text і так далі:
void main() { List<String> items = List.of("1", "2"); println(items); }
Все це зроблено для того, щоб спростити новачкам знайомство з Java та написання Java-коду.
Переходимо на JDK 23
Ми спробували перенести кілька наших проєктів на JDK 23. Хоча жодну з її нових фіч використати не вдасться, але принаймні перевіримо проєкти на сумісність. Оновлюємо Maven-конфігурацію:
<properties> <java.version>1.23</java.version> <java.release.version>23</java.release.version> </properties>
Якщо у вас Gradle, то тут трохи інший блок змін:
java { sourceCompatibility = org.gradle.api.JavaVersion.VERSION_23 targetCompatibility = org.gradle.api.JavaVersion.VERSION_23 toolchain { languageVersion.set(JavaLanguageVersion.of(23)) } }
Усі проєкти, окрім одного, запрацювали без проблем. Але в одному при запуску Maven збірки відразу отримуємо помилку від бібліотеки Google Error Prone:
Compilation failure [ERROR] common/infra/event/BaseEvent.java: An unhandled exception was thrown by the Error Prone static analysis plugin. [ERROR] Please report this at https://github.com/google/error-prone/issues/new and include the following: [ERROR] [ERROR] error-prone version: 2.30.0 [ERROR] BugPattern: (see stack trace) [ERROR] Stack Trace: [ERROR] java.lang.NoSuchFieldError: Class com.sun.tools.javac.parser.Tokens$Comment$CommentStyle does not have member field 'com.sun.tools.javac.parser.Tokens$Comment$CommentStyle JAVADOC' [ERROR] at lombok.javac.Javac$JavadocOps_8$1.getStyle(Javac.java:364) [ERROR] at jdk.compiler/com.sun.tools.javac.parser.DocCommentParser.getTextKind(DocCommentParser.java:171)
Зважаючи на все, ця помилка пов’язана з підтримкою Markdown в JavaDoc, і можна спочатку припустити, що версія Google Error Prone (2.30), що використовується зараз, не дозволяє запускати збірку на JDK 23. Ця проблема добре відома, але ноги у неї ростуть з іншої бібліотеки, а саме з Lombok.
Так, на жаль, автори Lombok знову не помітили випуск нової версії JDK і забули протестувати свій продукт на сумісність. Це при тому, що JDK 23 вийшла 10 днів тому, а її General Release версія (тобто та, яка і буде фінальною) — понад місяць тому. Більше того, у Lombok немає ніякої робочої edge-версії, яка б підтримувала JDK 23. Таким чином, якщо у вас використовується Lombok, потрібно почекати оновлень від Lombok. Але це єдина технологія Java, де знайдені проблеми сумісності з JDK 23.
Щодо Docker images, то основні дистриб’ютори — Eclipse Temurin, Amazon Corretto, Azul Zulu вже підготували свіжі images під нову версію. Тож із упаковкою в Docker image проблем не виникне.
Висновки
На жаль, у JDK 23 увійшла лише одна нова стабільна фіча — підтримка Markdown. Тож якщо ви любите писати JavaDoc і добре знаєте Markdown, вам буде за що оцінити нову версію Java. Всі інші фічі увійшли в режимі ознайомлення. Застосовувати їх у реальних проєктах не можна, але сподіватимемося, що вони будуть оголошені стабільними вже в наступних версіях Java. Чи станеться це в наступній LTS версії JDK 25 або раніше в JDK 24 — невідомо, але максимум через рік ви зможете їх використовувати.
Міграція на JDK 23 поки що неможлива. Якщо ви використовуєте Lombok у своєму проєкті, необхідно зачекати на офіційне оновлення від авторів.
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів