Оптимізуємо 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, але
Як тестований код візьмемо метод, який заповнює 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 до
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 додаток стартуватиме мінімум
Є багато бібліотек (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 дають привід для оптимізму:
1 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів