Оптимізуємо Java-додатки за допомогою GraalVM

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

Всім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник, хочу поділитися з вами своїм досвідом роботи з такою технологією як GraalVM. Я познайомився з нею два роки тому, коли вона тільки почала виходити на ринок. І зараз вже набралося достатньо матеріалу для окремої статті. Крім того, на деяких тренінгах ми вже вивчаємо цю технологію, а нещодавно я провів по ній воркшоп. Тому в цій статті я наведу прості і не дуже прості приклади, щоб наочно показати переваги та особливості GraalVM.

Я не буду дуже докладно розповідати теорію та історію GraalVM, тим більше що у мене вже була стаття на цю тему. Намагаюся більше уваги приділити практиці, і, зокрема, оптимізації та підвищення ефективності. Оптимізацію додатків можна умовно розділити на дві категорії: те, що робите ви і те, що роблять за вас. Перша частина більш трудомістка, вимагає хорошого технічного рівня у розробника і, в основному, стосується підвищення ефективності коду і зменшення обсягу пам’яті. Крім того, вона вимагає тестування (performance/load/stress) для того, щоб перевірити, що оптимізація дійсно принесла користь проєкту.

Друга категорія оптимізації здійснюється автоматично, наприклад, Java-компілятором або JIT-компілятором в JVM. Крім того, є велика кількість опцiй, які дозволяють контролювати цей процес. Якщо взяти такий код:

       private static final int CONNECTION_TIMEOUT = 10;      
       private static final int READ_TIMEOUT = 2 * CONNECTION_TIMEOUT;

і виконати компіляцію нашого проєкту, а потім зробити дизасемблювання, то виявимо, що Java компілятор виконав відразу дві оптимізації:

1) Обчислив значення READ_TIMEOUT, оскільки воно насправді є constant expression, як і CONNECTION_TIMEOUT.

2) Замінив усі використання наших констант на їх значення, оскільки значення константи не може змінитися під час роботи JVM.

Якщо взяти runtime оптимізацію в JVM, це, наприклад, відома опцiя -XX:+UseCompressedOops, який з’явився в JDK 6 і дозволяв використовувати стислі вказівники (oops), оскільки покажчики в JVM завжди вирівнюються на межі слова, і значить, останні три біти на адресу можна заощадити. Тести показували, що з увімкненим прапором програми використовували на 14% менше пам’яті та 24% менше викликів GC. А в JDK 7 ця опція включена за замовчуванням.

З нових прикладів — це опція «Compact Strings», яка з’явилася в JDK 9 і дозволяла зберігати символи в рядку (клас String) як масив байт:

@Stable
private final byte[] value; 

Якщо в рядку коду всі символи не перевищують байт, то нам нема чого використовувати для них кодування UTF16, а значить ми можемо зменшити витрати пам’яті в 2 рази (оскільки раніше value було масивом char і на кожен символ припадало 2 байти). В цілому, як ми бачимо, автоматична оптимізація дуже зручна, тому що дістається нам безкоштовно, добре продумана, протестована і може бути відключена в окремих випадках, коли вона не потрібна або працює не так, як нам потрібно.

Про GraalVM

Тепер повернемося до GraalVM. GraalVM — це нова віртуальна машина, написана на Java, розробка якої розпочалася у 2019 році. Її killer-фітча — це interoperability, коли ви можете в одному проєкті використати відразу кілька мов (Java, JavaScript, Python). Але ви можете використовувати його і тільки для Java-проєкту, сподіваючись на покращену продуктивність, заявлену авторами. Вже є синтетичні benchmarks, в яких лідирує GraalVM Enterprise Edition, правда в них використовувалися версії Java 8 і 11. Давайте як експеримент протестуємо GraalVM різних видань і OpenJDK, але 17-ї (поточної стабільної LTS) версії. Відмінність Enterprise від Community Edition щодо швидкодії в тому, що в першому додано більше можливостей для оптимізації, таких як покращена векторизація в циклах, path duplication та багато іншого. Але вона безкоштовна тільки для розробки та вивчення, для комерційного використання потрібно купити ліцензію. Крім того, тільки в Enterprise Edition доступний збирач сміття G1, а в Community редакції — є тільки Serial GC.

Як тестований код візьмемо метод, який заповнює Map цілими числами:

public static Map<Integer, Integer> fillMap(int size) {
       return IntStream.range(0, size).boxed()
              .collect(Collectors.toMap(i -> i, i -> i));
}

Такий код обраний тому, що досить простий і використовує найпопулярніші Java-фічі — колекції та Stream API. Benchmark також має досить стандартний вигляд. Єдине що — я додав параметризацію, вказавши за допомогою анотації @Param кілька значень аргументів:

@Param({"1", "100", "10000"})
private int size;   
@Benchmark
public Map<Integer, Integer> fillMapTest() {
     return CollectionUtils.fillMap(size);
}

Конфігурація для benchmarks:

  • JMH 1.33
  • Intel Core i9, 8 cores
  • 32 GB
  • 10 ітерацій обчислення, 10 ітерацій прогріву (warm-up)

Результати обчислень (наносекунди/операцію):


# елементів


Oracle JDK 17.0.2


GraalVM Community 22.0.0.2


GraalVM Enterprise 22.0.0.2


1


69


73


11


100


1249


2140


1157


10000


112636


183323


121250

Як ми бачимо, GraalVM Community Edition програє Oracle JDK від 5% до 70%, і чим більше елементів, тим більше програш. Продуктивність в GraalVM Enterprise Edition теж падає зі збільшенням кількості елементів, але в перших двох тестах він випереджає Oracle JDK, причому в першому тесті в 7 (!) разів. Ці benchmarks підтверджують, що GraalVM дійсно може працювати швидше за своїх конкурентів. Але за це перевагу доводиться платити (у прямому сенсі).

Про утиліту Native Image

Тепер я хочу розповісти про ще одну killer-фічу GraalVM — утиліту Native Image. Ми з вами звикли до слогану «Write once, run anywhere», про який обов’язково розповідають на вступній лекції або семінарі з Java. У 1995 році, коли все тільки починалося, це було дуже сильним аргументом на користь вибору Java, адже дозволяло скомпілювати проєкт в проміжний байт-код, а потім запустити на будь-якій ОС, для якої випущена JRE. Але з того часу минуло 27 років і кілька разів змінювалася парадигма Java-додатків:

  • Спочатку ми збирали проєкти як WAR-файли та деплоїли на вебсервер (або сервер додатків).
  • Потім, з появою Spring Boot, стало можливо упаковувати додаток як JAR-файл і запускати її як консольний.
  • Після появи Docker додатки (і сервіси) стали поставлятися у вигляді images, що дозволило вирішити купу проблем з безпекою, конфігурацією та ізоляцією від інших додатків — ідеальний варіант для використання для мікросервісів.

Але тут не все було гладко. Мікросервіси повинні бути швидкими і компактними, а результуючий Docker image був досить громіздким. Наприклад, якщо ми візьмемо все, що потрібно для запуску мінімального Spring Boot програми на Ubuntu:

  • Spring Boot залежності — 15 Мб.
  • JDK 11 — 250 Мб.
  • Ubuntu (tools) — 35 Мб.
  • Ubuntu (basic layer) — 60 Мб.

то матимемо мінімальний розмір image 360 Мб. В останній рік ситуація дещо покращилася:

1) У JDK 16 зробили портування з бібліотеки zlibc на musl, що дозволило запускати Java-додатки на Alpine Linux (а це всього 4 Мб), а не на Ubuntu.

2) У JDK 9 з’явилася утиліта jlink, яка може викинути з JDK модулі, які не використовуються вашим додатком, по суті, створивши аналог JRE. Але вона працює не зовсім стабільно та її використання не так просто автоматизувати.

Крім того, з появою Docker images, слоган «Write once, run anywhere» потьмянів, тому що тепер ми точно знаємо, на якій ОС запускатимемо додаток і кросплатформність нам більше не потрібна. А це означає, що ми можемо скомпілювати наш додаток в AOT (ahead-of-time) режимі, створивши бінарний файл з машинними інструкціями і забувши про байт-код. При цьому нам, розробникам, дуже важливо, щоб:

1) Нативний додаток (або native image) працював так само, як і у варіанті з байт-кодом.

2) Компіляція в нативний код була максимально швидкою та стабільною (відсутність помилок).

3) Підтримувалися фічі, які роблять життя девелопера приємним (налагодження, логування, тестування та багато іншого).

Для прикладу візьмемо найпростіший Java SE додаток і спробуємо крок за кроком оптимізувати:

public class HelloWorld {
       public static void main(String[] args) {
              System.out.println("Hello, IT-Discovery!");
       }
}

Якщо ми захочемо його упакувати в Docker image, то Dockerfile буде приблизно таким:

FROM openjdk:17-alpine
 COPY src/main/java/HelloWorld.java /sources/
WORKDIR /sources
RUN javac HelloWorld.java && rm HelloWorld.java
 CMD java HelloWorld

Розмір Docker image — 326 Мб. На жаль, ми не можемо використовувати JRE для запуску програми, так як з 12 версії Java вони офіційно не випускаються. Як створити native image? Для цього необхідно виконати 4 етапи:

1) Завантажити GraalVM.

2) Встановити утиліту native-image за допомогою інсталятора gu, оскільки вона не входить до стандартного дистрибутиву.

3) Скомпілювати наш додаток Java компілятором.

4) Запустити native-image і отримати бінарний виконуваний файл для необхідної ОС.

Найпростіше це описати в новому Dockerfile, де ми використовуємо multi-stage build:

FROM ghcr.io/graalvm/graalvm-ce:java17-21.3.0 as graalvm
 RUN gu install native-image
 COPY src/main/java/HelloWorld.java /sources/
WORKDIR /sources
RUN javac HelloWorld.java
 RUN native-image HelloWorld
 FROM ubuntu
 COPY --from=graalvm /sources/helloworld /helloworld
 ENTRYPOINT ["./helloworld"]

Перша стадія (на базі GraalVM Community Edition) для збирання, друга стадія (на базі Ubuntu) для запуску. Нажаль, Docker images для Enterprise Edition немає. Нам, як і раніше, потрібно використовувати Ubuntu, тому що там містяться необхідні системні бібліотеки для комунікації з ОС. Час компіляції — 22 секунди. Розмір Docker Image — 88 Мб (у 3,5 рази менше). Яким чином досягається такий виграш?

Секрет у тому, що в згенерованому виконуваному файлі є все: і ваш додаток, і всі пов’язані залежності, і навіть JDK, але при цьому включається процес tree-shaking, коли видаляються ВСІ класи, що не використовуються. Такий процес дуже популярний у світі фронтенду, тепер він доступний і нам.

Але як же бути з магічними фічами, наприклад, динамічне завантаження класів або Reflection API? Вони просто не працюватимуть, тому що байт-коду класів більше немає. У native image всі класи, весь код завантажуються одразу. Але тут є нюанси. Якщо ви напишете такий код:

       System.out.println(String.class.getInterfaces().length);

то він буде працювати і виведе «5», тому що Native image досить інтелектуальний, щоб виконати цей код на стадії збiрки і замінити його на 5. А ось такий код вже працювати не буде:

       System.out.println(String.class.getDeclaredMethods().length);

Але тут можна знайти вихід. Ми звикли до того, що наш код виконується на стадії завантаження програми. Але є досить інноваційні технології (Dagger, Micronaut), де частина роботи виконується під час компіляції програми. Це дозволяє як прискорити старт програми, так і відмовитися від магічних фіч, таких як Reflection, AOP та багатьох інших.

У GraalVM Native image до 19-ї версії вся статична ініціалізація виконувалася при складанні. Але іноді це призводило до небажаних ефектів і тому з 19 версії така ініціалізація виконується в run-time. Але це можна налаштувати. Якщо ми напишемо такий код:

       private static int length = String.class.getDeclaredMethods().length;      
       public static void main(String[] args) {
              System.out.println(length);
       }

То він за замовчуванням не працюватиме. Але ми можемо додати наш клас у список класів, де статична ініціалізація буде проводиться на стадії складання:

RUN native-image --initialize-at-build-time=HelloWorld HelloWorld

І тоді поле length буде обчислено заздалегідь, і під час виконання дорівнювати 141. Це дає нам хороші можливості для оптимізації коду, оскільки ми можемо багато статичних речей зробити ще в build-time. Але якщо це неможливо, то доведеться написати спеціальний файл конфігурації reflect-config.json:

[
  {
    "name" : "java.lang.String",
    "allDeclaredConstructors" : true,
    "allPublicConstructors" : true,
    "allDeclaredMethods" : true,
    "allPublicMethods" : true,
    "allDeclaredClasses" : true,
    "allPublicClasses" : true
  }
]

І передати його в native-image, щоб він зберіг всю цю метаінформацію про клас String:

 RUN native-image -H:ReflectionConfigurationFiles=reflect-config.json HelloWorld

На жаль, цей мінус, який може ускладнити життя нам, розробникам. Дуже багато Java-фічі вимагають створення таких файлів:

1) Reflection API.

2) AOP проксі.

3) Використання ресурсів.

4) Серіалізація.

Але є спосіб це автоматизувати, ми про нього трохи згодом поговоримо. А поки що повернемося до нашого Dockerfile. На щастя, в GraalVM є підтримка static images, коли у виконуваний файл зберігаються ще й системні бібліотеки — zlibc (за замовчуванням) або musl:

RUN native-image --static HelloWorld

. А це дозволяє нам взагалі більше не використовувати базову ОС для запуску додатку:

FROM scratch

У результаті отримаємо такий Dockerfile:

FROM ghcr.io/graalvm/graalvm-ce:java17-21.3.0 as graalvm
RUN gu install native-image
COPY src/main/java/HelloWorld.java /sources/
WORKDIR /sources
RUN javac HelloWorld.java
RUN native-image --static HelloWorld
FROM scratch
COPY --from=graalvm /sources/helloworld /helloworld
ENTRYPOINT ["./helloworld"]

Час компіляції — 22 секунди. Розмір Docker image — 16 Мб. Але це число можна зменшити за допомогою пакувальників виконуваних файлів, таких як UPX. UPX чимось схожий на архіватор, але його завдання складніше — потрібно стиснути файл, що виконується так, щоб його теж можна було запускати. Для UPX додамо ще одну (другу) стадію:

FROM ubuntu as ubuntu
RUN apt-get update && apt-get install -y upx
COPY --from=graalvm /sources/helloworld /opt/
RUN upx /opt/helloworld

Час збирання 23 секунди. Розмір Docker image — 5 Мб. Можна ще більше спробувати стиснути виконуваний файл, вказавши алгоритм стиснення LZMA, а не дефолтний UCL:

RUN upx --lzma /opt/helloworld

Час збирання збільшився до 26 секунд, розмір Docker image 3.8 Мб. Таким чином, за допомогою GraalVM та UPX ми зменшили розмір Docker image (а по суті додатка) з 326 до 3.8 Мб. Підсумковий Dockerfile:

FROM ghcr.io/graalvm/graalvm-ce:java17-21.3.0 as graalvm
RUN gu install native-image
COPY src/main/java/HelloWorld.java /sources/
WORKDIR /sources
RUN javac HelloWorld.java
RUN native-image --static HelloWorld
FROM ubuntu as ubuntu
RUN apt-get update && apt-get install -y upx
COPY --from=graalvm /sources/helloworld /opt/
RUN upx --lzma /opt/helloworld
FROM scratch
COPY --from=ubuntu /opt/helloworld /helloworld
 ENTRYPOINT ["./helloworld"]

Підтримка community

У цій статті я розглянув тривіальний приклад. Але що, якщо у нас реальний проєкт, де більше 100 сторонніх бібліотек і розмір дистрибутива під 60 Мб? Тут є три варіанти:

1) Можна використовувати Maven/Gradle плагіни для GraalVM native image. Але вам доведеться вручну писати всі ці конфігураційні JSON-файли.

2) Якщо у вас Spring Boot, то ви можете використовувати проєкт Spring Native та Spring AOT плагін. Цей плагін при складанні аналізує classpath і сам генерує необхідні конфігураційні JSON-файли. Він поки що перебуває у статусі експериментального та вихід версії 1.0 планується тоді ж, коли і вихід Spring 6/Spring Boot 3 (вересень-жовтень 2022 року).

3) Якщо у вас сучасні мікровеб-фреймворки (Micronaut), то швидше за все, вам не потрібно буде писати жодних файлів, тому що ці технології практично не використовують магічних Java фіч, а генерують конфігурацію при складанні.

4) Якщо у вас додаток на базі Java EE (Jakarta EE, Eclipse Microprofile), то тут все складніше. Тільки Quarkus повністю підтримує Native Image. В інших випадках він вам недоступний.

Але саме на реальних додатках можна оцінити плюси native image. Якщо у звичайному варіанті Spring Boot додаток стартуватиме мінімум 2-3 секунди, то native-варіанті — всього 50 мс. Зрозуміло, і тут є свої складнощі (сподіваюся, тимчасові). Є проєкти, які через свій дизайн просто не підтримують native image — наприклад, Log4j 2. Є чутки, що це виправлять у Log4j 3, але, коли він вийде невідомо.

Є багато бібліотек (Mockito, Liquibase), для яких ще не створили конфігураційних файлів і на жаль, якщо вони є у вашому проєкті, то ви просто не зможете створити native image.

Висновки

У цій статті ми по кроках розглянули стадії оптимізації упаковки Java додатка в Docker image:

1) Начальний розмір — 326 Мб.

2) Перший варіант native image — 87 Мб.

3) Використання static images — 16 Мб.

4) Упаковка UPX — 5 Мб.

5) Використання алгоритму LZMA — 3. 8 Мб.

Так, UPX ніяк не відноситься до GraalVM, але якби не було native image, ми б ніяк не змогли використати і UPX.

Чи кажуть ці цифри про те, що потрібно кинути всі поточні завдання і терміново переходити на GraalVM Native image? Зовсім ні, і одразу з двох причин. По-перше, не кожна бібліотека адаптована під нього, і якщо у вас у проекті є хоча б одна технологія, яка не підтримує native image, ви не зможете його використовувати. По-друге, AOT режим дає швидкий старт додатка, але при цьому в JIT режимі є більше можливостей для runtime оптимізації. Так що такий перехід вимагає перед початком ґрунтовних benchmarks.

Яке майбутнє чекає на GraalVM? Я добре пам’ятаю 2015 рік, коли тільки з’явилися перші відгуки про Docker, і багатьом здавалося, що це щось несерйозне і не варто розгляду. Зараз можливо є айтішники, які не працювали з Docker, але немає тих, хто не чув про нього та контейнери. Я впевнений, що якщо у розробників GraalVM не знизиться мотивація та швидкість розробки, то в майбутньому використання цієї технології стане рядовою та звичною подією.

На жаль, як і у випадку Kotlin або Scala компілятора, час компіляції — ахіллесова п’ята GraalVM. Для досить великих програм воно може займати 3 — 5 хвилин (адже тут ще додасться запуск тестів в native environment). Але результати останніх benchmarks дають привід для оптимізму:

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному2
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
На щастя, в GraalVM є підтримка static images, коли у виконуваний файл зберігаються ще й системні бібліотеки — zlibc (за замовчуванням) або musl:

1. вы путаете zlib и libc. zlib идет как дополнительная зависимость к musl, а не вместо.
2. static images ратотает только с musl, т.к. libc не линкуется статически. Для libc graalvm предлагает «Mostly Static Native Image» — link statically everything except libc.
И размер со static будет всегда больше, а не меньше, как у вас.

Для docker, как базовый image имеет смысл брать bellsoft/liberica-openjdk-alpine, который 80Mb, а не 326.

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