Оптимизируем Java-приложения с помощью GraalVM

0
279
Оптимизируем Java-приложения с помощью GraalVM
Оптимизируем Java-приложения с помощью GraalVM

С GraalVM я познакомился два года назад, когда она начала выходить на рынок. И сейчас уже набралось достаточное количество материала для отдельной статьи. Кроме того, на некоторых тренингах мы уже изучаем эту технологию, а я недавно провел по ней воркшоп. Поэтому в этой статье я приведу простые и не очень простые примеры, чтобы наглядно показать преимущества и особенности GraalVM.

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

Вторая категория оптимизации осуществляется автоматически, например Java-компилятором или JIT-компилятором в JVM. Кроме того, есть большое количество опций, позволяющих контролировать этот процесс. Если взять такой код:

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, это, например, известная опция -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.2GraalVM Community 22.0.0.2GraalVM Enterprise 22.0.0.2
1697311
100124921401157
10000112636183323121250

 

Как мы видим, 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 достаточно интеллектуален, чтобы выполнить этот код на стадии сборки и заменить его на 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 дают повод для оптимизма:

Оптимизируем Java-приложения с помощью GraalVM