Если вы хотите разобраться, что такое GraalVM, как он работает, и в чем различия между Just-In-Time (JIT) компиляцией и Ahead-Of-Time (AOT) компиляцией, то это руководство — именно то, что вы искали.
Вступление
Недавно я получил пару вопросов касательно GraalVM, AOT, JIT, нативных образов Java, и т.д.
А именно: если нативные исполняемые файлы Graal запускаются почти мгновенно, имеют меньший размер и потребляют меньше ресурсов — зачем вам вообще может понадобиться использовать что-то другое для своих Java/JVM проектов?
Давайте разбираться!
Что такое GraalVM?
У меня сложилось впечатление, что когда речь заходит о чем-либо связанном с GraalVM, то с большой долей вероятности это будет наспех сколоченная конструкция из ряда фактов. Скорее всего что-то вроде этого:
GraalVM — это виртуальная машина Java (JVM) (кто бы мог подумать), которая может запускать (байт)код Java, поддерживаемая Oracle.
Благодаря фреймворку Truffle GraalVM может запускать не только Java, но и JS, Python, Ruby и целый ряд других языков, которые я сейчас не вспомню.
Есть компилятор Graal, который является Just-In-Time (JIT) компилятором.
А также есть нативные образы (Native Image), которые создаются Ahead-of-Time (AOT) компилятором Graal.
Ну… и что нам теперь делать с этой информацией?
Следует ли вам использовать GraalVM вместо “обычной” виртуальной машины HotSpot? Просто использовать JIT-компилятор Graal?
Или вам все-таки стоит использовать его AOT-компилятор, чтобы создавать нативные образы?
Или лучше вообще плюнуть на это все, и развернуться в сторону PHP?
Чтобы дать вам правильный ответ, нам нужно совершить краткий обзор компилятора Java. И, как всегда, стоит начать с азов.
Что такое Javac?
Дефолтный компилятор Java, именуемый javac, берет ваш исходный код Java (ваши .java-файлы), например:
//...public static void main etc
public static int add(int a, int b) {
return a + b;
}
//...
И транслирует его в байт-код Java, ваши файлы классов, как, например, Main.class
. Вы можете запустить их на любой машине, на которой установлена JVM.
Вот как выглядит байт-код для приведенного выше метода (сгенерированный с помощью команды javap -c Main.class
):
0: iload_0
1: iload_1
2: iadd
3: ireturn
Что происходит с вашим байт-кодом?
Теперь, когда вы попытаетесь запустить свой класс/приложение Java (например, java Main.class
), вы обнаружите, что байт-код, который вы сгенерировали в предыдущем разделе, еще не был скомпилирован в машинный код — ваша JVM должна интерпретировать его. Для этого она использует TemplateInterpreter. Если вам интересна более подробная информация об этом процессе, вы можете найти ее здесь.
Что делает TemplateInterpreter?
Если в двух словах, то он по очереди проходит операторы, приведенные выше (как, например, istore_1
) и выясняет, что нужно выполнить для этого оператора в конкретной операционной системе и архитектуре, которую вы сейчас используете.
Что такое JIT-компилятор?
JVM, однако, достаточно умен для того чтобы не заниматься одной лишь бесконечной интерпретацией вашего байт-кода. Он также запоминает код, который ваша программа часто выполняет (так называемые критические пути – hot paths
), а затем напрямую компилирует этот байт-код в машинный код. Особо любопытным рекомендую почитать про компиляторы Java C1 и C2 и многоуровневую компиляцию (tiered compilation
).
После большого объема статического анализа кода и информации о выполнении JIT-компилятор наконец может выдать оптимизированный под вашу конкретную платформу машинный код.
Припоминаете старый добрый ассемблер?
push ebp
mov ebp, esp
mov eax, [ebp + 8]
mov ebx, [ebp + 12]
add eax, ebx
mov esp, ebp
pop ebp
ret
Итак, каковы преимущества JIT-подхода?
Если коротко и по делу, то:
Оригинальное обещание Java: «Написать один раз, запустить где угодно» (где установлен JVM).
После прогревочного периода интерпретации, вы получите «замечательную» производительность во время выполнения → «Just In Time».
Что такое AOT-компилятор?
Вместо того, чтобы следовать по маршруту:
.java -> javac -> bytecode -> jvm -> interpreter -> JIT
AOT-компилятор может идти по следующему пути:
.java -> AOT magic -> native executable (think .exe / elf)
По сути, AOT-компилятор выполнит кучу статического анализа кода (во время сборки, а не во время выполнения/JIT), а затем создаст собственный исполняемый файл для конкретной платформы: Windows, Mac, Linux, x64, ARM и т.д. и т.п. — как, например, если вы получите Main.exe
.
И это означает, что вам не нужно выполнять интерпретацию/компиляцию байт-кода после запуска вашей программы. Вместо этого вы получаете максимально быстрый запуск приложения. С другой стороны, вам нужно создать специальный исполняемый файл для каждой комбинации платформ x архитектур
, на которых вы хотите, чтобы ваша программа работала (+ целый ряд других ограничений, о которых мы поговорим чуть позже). По сути, это идет в разрез с главным обещанием Java.
Хорошо, хорошо, так что насчет GraalVM?
Как упоминалось в самом начале, GraalVM поставляется с обоими JIT И AOT компиляторами, хотя люди по ошибке часто отождествляют Graal с его функцией Native Image.
Компилятор Graal (JIT) по сути является заменой компилятора C2 (JIT). (Если вас интересует сравнение производительности между ними, вы можете найти его здесь, здесь или здесь).
Native Image — это AOT-компилятор, который может создавать нативные исполняемые файлы из исходников Java.
Так что, с AOT есть какие-то проблемы?
Да, вообще-то есть.
Когда Graal/любой AOT-компилятор создает эти нативные исполняемые файлы, ему необходимо выполнить статический анализ кода с помощью так называемого предположения о замкнутости мира (closed-world assumption
). Фактически это означает, что ему нужно знать все классы, которые должны быть доступны во время выполнения, во время сборки, иначе код не попадет в окончательный исполняемый файл.
А это означает, что все, что связано с динамической загрузкой, например, рефлексия, JNI или прокси — все те полезные вещи, которые используют множество библиотек и проектов на Java, — это потенциальная проблема.
А теперь пример!
Разминочный пример с рефлексией
public static void main(String[] args) {
if (isFriday()) {
String myClass = "com.marcobehler.MyFancyStartupService";
MyFancyStartupService instance = (MyFancyStartupService)
Class.forName(myClass)
.getConstructor()
.newInstance();
instance.cashout();
}
}
Статический анализ кода не выполняет ваш код, поэтому компилятор не знает, действительно ли сейчас пятница, и поэтому ваш MyFancyStartupService
не будет виден ему и не попадет в окончательный исполняемый файл.
Конечно для таких ситуаций есть обходные пути: вы можете указать метаданные в форме JSON-файлов, которые дают возможность AOT-компилятору узнать о существовании MyFancyStartupService
. Это также означает, что любая библиотека, которую вы хотите включить в свой проект, должна быть «AOT ready» и, когда это необходимо, предоставлять эти метаданные.
Пример из реального мира
Рассмотрим более реалистичный пример из мира Spring.
В зависимости от конкретных свойств или профилей, которые вы установили при запуске приложения Spring, у вас могут быть разные загружаемые во время выполнения bean-компоненты.
Взгляните на следующую AutoConfiguration
, которая будет создавать bean-компонент FlamegraphProvider
, только если установлено определенное свойство, например, в файле конфигурации при запуске приложения.
Опять же, компилятор Graal не может узнать во время сборки, будет это так или нет, поэтому Spring (Boot) вообще не поддерживает @Profiles и @ConditionalOnProperties для своих нативных изображений.
@AutoConfiguration
@ConditionalOnProperty(prefix = "flamegraphs",
name = "enabled",
havingValue = "true")
public static class FlamegraphConfiguration {
@Bean
public FlamegraphProvider flamegraphProvider() {
// ...
}
}
Еще какие-нибудь потенциальные проблемы?
Да.
AOT-компиляция очень требовательна к ресурсам. В случае с нативными образами Spring мы говорим о многих, многих гигабайтах памяти и высокой загрузке ЦП, необходимых для компиляции. Однако это определенно порадует вашего поставщика CI/CD!
Кроме того, создание нативного исполняемого файла занимает значительно больше времени, чем создание байт-кода. Например, если вы возьмете скелет приложения Spring Boot, речь будет идти о минутах (AOT), а не о секундах (JIT).
В зависимости от вашей платформы (я смотрю на вас, Windows!), также может быть очень обременительно настроить все необходимые SDK и библиотеки, чтобы иметь возможность хотя бы просто начать компиляцию.
Если у вас нет контроля над целевой средой выполнения, как в случае с любым стереотипным десктопным приложением: вы получите просто безумную CI/CD матрицу для создания нативных исполняемых файлов для различных поддерживаемых архитектур/платформ. И вам нужно будет поддерживать и сопровождать эту матрицу CI/CD.
Это не проблема, если вы, например, поместите исполняемый файла вашего сервера в Docker-контейнер, но об этом чуть позже.
Так, а у вас ДЕЙСТВИТЕЛЬНО есть опыт в этом?
Вот вы меня и подловили! Помимо небольших тестовых серверных приложений и одного реального CLI-приложения, написанного на picocli, у меня нет опыта создания нативных исполняемых файлов для реальных приложений среднего и крупного масштаба.
Поэтому я могу положиться только на наш любимый коллективный разум Reddit, где один пользователь оставил в начале 2023 года следующий комментарий:
Я не думаю, что мы находимся даже в стадии альфы.
Например, я уже 2 дня бьюсь с простым микросервисом, задействующим только JPA, MySQL и несколько транзакций, и все безуспешно.
Я исправил как минимум 4 бага и сдался. Не представляю, какие проблемы могут возникнуть в проектах среднего размера.
Зачем кому-либо вообще может понадобиться AOT?
Видеть запуск приложений за миллисекунды, когда они скомпилированы и запущены как нативный исполняемый файл, — это что-то. Я полагаю, именно поэтому нативные образы часто рекламируются как идеальное решение для, например, Lambdas или что они нашли свою нишу в CLI-приложениях, где вы также с меньшей вероятностью столкнетесь с ограничениями AOT из-за масштабов проекта.
С другой стороны, в высшей степени абсурдно создавать нативный исполняемый файл, помещать его в образ Docker, чтобы потом иметь возможность быстро раскручивать контейнер на каждый запрос (что было реальным предложением для одного из прошлых проектов моего близкого друга @ae)… Я не могу не думать, что мы прошли полный круг к старым добрым CGI-bin Perl.
Но если быть чуть менее саркастичным:
Если вы можете справиться с его ограничениями и обойти их для вашей конкретной среды, тогда AOT — отличный выбор. В противном случае наслаждайтесь преимуществами JIT-компиляции серверных приложений, не поддавайтесь на ажиотаж и пишите код в свое удовольствие! :)
Слова благодарности
Тагиру Валееву, Андрею Акиньшину и @ae за знания/комментарии/исправления/обсуждение.
Особая благодарность PartOfTheBotnet, который указал на неправильный исходный байт-код (в первой редакции этой статьи) и подсказал, что можно использовать javap, чтобы извлечь правильный.
Конец
Хотите увидеть больше таких коротких технологических погружений? Оставьте комментарий ниже. А пока посмотрите мою серию на YouTube о том, как создать cli текстовый редактор на чистом Java.
Перевод подготовлен в преддверии старта курса "Java Developer. Professional".