Если вы хотите разобраться, что такое GraalVM, как он работает, и в чем различия между Just-In-Time (JIT) компиляцией и Ahead-Of-Time (AOT) компиляцией, то это руководство — именно то, что вы искали.

Вступление

Недавно я получил пару вопросов касательно GraalVM, AOT, JIT, нативных образов Java, и т.д.

А именно: если нативные исполняемые файлы Graal запускаются почти мгновенно, имеют меньший размер и потребляют меньше ресурсов — зачем вам вообще может понадобиться использовать что-то другое для своих Java/JVM проектов?

Давайте разбираться!

Что такое GraalVM?

У меня сложилось впечатление, что когда речь заходит о чем-либо связанном с GraalVM, то с большой долей вероятности это будет наспех сколоченная конструкция из ряда фактов. Скорее всего что-то вроде этого:

Ну… и что нам теперь делать с этой информацией?

  • Следует ли вам использовать 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".

Комментарии (2)


  1. konsoletyper
    22.06.2023 11:22
    +2

    У меня на работе мы нашли применение native image: компиляция приложения в iOS - там нельзя JIT ни под каким соусом. Получается, можно из одного набора исходников (с небольшой платформо-специфической частью) собирать приложения для Android, iOS и для веба (привет, компиляторы Java в JS).


  1. eMptywee
    22.06.2023 11:22
    +2

    У нас в проде на базе Quarkus микросервис, переписанный с Spring Boot и скомпилированный в нативный образ, уже наверное с год работает, без особых проблем. В нем используются библиотеки типа MariaDB, Hashicorp Vault, Spring Cloud Config Client, и т.д.

    Сейчас на подходе пара-тройка микросервисов на Spring Boot 3.0 с AOT. В них как либы для мускла, так и для rabbitmq, vault, spring cloud и прочие необходимые плюшки. Не без скрипа, но в QA уже что-то вполне рабочее.

    По замерам метрик нативные образы выглядят менее прожорливыми до ресурсов, особенно на старте. По быстродействию, однако, примерно одинаковы с JIT.

    Так что не всё так уж и плохо, а то статья уж, на мой взгляд, шибко мрачно заканчивается :)