Миграция към Java 21: Ръководство за корпоративни екипи
Как мигрирахме Olympus Mobility от Java 17 към 21: виртуални нишки, клопката със закотвянето, pattern matching и поетапен план за миграция.
Защо да мигрирате към Java 21?
Java 21 е версия с дългосрочна поддръжка (LTS) и носи възможности, които корпоративните екипи трудно могат да подминат. От виртуалните нишки, които значително опростяват паралелната обработка, до pattern matching, който прави кода по-изразителен - Java 21 е най-голямата стъпка напред след Java 8.
В Dinix Software наскоро завършихме пълна миграция на платформата Olympus Mobility - система, обслужваща над 500 000 потребители - от Java 17 към Java 21. Ето какво научихме.
Виртуалните нишки: истинският пробив
Виртуалните нишки (Project Loom, JEP 444) са може би най-значимата промяна в Java 21. С тях можете да създавате милиони леки нишки без режийните разходи на класическите платформени нишки. За приложения, ограничени от вход-изход - а такива са повечето корпоративни бекенд системи - това означава чувствително по-висока пропускателна способност при по-прост код.
Важното е, че моделът на програмиране остава същият. Продължавате да пишете обикновен блокиращ код, а средата за изпълнение освобождава носещата нишка, докато виртуалната чака:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var providerId : providerIds) {
executor.submit(() -> client.fetchAvailability(providerId));
}
}
В Spring Boot 3.2 и по-нови версии целият уеб слой се прехвърля към виртуални нишки с едно-единствено свойство:
spring.threads.virtual.enabled=true
При миграцията на Olympus Mobility преминаването към виртуални нишки за нашите REST API handler-и доведе до трикратно подобрение в обработката на едновременни заявки, без каквито и да е промени в архитектурата.
Проблемът със закотвянето, за който никой не предупреждава
Виртуалните нишки се създават евтино, но блокирането им не е безплатно. Когато виртуална нишка блокира в synchronized блок, тя закотвя (pinning) носещата платформена нишка и планировчикът не може да я преизползва, докато трае изчакването. Закотвят ли се достатъчно носещи нишки, пропускателната способност пада до нивото на класическите нишки.
Решението е механично: навсякъде, където се извършва вход-изход, заменете synchronized с ReentrantLock.
private final ReentrantLock lock = new ReentrantLock();
void recordTrip(Trip trip) {
lock.lock();
try {
repository.save(trip); // блокиращ вход-изход, нишката може да се освободи
} finally {
lock.unlock();
}
}
Пускайте натоварващите тестове с -Djdk.tracePinnedThreads=full, за да откриете проблемните места, преди да го направи продукцията. По-новите версии на JDK намалиха този проблем значително, но в 21 той е напълно реален и трябва да го заложите в дизайна.
Pattern matching, record patterns и sealed класове
Pattern matching за switch (JEP 441) и record patterns (JEP 440) заедно правят възможен по-функционален стил на писане в Java. Оказаха се особено полезни в домейн логиката ни, където сложните бизнес правила станаха осезаемо по-четими.
sealed interface FareEvent permits TripStarted, TripEnded, TripCancelled {}
String describe(FareEvent event) {
return switch (event) {
case TripStarted(var id, var at) -> "trip %s started at %s".formatted(id, at);
case TripEnded(var id, var cost) -> "trip %s cost %s".formatted(id, cost);
case TripCancelled c when c.refunded() -> "refunded cancellation";
case TripCancelled c -> "cancellation, no refund";
};
}
Компилаторът следи за изчерпателност. Добавите ли нов разрешен подтип, всеки switch, който не го обработва, спира да се компилира - така цял клас грешки от runtime се превръщат в грешки при компилация.
Sequenced Collections
По-дребна промяна с осезаема полза в ежедневието: SequencedCollection (JEP 431) най-после дава на подредените колекции общ начин да работят с двата си края.
var first = trips.getFirst();
var last = trips.getLast();
var newestFirst = trips.reversed();
Край на list.get(list.size() - 1) и на факта, че при LinkedHashSet изобщо нямаше как да поискате последния елемент.
Generational ZGC
Generational ZGC (JEP 439) събира младите обекти отделно, а точно такъв е профилът на типичното уеб натоварване, където повечето обекти живеят колкото една заявка. Ако работите с големи heap-ове и държите на забавянето при най-бавните заявки, струва си да замерите с:
-XX:+UseZGC -XX:+ZGenerational
Мерете преди и след, при вашия собствен трафик. Изборът на garbage collector зависи от натоварването и G1 по подразбиране остава напълно разумен избор за много услуги.
Стратегия за миграция: поетапният подход
Препоръчваме поетапна миграция:
- Обновете първо build инструментите (Maven/Gradle) за поддръжка на Java 21
- Пуснете съществуващия набор от тестове - по-голямата част от кода ще работи без промени
- Отстранете употребите на остарели API-та, които компилаторът маркира
- Постепенно въвеждайте новите възможности в новия код
- Рефакторирайте съществуващия код там, където новите възможности носят яснота
Направете стъпки 1-3 в един pull request, което променя поведението възможно най-малко. Ако обновяването на средата пътува отделно от въвеждането на новите езикови възможности, при регресия в продукцията веднага знаете кое от двете я е причинило.
Често срещани клопки
Внимавайте за следното по време на миграцията:
- Някои библиотеки на трети страни още може да не поддържат Java 21 - проверете съвместимостта отрано
- Виртуалните нишки се държат различно със synchronized блокове - за операции с вход-изход предпочитайте ReentrantLock
- Ако използвате рефлексия интензивно, прегледайте новите ограничения за достъп до модулите
- Всичко, което рови във вътрешностите на JDK - по-стари версии на Lombok, Mockito, bytecode агенти, някои APM колектори - се чупи първо, затова ги обновете преди самата среда
- Пуловете от нишки, оразмерени за платформени нишки, вече вредят: фиксиран пул от 200 сега е таван от 200, а не предпазна мярка
Ами Java 25?
Java 25 е по-новата LTS версия и за проект от нулата изборът е очевиден. При заварена система сметката е друга: зад Java 21 стоят години поддръжка от библиотеките, всички основни фреймуърци стъпват на нея, а пътят от 17 към 21 е добре утъпкан. Преминаването 17 → 21 сега и 21 → 25 по-късно обикновено носи по-малко риск от един голям скок, защото на всяка стъпка зависимостите ви остават на версии, които други вече са пуснали в продукция.
Заключение
Миграцията към Java 21 си заслужава инвестицията. Само подобренията в производителността от виртуалните нишки оправдават усилието, а по-доброто изживяване при разработка с pattern matching и sealed класове прави кодовата ви база по-поддържана за години напред.
Ако обмисляте обновяване на JDK в по-голяма система, екипите ни за разработка на софтуер и IT консултации вършат точно такава работа по миграции.
