Як мігрувати проєкт з JDK 17 на JDK 21
Усім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник, хочу поділитися з вами досвідом міграції проєкту з JDK 17 на JDK 21. Ця версія вийшла всього кілька тижнів тому, але багато хто так довго її чекав, що хочеться хутчіш розглянути, що в ній нового і чи можна вже зараз перевести свої проєкти на неї. У цій статті я докладно розповім (з детальними прикладами) про її фічі і про те, як я проводив міграцію.Сподіваюся, що ця стаття буде корисною для всіх, хто хоче дізнатися на практиці, як провести таку міграцію і навіщо.
Що нового в JDK 21
Нещодавно вийшла нова LTS-версія Java-платформи — JDK 21. Я вже багато разів розповідав про ті фічі, які з’явилися в JDK, починаючи з JDK 18, але ми не могли їх використовувати, оскільки, по-перше, це були версії із короткостроковою підтримкою. По-друге, вони перебували в режимі ознайомлення (preview), їхня функціональність була нестабільною і могла змінюватися. І ось нарешті багато з них визнані стабільними та готові для використання на production. Перерахуємо такі фічі:
- Record patterns.
- Pattern matching for switch.
- Віртуальні потоки.
Така відносно велика кількість фіч, пов’язаних з Java Records, свідчить про те, що інженери Oracle серйозно просувають цю функціональність. І якщо у Java 8 дуже багато було зроблено для просування функціонального програмування (Streams API), то у Java 17/21 є нахил на immutability, що лежить в основі Records.
Деякі фічі з JDK 21 досі перебувають у режимі preview:
- Scoped values.
- Structures concurrency.
Ну і в цьому релізі з’явилися нові фічі, про які ми, власне, і поговоримо трохи далі:
- Строкові темплейти.
- Безіменні патерни та змінні.
- Безіменні класи та main-методи.
- Послідовні колекції (Sequenced Collections).
Перші три поки що в режимі preview, а четверта визнана стабільною. З неї і почнемо.
Sequenced collections
Уже в першій версії Java були свої колекції (Vector, Stack і Hashtable), які ніяк не залучали інтерфейси при своєму оголошенні. Ця помилка була врахована в Java 1.2, коли з’явився Collections framework і такі базові інтерфейси, як Iterable, Collection, List і Set, причому:
- List — список упорядкованих елементів;
- Set — набір унікальних елементів.
Щоправда, вже тоді з’явилися перші питання про такий поділ функціональності і про те, чому, наприклад, колекція не може бути і впорядкованою, і не містити дублікатів (така, до речі, з’явилася у JDK 1.4 — LinkedHashSet). Усе було пояснено особливостями дизайну, і на цьому дискусія закінчилася. Але проблема лишилася. Усі ми знаємо про принцип Liskov substitution. Якщо ми оголосимо такий метод:
private void handle (List<User> users) {
то зможемо туди передати будь-який об’єкт-список або завантажити точніше будь-який об’єкт з властивостями списку, наприклад ArrayList або LinkedList. Але як нам оголосити метод, який зможе приймати як аргумент будь-яку колекцію з упорядкованим зберіганням елементів? Це неможливо. Більш того, поточний SDK від Java також не покращить становище. Наприклад, якщо у нас був об’єкт LinkedHashSet, і ми хотіли перетворити його на immutable:
private void handle (LinkedHashSet<User> users) {
Set<User> allUsers= Collections.unmodifiableSet(users);
то отримували об’єкт типу Set. Тобто ознака впорядкованості губилася. Ситуація почала потихеньку змінюватися після виходу JDK 17. У 2021 з’явилася перша пропозиція створити упорядкований тип, який назвали ReversibleCollection (альтернативна назва — OrderedCollection). І ось у JDK 21 з’явилося розширення Collections Framework та новий тип — SequencedCollection. Це інтерфейс, який визначає будь-яку колекцію, де є впорядкованість елементів і де можна чітко виділити перший і останній елемент. Зараз у ній шість методів:
reversed()
;addFirst()
;addLast()
;getFirst()
;getLast()
;removeFirst()
;removeLast()
;
Серед спадкоємців нового інтерфейсу — List, Deque та ще один новий тип SequencedSet. SequencedSet — це колекція, яка поєднує унікальність і впорядкованість (TreeSet або LinkedHashSet). Але як тоді працюватиме цей код, якщо ми не можемо самі позиціонувати елементи у TreeSet:
TreeSet<String> set = new TreeSet<>();
set.addFirst("1");
Усе дуже просто, він буде викидати UnsupportedOperationException
. Виникає питання, а чи потрібні нам одночасно List і SequencedCollection
, якщо насправді List це і є SequencedCollection
? Інженери Oracle підійшли до цього питання досить практично, зробивши SequencedCollection
максимально легковагим і додавши туди лише методи, пов’язані з доступом до окремих елементів. Але частина SDK усе ж була оновлена. Наприклад, з’явився новий метод, який дозволить писати коректніший код:
private void handle (LinkedHashSet<User> users) {
SequencedSet<User> allUsers= Collections.unmodifiableSequencedSet(users);
Unnamed variables
Наступна фіча (поки що в режимі тестування) — безіменні змінні (unnamed variables). Думаю, що в кожного розробника в роботі траплялися ситуації, коли доводилося оголошувати змінні, які ніде більше не використовувалися. Наприклад, порожній блок catch
:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
Тепер ви можете використовувати замість назви (ідентифікатора) змінної просто символ підкреслення:
try {
Thread.sleep(1000);
} catch (InterruptedException _) {
}
Водночас підкреслення, починаючи з Java 9, було зарезервованим словом (у Java 8 видавалося попередження).
Здавалося б, яка різниця, назвати змінну _ чи e? В обох випадках це один символ, ми особливо нічого не заощаджуємо. Але у разі підкреслення мається на увазі, що це не просто змінна, а змінна, яка більше ніяк не використовується, що підвищує читабельність коду. Використовувати такі назви можна в обмеженій кількості випадків і оголосити таке поле не вийде (помилка компіляції):
private int _ = 1;
Ще одне обмеження — ви не можете використовувати такі змінні далі в коді:
int _ = 1;
System.out.println(_);
Другий рядок викликає компілятор здивування та помилку: <i>Using '_' as a reference is not allowed</i>
. Це призводить до кумедних прикладів, наприклад, можна оголосити кілька змінних з формально однаковим ідентифікатором:
int _ = 1;
int _ = 2;
Також не можна використовувати такі змінні, як назви аргументів:
public class Starter {
public static void main(String[] _) {
А ось де варто їх застосовувати, то це різні pattern matching, наприклад для instanceof
. Якщо в цьому прикладі вам не важливе ім’я людини:
private void handle(Object info) {
if(info instanceof UserInfo(int id, String name)) {
то можна знову замінити його підкресленням:
private void handle(Object info) {
if(info instanceof UserInfo(int id, String _)) {
Unnamed classes і instance main methods
Будь-яка мова програмування мріє про збільшення популярності. Цього можна досягти, наприклад, зниженням вхідного порогу навчання. У Java 9 з’явилася утиліта JShell, яка дозволяла писати код у спрощеному стилі та інтерактивному режимі. А в JDK 21 з’явилася фіча — безіменні класи та main-методи.
Скільки б у вас не було досвіду з Java, ви хоч раз писали такий клас:
public class Starter {
public static void main(String[] args) {
}
}
І, швидше за все, не використовували аргумент args
, та й не завжди розуміли, чому метод main
повинен бути static
. Для того, щоб новачкам було простіше вивчати Java, було вирішено послабити вимоги до такого класу, що запускається, і тепер написати main()
набагато простіше:
public class Starter {
void main() {
}
}
Головне, щоб метод main
був не приватним. Цей код можна ще більше спростити, якщо викинути оголошення класу:
void main() {
}
Такий синтаксис добре знайомий Kotlin-розробникам. Але ж у JVM методи не можуть існувати без класів. Клас нікуди не зник, і він буде згенерований з таким ім’ям, як і назва файлу.
Важко сказати, наскільки такий синтаксис буде затребуваний, але принаймні він дозволяє писати менший код для дуже невеликих класів.
String templates
Четверта фіча — string templates. Практично будь-яка сучасна мова програмування підтримує інтерполяцію рядків. Наприклад, у Kotlin ви можете написати:
val name = "John"
val text = "His name is $name"
У JavaScript так само можна зробити за допомогою косих лапок:
let name = "John"
let text = `His name is $name`
Не можна сказати, що в Java зовсім немає можливості для інтерполяції. Вищевказаний приклад можна переписати як:
String name = "John";
String text = "His name is %s".formatted(name);
Але такий код більш громіздкий і менш читабельний. Тому Java-розробники давно вимагали реалізувати інтерполяцію, і в JDK 21 з’явилися string templates. Ключова їхня роль відводиться інтерфейсам StringTemplate
і StringTemplate.Processor.
Processor (або template processor) відповідає за валідацію та трансформацію, тоді як StringTemplate зберігає деталі вашого темплейту. Зараз у JDK є три процесори (RAW, STR та FMT), які неявно імпортуються в кожному Java-файлі. І наш приклад можна переписати як:
String name = "John";
StringTemplate template = RAW."His name is \{name}";
String text = template.interpolate();
RAW — це процесор, який бере рядок-темплейт та створює на його основі об’єкт StringTemplate
, де рядкові фрагменти відокремлені від виразів (у нашому випадку \{name}
). А далі вже на основі поточних значень виразів обчислюється підсумковий рядок. Але така форма ще більш громіздка, тому її можна переписати як:
String name = "John";
String text = STR."His name is \{name}";
Тут STR — це процесор, який виконує всю чорнову роботу і повертає обчислений рядок. Що можна вказувати як template expressions? Можна викликати методи:
public static String getName() {
return "John";
}
String text = STR."His name is \{getName()}";
Викликати методи у об’єктів:
String name = "John";
String text = STR."His name is \{name.toUpperCase()}";
Що, якщо потрібно викликати метод класу іншого пакета? Не біда, його можна проімпортувати прямо в рядку:
String text = STR. "His name is \{demo.model.Storage.getName()}" ;
Водночас новий формат \{}
вже не можна використовувати без template processor (буде помилка Processor missing from string template expression
):
String text = "His name is \{name}";
Що якщо ми намагаємося використати змінну, що не існує? Зрозуміло, отримаємо помилку компіляції:
Cannot resolve symbol 'name2'
А якщо name буде null? Тоді null і буде підставлено замість name. Але що, якщо ми викличемо метод у null-об’єкта?
String name = null;
String text = STR. "His name is \{name.toUpperCase()}" ;
Тут ми отримаємо стандартний NullPointerException
.
Якщо у вас legacy-проєкт, то, можливо, ви часто використовуєте метод String.format
і не хочете витрачати час на переписування коду під string templates. На щастя, вже зараз є процесор FormatProcessor
(доступний як FMT), який підтримує таке форматування:
int amount = 100;
String text = FMT. "The amount is %x\{amount}";
Тут на виході ми отримуємо The amount is 64. Останнє питання — наскільки швидко це все працює? Створимо невеликий benchmark на базі JMH 1.37:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 10)
@Measurement(iterations = 10)
public class StringTemplates {
private String name = "John";
@Benchmark
public String stringConcatenation() {
return "My name is" + name;
}
@Benchmark
public String stringFormat() {
return String.format("My name is %s", name);
}
@Benchmark
public String stringBuilder() {
return new StringBuilder("My name is %s").append(name).toString();
}
@Benchmark
public String stringTemplates() {
return STR. "My name is \{ name }" ;
}
Тут ми тестуємо чотири способи конкатенації рядків. Результати тестування показують, що String templates виконуються так само швидко, як звичайне об’єднання рядків і поступаються StringBuilder
всього на 10%. Для порівняння — String.format
працює у вісім разів повільніше.
Benchmark Mode Cnt Score Error Units
StringTemplates.stringBuilder avgt 5 9.632 ± 0.042 ns/op
StringTemplates.stringConcatenation avgt 5 10.389 ± 0.351 ns/op
StringTemplates.stringFormat avgt 5 83.597 ± 0.620 ns/op
StringTemplates.stringTemplates avgt 5 10.373 ± 0.027 ns/op
Яким способом забезпечується така ефективність? Якщо подивитися на декомпільований байт-код:
int amount = 100;
String text = "The amount is %x" + amount;
то видно, що основну частину роботи виконує компілятор, який шукає в рядку вирази та замінює їх на звичайну конкатенацію.
Починаємо міграцію
Що використовувати з розібраних фіч? Поки що не варто використовувати preview-фічі (вони можуть змінитися в наступних версіях). Перш за все, зверніть увагу на ті ж Java Records. Вони з’явилися ще в JDK 16, але тільки недавно Java-бібліотеки / -фреймворки почали їх підтримувати, тому ми можемо їх безпечно застосувати. Наприклад, у нас є клас Address
:
@Embeddable
public class Address {
private String zipCode;
private String street;
private String houseNo;
private String apartment;
@Column(name = "ZIP_CODE", length = 10)
public String getZipCode() {
return zipCode;
}
Його можна переробити в запис, оскільки Hibernate підтримує їх, починаючи з версії 6.0. Щоправда доведеться додати спеціальний клас Instantiator
і вказати його в анотації @EmbeddableInstatiator
. Це не дуже зручно, і у версії 6.2 така анотація (і окремий клас) більше не потрібні. У нашому проєкті ми використовували Hibernate 6.1.7, тож перейдемо на 6.2.0:
val hibernateVersion = "6.2.0.Final"
Перепишемо Address
як:
@Embeddable
public record Address(@Column(name = "ZIP_CODE", length = 10) String zipCode,
@Column(name = "STREET", length = 32) String street, @Column(name = "HOUSE_NO", length = 16) String houseNo, @Column(name = "APARTMENT", length = 16) String apartment) {
}
Головний side effect
від Java records — перехід від гетерів (getStreet</i>()
) до read accessors (street()
). Тобто вам доведеться переписати код, де вони використовуються. Відносний мінус Java records — потрібно під час створення об’єкта вказувати всі поля, навіть опціональні:
station.setAddress(new Address(dto.getApartment(), dto.getHouseNo(), dto.getStreet(), dto.getZipCode()));
Запускаємо тести, усі тести відбуваються успішно. Ще один тип, який можна змінити — Coordinate
:
@Embeddable
@Setter @NoArgsConstructor @AllArgsConstructor
public class Coordinate {
private double x;
private double y;
на:
@Embeddable
public record Coordinate(@Column(name = "X") double x, @Column(name = "Y") double y) {}
Наступний кандидат у записі — RangeCriteria:
@Getter
public class RangeCriteria {
private final int page;
private final int rowCount;
public RangeCriteria(final int page, final int rowCount) {
Checks.checkParameter(page >= 0, "Incorrect page index:" + page);
Checks.checkParameter(rowCount >= 0, "Incorrect row count:" + rowCount);
this.page = page;
this.rowCount = rowCount;
}
}
Тут все трохи складніше, тому що потрібно перевіряти вхідні параметри в конструкторі, спрощеним записом не обійдешся. Тому доведеться використовувати звичайний конструктор для перевірки:
public record RangeCriteria(int page, int rowCount) {
public RangeCriteria {
Checks.checkParameter(page >= 0, "Incorrect page index:" + page);
Checks.checkParameter(rowCount >= 0, "Incorrect row count:" + rowCount);
}
}
Зверніть увагу на два важливі нюанси:
- Конструктор не має дужок.
- У конструкторі не проводиться привласнення полів (це виконується автоматично).
Ще один претендент на записі — різноманітні DTO, наприклад CreateTripDTO
:
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class CreateTripDTO {
private int routeId;
private LocalDate date;
}
Досить легко переписати як:
public record CreateTripDTO (int routeId, LocalDate date) {}
Тести показують, що Java Records чудово інтегровані і з Jackson, і Spring MVC. Але що, якщо у вас не Jackson для серіалізації, а Gson? Майже три роки йшли розмови про підтримку записів у ньому, і нарешті у версії 2.10.1, яка вийшла в січні цього року, така підтримка з’явилася.
Якщо у вас Spring Boot 3.x проєкт і ви використовуєте Configuration Properties, то можете для mapping застосовувати не тільки класи, але і Java records. У нас для цього є клас RouteDefinition
:
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class RouteDefinition {
private String host;
private int port;
}
який замінимо на:
public record RouteDefinition(String host, int port) {}
Перейдемо до віртуальних потоків. Чи можемо ми їх уже зараз використати? Так, цілком, щоправда поки в ручному виконанні. А ось у версії Spring Boot 3.2, яка скоро вийде, додано нову властивість spring.threads.virtual.enabled
. Якщо його встановити в true, то Spring автоматично перетворить всі ExecutorService/TaskExecutor
з platform-threaded
на virtual-threaded
. Ще один проєкт, який почав їх підтримувати — Tomcat. У 11 версії (яка ще бета) вже є підтримка пула потоків (Executor), заснованого на віртуальних потоках — org.apache.catalina.core.StandardVirtualThreadExecutor
. Також у версії Micronaut 4.x, якщо ви використовуєте JDK19+, можна переключитися з реактивного стека на блокуючий (але на основі віртуальних потоків)
Ще одна фіча, яку давно хотілося спробувати — запечатані типи. У нас є невеликий компонент для генерації випадкових рядків. Для того, щоб ніхто не намагався успадкувати його типи (Composition over inheritance
), оголосимо базовий інтерфейс як sealed та вкажемо після permits ті класи, які можуть його реалізовувати:
public sealed interface NumberGenerator
permits RandomNumberGenerator, SecureRandomNumberGenerator, SequentialNumberGenerator {
int generate();
}
Правда, тепер потрібно змінити кожен клас і вказати там або модифікатор final, sealed або non-sealed:
public final class RandomNumberGenerator implements NumberGenerator {
Так, запечатані типи дозволяють створити ізольовану ієрархію типів без можливості їх успадкування ззовні (а лише використання).
Тепер спробуємо перейти на JDK 21:
java {
sourceCompatibility = org.gradle.api.JavaVersion.VERSION_21
targetCompatibility = org.gradle.api.JavaVersion.VERSION_21
}
Збираємо проєкт і одразу отримуємо помилку:
Fatal error compiling: java.lang.NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field ’com.sun.tools.javac.tree.JCTree qualid’
Ця проблема добре відома і, на щастя, має рішення перейти на останню версію Lombok (1.18.30).
Продовжуємо складання та отримуємо помилку при запуску тестів:
Underlying exception : org.mockito.exceptions.base.MockitoException: Could not modify all classes [interface java.util.function.Function]
at common.infra.util.SecurityUtilTest.getUserId_idExists_success(SecurityUtilTest.java:42)
Caused by: org.mockito.exceptions.base.MockitoException: Could not modify all classes [interface java.util.function.Function]
at common.infra.util.SecurityUtilTest.getUserId_idExists_success(SecurityUtilTest.java:42)
Caused by: java.lang.IllegalStateException:
Byte Buddy could not instrument all classes within the mock’s type hierarchy
Зараз ми послуговуємося Mockito 5.2.0, яке транзитивно використовує ByteBuddy 1.14.1. ByteBuddy не має проблем з підтримкою JDK 21, тому спробуємо просто оновитися до останньої версії Mockito (5.5.0). Продовжуємо збiрку та отримуємо ще одну помилку при запуску тестів у JAX-RS сервісі:
Mockito cannot mock this class: interface persistence.loader.EntityLoader.
Underlying exception : org.mockito.exceptions.base.MockitoException: Could not modify all classes [interface persistence.loader.EntityLoader]
Caused by: org.mockito.exceptions.base.MockitoException: Could not modify all classes [interface persistence.loader.EntityLoader]
Caused by: java.lang.IllegalStateException:
Byte Buddy could not instrument all classes within the mock’s type hierarchy
Ця помилка — копія попередньої, хоч версію Mockito ми оновили. Проблема в тому, що ByteBuddy транзитивно завантажується з Hibernate Core, причому Hibernate 6.2 використовує досить стару його версію. Тому перейдемо на останню версію Hibernate (6.3.1):
val hibernateVersion = "6.3.1.Final"
Тепер оновимо версію JDK у Dockerfile скриптах. Почнемо із Gradle images. Цікава ситуація зі Spring Boot додатками. Ми зараз використовуємо збiрку Eclipse Temurin від Adoptium:
FROM eclipse-temurin:17-jre-alpine
Але досі на Adoptium немає релізу для JDK 21. На їхньому сайті, як і раніше, JDK 17 — це остання випущена LTS. А в розділі нічних релізів останнє збирання для JDK 21 випущено більше місяця тому і позначено як EA (Early Access). Пояснення не дає ясності, коли цю збірку чекати — " We are awaiting access to the new Java 21’s specification tests before formally releasing Temurin 21 «
Чи є альтернативи? Так, є ось список дистрибутивів, які вже підтримують JDK 21:
- Amazon Corretto
- Azul Zulu
- Liberica
Правда, тільки Liberica надає JRE версію для JDK 21. Спробуємо використовувати її як експеримент, тим більше вона добре відома і стабільна:
FROM bellsoft/liberica-openjre-alpine:21
Потім з’являється другий сюрприз. Поточна версія Gradle 8.3 ще не сумісна з JDK 21, ця підтримка буде реалізована тільки у версії 8.4. Якщо запустити збирання на JDK 20:
FROM gradle:8-jdk20-alpine as gradle
то передбачувано отримаємо помилку:
Invalid source release: 21
На щастя, вже є бета-версія Gradle 8.4 RC1, але, на жаль, Gradle Docker image немає «нічних» збірок для releases candidates. Ми вимушені піти іншим шляхом — взяти офіційний Dockerfile для JDK 20 Alpine і змінити в ньому рядки:
FROM eclipse-temurin:20-jdk-alpine
на
FROM bellsoft/liberica-openjdk-alpine:21
та
ENV GRADLE_VERSION 8.3
на
ENV GRADLE_VERSION 8.4-rc-1
Зберемо Docker image для локального використання:
docker build -t gradle:8-jdk21-alpine .
Залишилося тільки оновити Docker images для Tomcat (тут теж чомусь немає JRE-версії для Java 21):
FROM tomcat:jdk21-openjdk-slim
Запускаємо збірку та отримуємо нову помилку — Docker не може знайти на Docker Hub
docker buildx use default
Усі сервіси запускаються та працюють успішно, щоправда в JAX-RS сервісі в логах дивне попередження, яке потребує додаткового дослідження:
WARNING [main] org.glassfish.jersey.server.internal.scanning.AnnotationAcceptingListener$ClassReaderWrapper.accept Unsupported class file major version 65
Висновки
Можна сказати, що загалом усі бібліотеки та фреймворки виявилися готовими до виходу JDK 21, у деяких випадках (Lombok, ByteBuddy) потрібно лише оновитися до останніх версій. А ось хто виявився неготовим, то системи збирання (Gradle) і організації, що випускають JDK-дистрибутиви (Adoptium). Але ще гірше від того, що в них немає якогось розпису і неможливо передбачити, коли вони випустять версії, засновані на JDK 21. Зрозуміло, вам не варто писати власний Dockerfile, як я описав у статті, а просто дочекатися виходу Gradle 8.4, який скоро буде.
Поки писалася ця стаття, 4 жовтня таки вийшла версія Gradle 8.4 (але не Eclipse Temurin 21). І це призвело до цікавої колізії. Gradle 8.4 підтримує JDK 21, але Gradle Docker image заснований на Eclipse Temurin, тому в офіційних images все ще використовується JDK 20, а це означає, що використовувати їх для збирання не можна.
Водночас дуже добре, що ми живемо в часи, коли є не одне джерело JDK-дистрибутивів (як раніше був Oracle JDK) — ми маємо кілька альтернатив, і, наприклад, Liberica вже надає Docker images з усіма останніми змінами.
З нових фіч найбільш затребувані Java Records та pattern matching. Також цікаво спробувати віртуальні потоки, коли вийде Spring Boot 3.2 (або за умови використання Micronaut 4).
6 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів