JDK 19 та нова віртуальна реальність
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник, хочу поділитися з вами інформацією про нову версію Java, JDK 19, яка вийшла 20 вересня на ринок. Я думаю, що багато розробників Java вже пішли з Java 8, яка довго була основною версією і почали поступовий перехід на LTS версії
Номер версії —
Pattern matching for switch
Розробники Java всі намагаються наздогнати поїзд під назвою Kotlin і впроваджувати ту функціональність, яка там була спочатку.
У JDK 16 з’явилася нова фіча — pattern matching for instanceof:
Object obj = "1";
if (obj instanceof String text) {System.out.println(text.length());
}
І все б добре, але коли у вас безліч варіантів, яким класом може бути змінна obj, то буде багато розгалужень if-else, що погано впливає на читабельність. У той же час у JDK 14 з’явилася ще одна фіча — switch expressions:
int count = 10;
String message = switch (count) {case 0 -> "none";
case 1 -> "one";
default -> "many";
};
А чому б їх не поєднати? В результаті з’явилася ще одна фіча — pattern matching for switch, де навіть є можливість вказати null як один з варіантів.
private String getMessage(Object element) { return switch (element) {case null -> "No data";
case Integer i -> i + "items";
case String s -> s;
default -> throw new IllegalArgumentException(element.getClass() + "is not supported");
};
}
У результаті вперше в Java у switch можна вказувати змінну будь-якого типу (а не тільки примітиви, рядки та перерахування, як раніше). JDK дозволяє виявляти деякі помилки у цій функціональності на стадії компіляції. Наприклад, ось такий код не буде компілюватися, тому що гілка з Integer ніколи не буде досягнута (клас Number є базовим для Integer, тому він буде домінантним в цьому випадку):
private String getMessage(Object element) { return switch (element) {case null -> "No data";
case Number n -> n + "items";
case Integer i -> i + "items";
default -> throw new IllegalArgumentException(element.getClass() + "is not supported");
};
}
Правда, Java компілятор не є таким вже могутнім, як ви подумали, тому, як і раніше, не може визначати generic types в run-time:
private String getMessage(Object element) { return switch (element) {case null -> "No data";
case Integer i -> i + "items";
case List<String> l -> l.size() + "items";
default -> throw new IllegalArgumentException(element.getClass() + "is not supported");
};
}
Тобто тут потрібно List<String> поміняти або на List, або на List<?>.
Record patterns
Record patterns — це фіча, якій можна дати й альтернативну назву — deconstructing records. Записи, як і будь-які інші типи даних, можна використовувати разом з pattern matching for instanceof:
record Optional<T>(T item) {} void printItem(Object obj) { if (obj instanceof Optional optional) {System.out.println(optional.item);
} else {System.out.println(obj);
}
}
У JDK 19 ми відразу можемо використовувати поля запису без необхідності додатково оголошувати локальні змінні і робити присвоєння:
void printItem(Object obj) { if (obj instanceof Optional<?>(String item)) {System.out.println(item);
}
}
Цікаво, що якщо запис є generic type, можна використовувати різні типи generics:
void printItem(Object obj) { if (obj instanceof Optional<?>(String item)) {System.out.println(item);
} else if (obj instanceof Optional<?>(Integer item)) {System.out.println(item);
}
}
Причому в цьому випадку не можна використовувати raw types. Тобто такий варіант не компілюватиметься з помилкою: raw deconstruction patterns are not allowed:
void printItem(Object obj) { if (obj instanceof Optional(String item)) {System.out.println(item);
}
}
Тим, хто любить застосовувати записи, безперечну користь принесе підтримка вкладеності в цій фічі. Тобто якщо у вас запис містить інший запис, то це ще більше спростить код:
record Coordinate(int x, int y) {}void printItem(Object obj) { if (obj instanceof Optional<?>(Coordinate(int x,int y))) {System.out.println(x);
}
}
Крім того, новий синтаксис тепер підтримується і в switch expressions:
String getMessage(Object obj) { return switch (obj) {case null -> "None";
case Optional<?>(String item) -> item;
default -> "N/A";
};
}
Компілятор простежить, щоб були вказані всі можливі гілки (або default). У той же час потрібно бути обережним з raw types, оскільки можна заплутати JVM. І такий код відкомпілюється, але при запуску видасть Exception in thread «main» java.lang.MatchException
static String getMessage(Optional<? extends String> optional) { return switch (optional) {case null -> "None";
case Optional<?>(String item) -> item;
};
}
Optional optional = new Optional<>(1);
System.out.println(getMessage(optional));
Віртуальні потоки
Віртуальні потоки (раніше fibers або lightweight threads) — це основне дітище проєкту Loom, яке нарешті потрапило в JDK (нехай і як preview feature). Що вдієш, розробники Java не поспішають виправляти помилки дизайну ранніх версій, зберігаючи стабільність та зворотну сумісність. Можна згадати бібліотеку Java Time, яка з’явилася тільки в Java 8. Або методи stop/destroy у класі Thread, які були видалені лише у Java 11.
Отже, ми давно знаємо асинхронне програмування, засноване на потоках, які можна назвати platform threads, kernel threads чи native threads, оскільки вони створюються нашою ОС. З перших версій стало зрозуміло, що потоки — це дороге задоволення:
- до кожного потоку прикріплюється стек розміром від 512K до одного мегабайта;
- створити і завершити потік займає досить тривалий час (порівняно з виконанням рядових операцій);
- перемикання між потоками (context switching) вимагає від CPU збереження локальних даних і тому не є моментальним.
Тому вже в Java 5 з’явилися пули потоків та ExecutorService для того, щоб потоки можна було перевикористовувати. Але це вирішило проблему тільки частково, тому що не працювало для вебпрограмування. Ідея «один запит на один потік» відразу негативно впливала на масштабованість. І хоча на вашому сервері можна відкрити 50 000 сокетів, створити 50 000 одночасних потоків не вдасться. Як вихід, з’явилося реактивне програмування (проєкти ReactiveX, Reactor), яке допомогло вирішити цю проблему, але воно зажадало застосування нових парадигм і патернів. А що, якщо у вас вже є величезний проєкт, який використовує асинхронний блокуючий підхід? Хочеться покращити ефективність безкоштовно. І ось тут на сцену виходить Loom.
Проєкт Loom стартував у 2014 році, але тільки в останні
Раніше щоб створити новий потік ми використовували клас Thread (явно чи неявно). Взагалі потік — це абстракція, яка передбачає можливість створення різних реалізацій (як Map, List або Queue). На жаль, в Java Thread — це клас, і можна було зустріти поради успадкувати цей клас для запуску потоків.
public class Thread implements Runnable {У Loom вже все серйозніше і є базовий клас BaseVirtualThread, який використовує нову фічу — запечатані типи:
sealed abstract class BaseVirtualThread extends Thread
permits VirtualThread, ThreadBuilders.BoundVirtualThread {І тепер ви вже не можете успадковувати BaseVirtualThread (хоча це абстрактний клас), а це може робити лише VirtualThread:
final class VirtualThread extends BaseVirtualThread {Ще одна істотна відмінність — це те, що вже не вийде створити об’єкт VirtualThread безпосередньо, оскільки конструктор у нього один і він не є публічним. Натомість є новий factory метод, який і запускає новий віртуальний потік:
Thread.startVirtualThread(() -> System.out.println("Hello, world"));Що станеться після запуску цього рядка? Не просто створиться новий об’єкт VirtualThread, а цей об’єкт-потік буде прикріплений до platform thread, який у цьому контексті називається carrier thread. При цьому віртуальний потік може протягом свого життєвого циклу змінювати цей carrier thread. Отримати його значення не можна звичайним способом. Thread.currentThread() завжди поверне сам віртуальний потік. А новий метод currentCarrierThread у класі Thread публічно недоступний:
@IntrinsicCandidate
static native Thread currentCarrierThread();
Правда, не все так сумно, тому в класі Thread метод toString() перевизначено і назву цього потоку все-таки можна дізнатися:
Thread.startVirtualThread(() -> System.out.println(Thread.currentThread()));
Воно йде після символу «@»:
VirtualThread[#41]/runnable@ForkJoinPool-1-worker-1
При цьому виникає питання, а хто буде створювати (і запускати) цей carrier thread? А для цього є спеціальний пул потоків — ForkJoinPool, який таки оголошений у класі Virtual-Thread:
private static final ForkJoinPool DEFAULT_SCHEDULER = createDefaultScheduler();
Чи можна змінити цей планувальник на власний? На жаль, я не знайшов такої можливості в API.
Таким чином, якщо platform threads управляються нашою ОС, але віртуальними потоками управляє сама JVM. І для одного carrier thread може бути безліч віртуальних потоків. Що станеться, якщо у віртуальному потоці викинеться виняток:
Thread.startVirtualThread(() -> {throw new RuntimeException();
});
Stack-trace міститиме інформацію тільки про віртуальний потік, carrier thread буде прихований:
Exception in thread «" java.lang.RuntimeException
at demo.Starter.lambda$main$3(Main.java:25)
at java.base/java.lang.VirtualThread.run(VirtualThread.java:287)
at java.base/java.lang.VirtualThread$VThreadContinuation.lambda$new$0(VirtualThread.java:174)
at java.base/jdk.internal.vm.Continuation.enter0(Continuation.java:327)
at java.base/jdk.internal.vm.Continuation.enter(Continuation.java:320)
При цьому в цьому stack-trace є цікавий тип — Continuation. Хоча ми і передаємо об’єкт Runnable для створення віртуального потоку, як і раніше, але всередині він обертається у спеціальний клас — Continuation (аналог coroutines у Kotlin та processes у Go).
По суті, Continuation (офіційно delimited continuation) — це обгортка над Runnable зі своєю функціональністю:
- Її можна призупинити і відновити
- Підтримується вкладеність
- Для неї створюється спеціальний ContinuationScope
Вона інкапсульована всередині JDK, але якщо зловчитися, можна запустити її безпосередньо:
Continuation cont1 = new Continuation(new ContinuationScope("local"), () -> System.out.println("Hello!"));cont1.run();
Крім того, вона зберігається в класі Thread:
private Continuation cont;
І кожному потоку потрібні дві конструкції для роботи: continuation та планувальник (Execu-torService), який керуватиме віртуальними потоками. І якщо якийсь віртуальний потік встане на паузу, то планувальник призначить інший віртуальний потік для carrier thread.
У Java 19 нарешті здогадалися застосувати шаблон Builder для створення потоків, тому тепер їх можна створити так:
Thread.ofVirtual().name("Virtual thread") .start(() -> System.out.println("Hello, world "));або:
Thread thread = Thread.ofPlatform().name("Platform thread").daemon(false).priority(1) .start(() -> System.out.println("Hello!"));Ми звикли до того, що потоки краще використовувати разом з пулами, оскільки це ресурс. Для віртуальних потоків це не застосовується, так це просто Java об’єкти і їх можна створювати буквально мільйонами. Тому тут пули потоків не потрібні. У яких випадках краще використовувати саме віртуальні потоки?
Якщо у вас багато інтенсивних обчислень, де немає паузи очікування, виграш ефективності, якщо і буде, то несуттєвий. Віртуальні потоки не роблять ваш код швидше, вони дозволяють поліпшити масштабованість, так як тепер ви можете обслуговувати десятки тисяч одночасних запитів, прив’язавши їх до віртуальних потоків. Для перевірки ефективності виконаємо два простих benchmarks. Створимо певну кількість завдань, кожна з яких буде чекати рівно секунду. Завдання будемо віддавати потокам не безпосередньо, а через ExecutorService. Так це буде виглядати для віртуальних потоків:
CountDownLatch latch = new CountDownLatch(amount);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, amount).forEach(i -> { executor.submit(() -> {Thread.sleep(Duration.ofSeconds(1));
latch.countDown();
return i;
});
});
}
latch.await();
Для звичайних потоків використовуватиметься Executors.newCachedThreadPool(). Amount варіюється від 1000 до 150 000. У результаті отримаємо:
Кількість завдань | Віртуальний потік (мс) | Platform thread (мс) |
1000 | 1019 | 1069 |
10000 | 1829 | 2371 |
50000 | 2275 | 8418 |
150000 | 5244 | 18451 |
Чим більше завдань, тим більше відрив у продуктивності у віртуальних потоків.
Існуючі обмеження:
- Віртуальні потоки завжди є daemon.
- Неможливо змінити їхній пріоритет.
- Усі вони належать до спеціальної групи потоків «VirtualThread».
- Методи stop/suspend/resume у них не реалізовані (при виклику отримаєте Unsup-portedOperationException).
- ThreadLocal об’єкти у віртуальних потоках не доступні carrier потоків і навпаки.
Але що, якщо ми створимо віртуальний потік у межах іншого віртуального потоку?
Thread.startVirtualThread(() -> { ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 20); threadLocal.set(10); Thread.startVirtualThread(() -> System.out.println(threadLocal.get()));});
У цьому випадку код виведе «20» (початкове значення), і це правильна поведінка, тому що ThreadLocal залежить від методу Thread.currentThread(), а він для кожного віртуального потоку повертає себе. Таким чином, навіть якщо два віртуальні потоки поділяють один і той же carrier thread, все одно не можна використовувати ThreadLocal для обміну інформацією мiж ними.
Крім того, є два сценарії, при яких віртуальний потік може заблокувати основний потік (і перейти в статус pinned):
- Коли він виконується всередині synchronized блоку або методу.
- Коли він виконує природний метод або foreign function.
Тому якщо ваш код використовує віртуальні потоки, то рекомендується замінити syn-chronized на ReentrantLock, наприклад.
Зміни в API
З тих змін, які особливо не афішувалися, можна відзначити суттєві додавання в знайомий всім інтерфейс Future (вперше з Java 5).
Якщо раніше ми оперували лише його методами get() та isDone():
Future<Integer> future = Executors.newFixedThreadPool(1).submit(() -> 1);
System.out.println(future.get());
То тепер у Future з’явився стан, який може набувати чотирьох значень:
String status = switch(future.state()) {case RUNNING -> "In progress";
case CANCELLED -> "Cancelled";
case FAILED -> "Error";
case SUCCESS -> "Completed";
};
Швидше за все, це було продиктовано новими фітчами в останніх версіях Java, пов’язаних із оператором switch. Крім того, було додано метод resultNow, який працює приблизно так само, як метод get(), але з двома відмінностями:
- Він викидає тільки unchecked виключення
- Він призначений тільки для завершених завдань, для інших він просто викине виключення
Це дозволяє його широко використовувати в Streams API, наприклад.
List<Integer> list = futures.stream().filter(Future::isDone).map(Future::resultNow).toList();
Висновки
JDK 19 — це серйозний крок у розвитку Java як мови та платформи. Ті фічі, які з’явилися, мають статус preview. Тому наше завдання, як розробників, спробувати їх та дати свій фідбек у плані зручності та простоти використання API. Віртуальні потоки — це зовсім нова функціональність, до якої потрібно звикнути і чекати підтримки з боку Java фреймворків. Багато офіційної інформації щодо них є тут.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів