Ну, блин, короче :-)

Знаете ли вы, куда уходит время и ресурсы при сборке проектов на Java? Сейчас покажем и расскажем, как сберечь время, нервы и кофе.

У сборочной системы Gradle есть интересный ключ --scan, позволяющий наглядно посмотреть на графике распределение ресурсов при сборке. После завершения процедуры протокол сборки загружается на сайт Gradle, и в красивом интерфейсе можно посмотреть на этапы. Там много другой информации, обязательно посмотрите, если ещё не видели. 


Только имейте ввиду, что не всякий проект стоит так собирать – чувствительные данные, пароли и прочее отправлять в Интернет не надо.


Получаем что-то вроде такого:

Протокол сборки Spring Boot
Протокол сборки Spring Boot

В оригинале эта картинка «живая» и там можно многое узнать о сборке вашего проекта. Скриншот лишь показывает, что задач выполняется много. Давайте немного структурируем информацию. Я перерисовал этот же таймлайн и получилось уже более понятно. Заштрихованные части — задачи компиляции, незаштрихованные — остальные задачи (документация, подготовка jar и др.):

Timeline сборки Spring Boot
Timeline сборки Spring Boot

В разных проектах получается, что н�� компиляцию тратится где‑то 35–50% ресурсов.

Но мало того, ресурсы тратятся не только непосредственно на компиляцию, но ещё и на «разогрев» виртуальной машины.

Как вы знаете, javac (компилятор языка Java) сам написан на Java (как говорят компиляторщики — раскручен, по‑английски bootstrapped). Поэтому и запускается он сравнительно небыстро.

Как запускается Java-приложение

Давайте вкратце вспомним, как происходит запуск Java-приложения. Это важно для понимания следующего шага.

  • Сначала запускается небольшой «пускач» (по-английски launcher). Это очень небольшое приложение (16 Кб), основная задача которого – загрузить и запустить библиотеку libJLI (Java launcher interface). Практически все утилиты, которые можно запустить из $JDK/bin – это копия launcher’а. Отличаются они, по сути, названием, и классом, который вызовется на одном из следующих шагов.

  • Библиотека libJLI, в свою очередь, разбирает параметры командной строки, переменные окружения, определяет конфигурацию, в которой произошел запуск (JRE или JDK), собирает всю эту информацию воедино и, в зависимости от того, что за «пускач» был вызван, передает всю эту информацию в JVM.

И вот тут начинается магия.

  • JVM загружает класс и/или jar-файл (в последнем случае она, очевидно, разворачивает zip-архив в памяти), раскладывает класс(ы) в памяти, инициализирует их и запускает интерпретатор (“Исполнение” на картинке выше).

Процесс инициализации классов – вещь не бесплатная и занимает определенное время. Для ускорения этой задачи в JRE предусмотрены механизмы Class Data Sharing и Application Class Data Sharing, которые позволяют сохранить состояние классов и загрузить их в память, минуя этап инициализации классов.

Делается это парой ключей -XX:ArchiveClassesAtExit=<архив> и -XX:SharedArchiveFile=<архив>.

На первом шаге запускаем приложение с ключом -XX:ArchiveClassesAtExit=<архив>, даём ему настроиться (загрузить все классы), после чего останавливаем. Получится архив с настроенными классами. После этого следующие запуски производим с ключом -XX:SharedArchiveFile=<архив>, указав путь до прежде сохранённого архива. В зависимости от количества классов, это даёт возможность экономить время старта приложения.

  • В процессе исполнения кода методов в работу включаются два компилятора прямо внутри JVM – C1 и C2. Это части JIT (just-in-time) компилятора, преобразующие байткод в машинный код платформы, на которой запущена JVM. Отличаются они разным количеством оптимизаций и правилами, по которым производится включение их в работу. Описание работы C1/C2 сильно выходит за рамки настоящей статьи, но вы можете посмотреть то, что уже есть на Хабре.

На все перечисленные процедуры при старте Java-приложения нужно время и ресурсы, в том числе поэтому компиляция такая неспешная.

Ускоряем javac

В некоторых случаях старт Java-приложения можно ускорить, применив к нему AOT-компиляцию (ahead-of-time, компиляция перед исполнением) с помощью Axiom NIK Pro. Это компилятор GraalVM с патчами от нашей компании.

После выполнения AOT-компиляции Java-приложение представляет собой машинный код конкретной платформы, поэтому при запуске преобразованного приложения не тратится время на разогрев.

Как следствие, приложение стартует, да и работает заметно быстрее исходного, нескомпилированного приложения.

Мы скомпилировали компилятор Javac по такой схеме, и он стал работать быстрее практически вдвое. Суммарное ускорение при сборке проектов составляет 10-20%, поскольку сборка проектов состоит не только из компиляции + тесты, если они есть, могут работать часами.

Причину ускорения наглядно можно увидеть на графиках, полученных из снятых профилей производительности при компиляции модуля jdk.compiler (это классическая задача “раскрутки” компилятора, когда разрабаты��аемый компилятор собирает свои же собственные исходные тексты).

Красным цветом показаны расход памяти и процессора оригинальным компилятором Javac при компиляции собственных исходников, зелёным – этот же компилятор, но уже после AOT. Разница весьма заметная. 

Цифры для графиков получены с помощью psrecord:

psrecord --log-format=csv --log=compile.csv --include-children "mvn compile"
Потребление CPU при компиляции компилятора
Потребление CPU при компиляции компилятора
Потребление памяти при компиляции компилятора
Потребление памяти при компиляции компилятора

Такое ускорение получается за счёт преобразования байткода в машинный код и отсутствия необходимости в JIT-компиляции, на которую тратятся значительные ресурсы.

Ускоренная версия javac включена в состав продукта Axiom JDK Express, который доступен в личном кабинете в статусе раннего доступа, можете скачивать и использовать.

Для использования напрямую (при вызове javac из командной строки) не требуется дополнительной настройки. Для использования в проектах Maven и Gradle необходимо включить режим fork в конфигурации.

Maven

В pom.xml нужно добавить профиль

<profile>
  <id>native_build</id>
    <build>
      <plugins>
        <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.10.1</version>
        <configuration>
          <fork>true</fork>
          <executable>/путь/до/jdk/bin/javac</executable>
        </configuration>
      </plugin>
    </plugins>
  </build>
</profile>

Запуск сборки выполняется как mvn <target> -Pnative_build.

Gradle

Создаете в проекте файл customjavac.gradle.kts с содержимым

allprojects {
  plugins.withId("java-base") {
    tasks.withType<JavaCompile>().configureEach {
      options.forkOptions.executable = "/путь/до/jdk/bin/javac"
      options.isFork = true
    }
  }
}

И далее запускаете сборку как ./gradlew --init-script customjavac.gradle.kts <target>.

P.S. Особенная благодарность Владимиру Ситникову @vladimirsitnikov за подсказку в настройке Gradle.