Міграція застосунків на Java 22. Частина перша

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник. Сьогодні хочу поділитися з вами своїм досвідом міграції проєктів з JDK 21 на JDK 22. Ця версія вийшла кілька місяців тому, але ми не могли відразу перейти на неї через несумісності з нею окремих технологій. Зараз усі проблеми вирішені, тому така міграція стала можливою, і я хочу у цій статті докладно, з детальними прикладами розповісти про те, як і для чого ми проводили міграцію.

Я вже писав про нові фічі в Java 21, тому в цій статті пропущу те, що не змінилося за ці пів року. Сподіваюся, що ця стаття буде корисна для всіх, хто хоче більше дізнатися про нові фічі в Java 22, розібратися в тому, як провести таку міграцію і яка від цього практична користь.

Що нового в Java 22

У березні 2024 року вийшла Java 22, але ми в нашому проєкті довгий час не могли на неї перейти, оскільки поточна на той момент версія Gradle (8.7) не дозволяла запускати збірку на Java 22, тому що в нашому проєкті все ще є Groovy-скрипти.

Якби у нас були тільки Kotlin-скрипти, то це не було б проблемою. Але недавно вийшла Gradle 8.8, так що така міграція стала можливою. Але перед нею я б хотів розповісти про те, що ж цікавого увійшло в нову версію Java.

Ми не будемо розглядати інфраструктурні зміни, візьмемо тільки синтаксис та зміни в API. Як завжди частина змін прийшла в режимі ознайомлення (preview):

  • Statements before super();
  • Class-file API;
  • String templates;
  • Vector API;
  • Stream Gatherers;
  • Structured concurrency;
  • Scoped values;
  • Implicitly declared classes and instance main methods.

Список досить значний. Але є й хороші новини в тому, що частина фіч (та їх API) таки оголошена стабільними:

  • Foreign functions and memory API;
  • безіменні змінні та патерни.

Їх таки недостатньо, щоб через них рішуче переходити на Java 22, але заради інтересу спробуємо з ними познайомитися. Безіменні змінні та патерни з’явилися ще в JDK 21, в поточній версії вони були просто фіналізовані без будь-яких змін у специфікації, так що особливо тут говорити нема про що.

Foreign functions and memory API

Foreign functions and memory API — довгоочікувана фіча, яка з’явилася ще в JDK 19, досить довго стверджувалася, і в ній слово foreign означає не іноземний, а зовнішній. Цю фічу можна розділити на два підкомпоненти:

  1. Foreign functions — заміна JNI більш сучасним та зручним API.
  2. Foreign memory — заміна типу sun.misc.Unsafe (on-heap memory), який спочатку був створений для внутрішнього використання, але потім почав масово застосовуватися різними Java-технологіями для прямого доступу до пам’яті та ByteBuffer (off-heap memory).

Ось, наприклад, код, який виділяє 100 байтів в off-heap-пам’яті, а потім записує туди деякі дані:

try (Arena offHeap = Arena.ofConfined()) {
    MemorySegment data = offHeap.allocate(100);
    data.set(ValueLayout.JAVA_INT, 0, 20);
}

Тут використовується новий інтерфейс з незвичним називанням Arena, який може бути різних типів (global, shared або confined). Для confined-арени виділена пам’ять доступна лише у поточному чи батьківському потоці.

super та виклик базового конструктора

Тепер перейдемо до preview-фіч. Почнемо зі зміни щодо виклику базового конструктора. У багатьох мовах програмування (включно з Java) якщо для вашого класу є базовий конструктор з параметрами, то ви обов’язково повинні його викликати в дочірньому класі, і це обов’язково має бути першим рядком у конструкторі:

@RequiredArgsConstructor
class Employee {
    
    private final String name;
}
class Manager extends Employee {
    
    private final List<Employee> employees;
    public Manager(String name, List<Employee> employees) {
        super(name);
        this.employees = employees;
    }
}

Це викликає певні незручності. Наприклад, якщо вам потрібно вставити код налагодження, то раніше ви могли це зробити тільки після виклику super. Тепер можна і перед:

@Slf4j
class Manager extends Employee {
    private final List<Employee> employees;
    public Manager(String name, List<Employee> employees) {
        log.debug("Name: {} and employees: {}", name, employees);  
        super(name);
        this.employees = employees;
    }
}

Ще одна часта ситуація — валідація вхідних аргументів. Раніше для валідації доводилося вставляти перевірки прямо у виклику super:

public Manager(String name, List<Employee> employees) {
    super(Objects.requireNonNull(name));
    this.employees = employees;
}

Але таким чином не можна перевірити employees на null, доводиться це робити після виклику базового конструктора:

public Manager(String name, List<Employee> employees) {
    super(Objects.requireNonNull(name));
    Objects.requireNonNull(employees);
    this.employees = employees;
}

Але якщо у вас є невалідне значення аргументу, навіщо викликати базовий конструктор? Тепер код читається краще:

public Manager(String name, List<Employee> employees) {
    Objects.requireNonNull(name);
    Objects.requireNonNull(employees);
    super(name);
    this.employees = employees;
}

Але нова фіча не знаменує епоху вседозволеності, і є певні обмеження на її використання. Ви не можете додати перед super() будь-який код, де буде явно (або неявно) звернення через this, тобто до instance-полів і методів поточного класу. Цей код не компілюватиметься:

public Manager(String name, List<Employee> employees) {
    Objects.requireNonNull(name);
    this.employees = Objects.requireNonNull(employees);
    super(name);

Перейдемо до String templates, які все ще знаходяться в режимі ознайомлення, але в поточній версії істотних змін до них не було внесено, так що я і тут не повторюватимуся.

Implicitly Declared Classes and Instance Main Methods

Implicitly Declared Classes and Instance Main Methods дозволяють простіше писати код для запуску застосунків:

void main() {
    System.out.println("Hello, world");
}

Не потрібно явно оголошувати клас (він буде створений автоматично з ім’ям Java-файлу). Тому такі класи слід називати не безіменними, а неявними. У поточній версії було змінено порядок пошуку main()-методів:

  1. Спочатку шукається метод main з аргументом типу String[].
  2. Якщо такого немає, то шукається метод main без аргументів.

Такі класи мають одне обмеження — ви можете їх створювати тільки в пакеті top-level (верхнього рівня). Наскільки корисна така фіча? Якщо у вас застосунок Spring Boot, то неявний клас не вдасться використати:

void main() {
    SpringApplication.run(Starter.class);
}

Вам видасться помилка: Implicitly declared class ’Starter’ cannot be referenced. Крім того, застосунок Spring Boot вимагає анотації @SpringBootApplication, яку потрібно десь помістити. Тому можна лише спростити лише main-метод:

@SpringBootApplication
public class Starter {
    void main() {
        SpringApplication.run(Starter.class);
    }
}

Stream Gatherers

Stream Gatherers — це дуже цікава фіча, оскільки серйозних змін у Streams API не було після його створення у 2014 році. Спочатку в Streams API було два типи операцій:

  • проміжні;
  • термінальні.

Термінальна операція запускала виконання проміжних операцій та закінчувала роботу всього потоку (і закривала його). Завдяки гнучкості термінальної операції collect() ви могли писати власні колектори, розширюючи функціональність Streams API.

На жаль, набір проміжних операцій фіксований у самому інтерфейсі Stream і не підлягає розширенню. Це створювало певні незручності, які має вирішити новий API. В основі його лежить новий інтерфейс Gatherer:

public interface Gatherer<T, A, R> {
    default Supplier<A> initializer() {
        return defaultInitializer();
    };
    Integrator<A, T, R> integrator();
    default BinaryOperator<A> combiner() {
        return defaultCombiner();
    }
    default BiConsumer<A, Downstream<? super R>> finisher() {
        return defaultFinisher();
    }

Тут основними є чотири методи, з яких три є опціональними та вже реалізованими:

  1. Initializer — створює проміжний стан для наступних операцій.
  2. Integrator — використовує проміжний стан для об’єднання з поточними елементами.
  3. Combiner — поєднує два проміжні стани в один.
  4. Finisher — викликається, коли всі елементи Stream оброблені.

Фактично, Gatherer є досконалішим колектором, але, на відміну від нього, не є термінальною операцією. Крім того, реалізація Gather може бути послідовною або паралельною, а також stateless або stateful. Щоб розробникам було простіше розібратися в новому API, JDK 22 додали його п’ять реалізацій:

  • fold;
  • mapConcurrent;
  • scan;
  • windowFixed;
  • windowSliding.

Розберемо декілька прикладів. Припустимо, у вас є список чисел. Ви хочете розбити його на списки, у кожному з яких буде не більше трьох елементів. Для цього підійде метод windowFixed:

List<Integer> items = List.of(1, 4, 7, 9, 13);
List<List<Integer>> results = items.stream().gather(Gatherers.windowFixed(3)).toList();

Тут ми використали новий метод gather та передали чинний gatherer Gatherers.windowFixed. Оскільки gather — це проміжна операція, то ми могли використовувати необмежену кількість таких методів перед викликом toList().

Розберемо складніший приклад. Тепер у нас є список значень дати/часу. Для простоти приймемо, що список відсортований за зростанням дати/часу. Нам потрібно розбити цей список на підсписки: так, щоб кожен підсписок містив дані з одного дня, і кожен з підсписків був послідовним за часом до попереднього. Тут уже вбудованою реалізацією не обійтись, треба писати свою:

class DayGatherer implements Gatherer<LocalDateTime, List<LocalDateTime>, List<LocalDateTime>> {
    @Override
    public Supplier<List<LocalDateTime>> initializer() {
        return ArrayList::new;
    }

Метод initializer повертає Supplier, який створюватиме проміжний стан — об’єкт ArrayList, де будуть об’єкти LocalDateTime для поточного дня. В принципі, його можна назвати й акумулятором:

@Override
public Integrator<List<LocalDateTime>, LocalDateTime, List<LocalDateTime>> integrator() {
    return ((state, element, downstream) -> {
        boolean newDay = !state.isEmpty() && !state.getLast().toLocalDate().equals(element.toLocalDate());
        if (!newDay) {
            state.add(element);
            return true;
        } else {
            var oldState = List.copyOf(state);
            state.clear();
            state.add(element);
            return downstream.push(oldState);
        }
    });
}

Головний код знаходиться в integrator(). Він повертає функціональний інтерфейс Integrator з методом integrate, який приймає поточний стан, поточний елемент і спеціальний об’єкт Downstream. Downstream дозволяє зберігати згенеровані елементи для подальшої конвертації в stream. А сам метод повертає true (якщо ви можете й надалі приймати елементи для обробки) або false (якщо не можете).

У цій реалізації ми перевіряємо, чи поточний список порожній. Якщо порожній, то додаємо поточний об’єкт-дату. Якщо не порожній, то порівнюємо дату поточного елемента та останнього елемента у списку. Якщо дати збігаються, то додаємо дату до списку. Інакше створюємо копію списку та відправляємо для зберігання через Downstream. Це чимось нагадує операцію flush. Потім очищаємо поточний список.

Але що, якщо елементи вже закінчилися, а поточний список не порожній? Ось тут потрібен метод finisher:

@Override
public BiConsumer<List<LocalDateTime>, Downstream<? super List<LocalDateTime>>> finisher() {
    return (window, downstream) -> {
        if(!downstream.isRejecting() && !window.isEmpty()) {
            downstream.push(List.copyOf(window));
        }
    };
}

Він перевіряє, чи є елементи у списку (проміжному стані), і якщо є, то теж відправляє їх через Downstream. А ось так виглядає використання нашого нового класу:

List<LocalDateTime> dates = List.of(LocalDateTime.of(2024, 1, 1, 10, 0),
        LocalDateTime.of(2024, 1, 1, 17, 10),
        LocalDateTime.of(2024, 1, 1, 19, 12),
        LocalDateTime.of(2024, 1, 2, 9, 27),
        LocalDateTime.of(2024, 1, 5, 11, 17));
List<List<LocalDateTime>> results = dates.stream().gather(new DayGatherer()).toList();

Якби у нас не було Gatherers API, то ми використовували б більш компактний код:

List<List<LocalDateTime>> results = dates.stream().collect(Collectors.groupingBy(LocalDateTime::toLocalDate))
        .values().stream().sorted(Comparator.comparing(List::getFirst)).toList();

Але такий підхід має три мінуси:

  1. Ми тут двічі конвертуємо колекцію у stream.
  2. Такий код складніше перевикористовувати (на відміну від DayGatherer).
  3. Stream Gatherers дозволяють продовжувати виконання наступних проміжних операцій, на відміну від цього підходу, коли викликаємо термінальну операцію та отримуємо колекцію.

Наскільки відрізняється продуктивність із використанням Stream Gatherers? Про це я розповім у наступній частині цієї статті.

Висновки

На жаль, більшість фіч, які були в режимі ознайомлення в Java 21, залишилися такими і в Java 22. Тому в новій версії вам доступні тільки дві стабільні фічі — foreign functions and memory API та безіменні змінні та патерни.

З іншого боку, з’явилися досить корисні фічі, що стосуються виклику базового конструктора та Stream Gatherers, які дозволяють писати більш функціональний і читальний код при складній трансформації даних у Streams API. Зачекаємо уніфікації цих специфікацій та перехід їх у фіналізований стан.

У наступній частині статті я проведу навантажувальне тестування нової функціональності та розповім про те, як ми проводили міграцію своїх проєктів на Java 22.

👍ПодобаєтьсяСподобалось8
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Foreign functions and memory API — довгоочікувана фіта
Тепер перейдемо до preview-фіч

Так фіча, чи фіта, я заплутався.

В Java з 10 — ї версії можна робити, замість такого

List< List> results = items.stream().gather(Gatherers.windowFixed(3)).toList();

От таке

var results = items.stream().gather(Gatherers.windowFixed(3)).toList();

Якщо у ви використовуєте lombok (у вас він є разом з @Slf4j ) то є і val як в Kotlin, що є еквівалентом final List або final var.
projectlombok.org/features/val
На практиці такий код читати значно зручніше, він значно коротший. С++ зробили те саме з ключовим словом auto.
В Java 21 більше цікавих фітч www.baeldung.com/java-lts-21-new-features
У мене враження, що мову підтягують за Kotlin. Модернова Java 9+ дуже схожа саме на Kotlin. Хоча має низку більш цікавого синтаксису, зокрема try with resources, try mutlicatch
В 22-й Java крім зазначених змін, є ще заміна CG lib — вбудований Class-File API, це має прискорити усі runtime аспектні фреймверки як то Spring Core драматично.
String Templates, тепер можна робити

val a = 100L;
System.out.println("Perl style ${a}");

Scoped Values openjdk.org/jeps/464 що є покращеним ThreadLocal який працює також з файберами, а не тільки повноцінними потоками.
Також додано Region Pinning for the G1 — покращення у використанні пам’яті, дуже суттєве.

Structures concurrency

Описка — мало бути Structured

Підписатись на коментарі