Прогрівання Java-машини: від JIT-флагів до CRIU/CRaC
Привіт! Мене звати Віталій Брецко, я Senior Developer в компанії GlobalLogic. Зараз працюю над одним із проєктів Hitachi. У цій статті я хочу розкрити тему прогрівання джава машини. Якщо ви використовуєте serverless-фреймворки, для вас важливий швидкий запуск застосунку, і ви хочете отримати відразу прогріту JVM — то ця стаття саме для вас.
У цій статті я не буду пояснювати, що таке C1, C2, JIT та AOT, тому рекомендую попередньо ознайомитися з цими термінами. Загального розуміння цих понять буде достатньо, щоб зрозуміти сенс написаного нижче.
Зазвичай Java-застосунок стартує у холодному стані. Холодний стан означає, що не виконувалось кешування методів (method caching), оптимізація шляхом вбудовування методів (method inlining), байткод ще не перетворений у машинний код, умовні конструкції (наприклад, if-else) ще не оптимізовані, і застосунок не зібрав жодної статистики використання. Згодом кількість статистики зросте, JVM визначить гарячі точки (hot spots) та передасть статистику до JIT-компілятора, який, своєю чергою, виконає відповідні оптимізації.
👇 А ви вже чули, що 21 червня DOU Mobile Day?
Однак цей підхід працює ефективно лише у довготривалій перспективі. Наприклад, якщо ви розгортаєте застосунок на ECS-сервісі AWS, ваш застосунок встигне пройти всі етапи оптимізації JIT-компілятора. Але не всі Java-застосунки розраховані на тривалу роботу. Наприклад, вам потрібно щодня генерувати звітність, і для цього створений окремий сервіс, що виконується лише кілька хвилин на день.
У такому випадку недоцільно витрачати ресурси на постійно запущений сервіс, тому найімовірніше ви використовуєте serverless-фреймворк. При цьому бажано мати вже прогріту JVM, щоб скоротити час формування звітів. Або, наприклад, ви торгуєте на біржах, де важлива швидкість обробки операцій. У цьому випадку потрібно запускати вже готовий застосунок, а не розганяти його під час торгівлі. Для цього ви можете скористатися однією з наступних технологій або підходів. Починаймо!
Common java flags
Тестування проходять в межах задачі JavaKMeansi з джерела renaissance.dev. Результат розв’язання цієї задачі на базі OpenJDK із включеними C1 i C2 компілятора профілюванням.
Отримали, що після 2000 ітерацій машина доходить до пікової продуктивності і розв’язує цю задачу за 44 мікросекунди.
За допомогою деяких java-флагів можна керувати рівнями JIT-компіляції, які повинні дати певні результати. Тому розглянемо наступні сценарії управлінням цими флагами:
Завжди оптимізуємо відразу на рівні C2-компілятора
Для цього потрібно використати наступні аргументи.
-XX:-TIeredCompilation -Xbatch -Xcomp
З цієї картини видно, що C2-компілятор одразу оптимізував код ще на етапі компіляції. У результаті ми маємо повністю «розігріту» машину з самого початку. Проте продуктивність при виконанні тієї ж операції суттєво знизилася — тепер потрібно у три рази більше часу.
Це пов’язано з тим, що оптимізація відбулася без попереднього збору статистики, що дало перевагу лише на перших ітераціях. Водночас звичайна Java, яка поступово накопичує профіль виконання, змогла провести ефективну компіляцію на рівні C1, що забезпечило кращу стабільну продуктивність надалі.
Цей підхід може дати виграш у випадках, коли застосунок виконує лише кілька ітерацій, але загалом — не варто на нього покладатися як на стабільне рішення для продуктивних систем.
Завжди зупиняємось на рівні компіляції C1
Це альтернативний підхід, за якого застосунок не збирає статистику для компілятора C2 і, відповідно, не витрачає ресурси на додаткові повні оптимізації чи деоптимізації.
Увімкнути цей режим можна за допомогою прапорців:
-XX:+TieredCompilation -XX:TieredStopAtLevel=1
Цей режим демонструє кращу продуктивність порівняно з підходом «відразу оптимізуємо» за допомогою компілятора C2, хоча й досі поступається за ефективністю стандартному підходу (з переходом до C2).
Отже, варто керуватись такою логікою: при використанні режиму «зупиняємось на C1» застосунок прогрівається приблизно у
За допомогою підходу «малою кров’ю» все-таки є можливість змінити час прогріву та кінцеву продуктивність. Зазвичай цього недостатньо, тому існують й інші підходи. Ці підходи займають більше часу, ніж просто додати декілька флагів до jvm, тому варто зважувати, чи варто витрачати час, чи підійдуть нестандартні Java-методи.
Manual warming (Штучне навантаження)
Якщо експерименти з флагами JVM не дали бажаного результату, хоча й показали певні позитивні зрушення за специфічних умов, то є ще один варіант — штучне навантаження. Сенс цього методу полягає в тому, щоб контрольовано прогрівати запущений застосунок. Тобто поки ми не отримуємо бажаний результат, відпрацьовує «мок»-логіка, а не реальна.
Наприклад, наш застосунок торгує на біржі, і зрозуміло, що час — це гроші: чим швидше відбувається покупка чи продаж, тим більший шанс отримати прибуток. Припустимо, застосунок використовує певне API з подальшою обробкою — це головна функція програми, без якої вона не має сенсу. Логічно було б оптимізувати цю ділянку коду, але застосунок не має права починати торгівлю, якщо він не прогрітий.
Рішенням може бути введення конструкції if-else: в одній частині — виклик API біржі, в іншій — виклик «мок»-методу. Умова базується на певному профілі, який може змінюватись динамічно. Це рішення виглядає доречним і гармонійним — якби не Code Elimination. Виходить, що гілка else може бути видалена під час оптимізації, хоча JVM знатиме, що там була конструкція if-else.
При зміні профілю, який використовується в цій умові, відпрацює Code Eliminated зона. Тому що гілка, яка відповідає за API-виклик, повинна відпрацювати, і JVM виконає деоптимізацію. Ми знову отримаємо стан, наближений до холодного. Тому при використанні методу штучного навантаження є ризик «вистрілити собі в ногу». Ця можливість є зумовленою специфікою роботи JIT-компілятора та методів оптимізації коду, що використовуються ним.
Specific jvm
Використання специфічних JVM, наприклад Alibaba або Jikesrvm. Вони містять незвичні рішення не тільки для оптимізації JVM-машини, що вплине на вашу існуючу систему і потребує експертизи в них, також вони не мають постійної підтримки, на що теж варто звернути увагу. Деякі із них я наведу посиланнями:
CRaC
Coordinated Restore at Checkpoint — проєкт, що базується на використанні Linux-технології CRIU, сенс якої отримати знімок, з якого ми зможемо запускати застосунок. Спочатку розглянемо, що таке CRIU, щоб розуміти, що ж під капотом CRaC відбувається.
Checkpoint/Restore In Userspace (CRIU) — це механізм, що дозволяє створювати контрольні точки в просторі імен користувача (user namespace) зі збереженням стану процесів. Під час збереження фіксується повний стан, зокрема:
- пам’ять;
- відкриті з’єднання;
- відкриті файли;
- сокети;
- та інші ресурси.
Особливо важливо пам’ятати, що за допомогою CRIU можливо навіть відновити TCP-з’єднання. Через це необхідно ретельно стежити за правильністю закриття ресурсів. Наприклад, сервер Tomcat використовує певні відкриті порти — якщо під час спроби відновлення з файлу ці порти вже зайняті, сервер завершить роботу з помилкою. Крім того, варто зважати на вміст оперативної пам’яті: процеси можуть містити чутливі дані, зокрема паролі або інші секрети. Ці дані потрапляють у файл знімка стану, тому він повинен зберігатися у надійному місці.
Ознайомившись із CRIU можна переходити до CRaC. Перед створенням файлу відновлення, потрібно ознайомитись із головним інтерфейсом цієї бібліотеки Resource. Він містить в собі два методи, що базуються на івентах відповідно перед початком створення файлу (чекпойнта) та після відновлення.
Приклад закриття пулу конекшинів до бази даних:
@Slf4j @Component @RequiredArgsConstructor public class FlushConnectionPoolResource implements Resource { private final DataSource dataSource; @PostConstruct public void init() { log.info("init"); Core.getGlobalContext().register(this); } @Override public void beforeCheckpoint(org.crac.Context<? extends Resource> context) { log.info("beforeCheckpoint dataSource: {}", dataSource); if (dataSource instanceof HikariDataSource hikariDataSource) { HikariPoolMXBean poolMxBean = hikariDataSource.getHikariPoolMXBean(); poolMxBean.suspendPool(); poolMxBean.softEvictConnections(); } } @Override public void afterRestore(org.crac.Context<? extends Resource> context) throws Exception { log.info("afterRestore dataSource: {}", dataSource); if (dataSource instanceof HikariDataSource hikariDataSource) { HikariPoolMXBean poolMxBean = hikariDataSource.getHikariPoolMXBean(); poolMxBean.resumePool(); try (Connection connection = hikariDataSource.getConnection()) { log.info("afterRestore: connection isValid={}", connection.isValid(1)); } } } }
Повністю розуміючи, що собою являє CRaC, що саме міститься у файлі, з якого здійснюється відновлення, а також як правильно закривати ресурси за потреби, можна переходити до створення точки відновлення.
Перед запуском Java-застосунку потрібно додати прапорець:
-XX:CRaCCheckpointTo=PATH — для створення відновлювального файлу.
У випадку зі Spring файл автоматично буде створено під час запуску (також варто звернути увагу на ваші cron- або scheduled-задачі — вони можуть потрапити у файл відновлення, що призведе до неочікуваної бізнес-логіки).
Якщо ж потрібно створити файл через певний час, можна скористатися командою:
jcmd application.jar JDK.checkpoint
Для відновлення з підготовленого файлу потрібно використати наступний прапорець:
— XX:CRaCRestoreFrom = PATH
За потреби можна створити декілька відновлювальних файлів, доналаштувати
Також є приблизні тести продуктивності для мінімального Spring-застосунку. Посилання.
Варто звернути увагу, що продуктивність буде відрізнятись від застосунку до застосунку, оскільки не всі використовують базу даних або навпаки, хтось використовує декілька баз даних і навіть динамічно створює нове з’єднання. Але CRaC допоможе не втратити оптимізацію, що проводиться за допомогою JIT-компілятора, зібрану статистику та інше.
Class Data Sharing
CDS — недооцінений шедевр JVM. CDS (Class Data Sharing) — це технологія, яка вже давно використовується в більшості JVM, але не на повну потужність. Швидше за все, ви вже користуєтесь CDS, навіть не підозрюючи про це. Однак оптимізація через CDS зазвичай стосується лише завантаження класів самого JDK, тоді як класи вашого застосунку чи бібліотек, найімовірніше, залишаються «поза бортом». Щоб це виправити, необхідно виконати спеціальний «навчальний» запуск застосунку.
При цьому потрібно дотримуватися ряду умов, які досить легко порушити без підтримки з боку середовища, наприклад, такого як Spring Boot:
- Потрібно використовувати ту саму JVM.
- Classpath має бути заданий списком JAR-файлів, без використання каталогів, символів * та вкладених JAR-архівів.
- Важливо зберігати тимчасові мітки JAR-файлів.
- Під час використання архіву CDS у робочому режимі classpath повинен бути точно таким же, яким він був під час створення архіву. Додаткові JAR-файли або каталоги можна вказати в кінці, але вони не будуть кешуватись.
Spring Boot 3.3 розкриває потенціал CDS завдяки двом новим функціям: саморозпаковуваний виконуваний JAR та підтримка Buildpacks CDS.
Запуск застосунку в продакшені за допомогою команди java -jar my-app.jar не є найефективнішим способом. Цей факт задокументований, але багато розробників, які не використовують Buildpacks, не беруть це до уваги — що підтверджується численними обговореннями в спільноті Spring. Донедавна не існувало повноцінної вбудованої функції, яка могла б допомогти в цій ситуації.
Починаючи зі Spring Boot 3.3 ситуація змінилася — завдяки покращенням, пов’язаним із саморозпаковуваними виконуваними JAR-файлами. Тепер для того, щоб розпакувати JAR і запустити застосунок, не потрібні зовнішні інструменти — достатньо команди java:
java -Djarmode=tools -jar my-app.jar extract —destination application
Також команда для запуску застосунку:
java -jar application/my-app.jar
Варто зазначити, що ця функція має справжню «суперсилу»: вона спроєктована з урахуванням вимог CDS (та Project Leyden). У поєднанні з підтримкою CDS у Spring Framework для «training» запусків, ви можете створити CDS-архів для вашого Spring Boot застосунку наступним чином:
java -XX:ArchiveClassesAtExit=application.jsa -Dspring.context.exit=onRefresh -jar application/my-app.jar
Та відповідно команда для запуску із CDS :
java -XX:SharedArchiveFile=application.jsa -jar application/my-app.jar
Трішки бенчмарків. Під час запуску мінімального Spring MVC застосунку на Tomcat ми спостерігаємо, що застосунок із використанням CDS завантажується приблизно у 1.5 раза швидше та споживає на 16% менше пам’яті порівняно із запуском у вигляді виконуваного JAR-файлу. Якщо до цього процесу додати Spring AOT, то завантаження прискорюється приблизно у два рази, а споживання пам’яті зменшується на 27%.
З графіка бачимо, що ми отримали пришвидшення застосунку за допомогою використання CDS і Spring Boot.
Проєкт, що активно інтегрує CDS:
openjdk.org/...yden-jvmls-2023-08-08.pdf
Висновок
Є багато підходів, які можна застосувати для пришвидшення застосунку, проте варто розглядати ці рішення не як уніфіковані. Для вашого випадку можуть підійти декілька з них або ж жоден. Тому якщо хочете використати їх у своєму проєкті, спочатку зважте, чи вам це реально підійде і принесе користь.
1 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів