Перейти к основному содержимому

Подробное знакомство с новым JIT-компилятором Java — Graal

· 10 мин. чтения

1. Обзор

В этом руководстве мы более подробно рассмотрим новый компилятор Java Just-In-Time (JIT) под названием Graal.

Мы посмотрим, что из себя представляет проект Graal , и опишем одну из его частей — высокопроизводительный динамический JIT-компилятор.

2. Что такое JIT - компилятор?

Давайте сначала объясним, что делает JIT-компилятор.

Когда мы компилируем нашу Java-программу (например, с помощью команды javac ), мы получаем исходный код, скомпилированный в двоичное представление нашего кода — байт-код JVM . Этот байт-код проще и компактнее нашего исходного кода, но обычные процессоры на наших компьютерах не могут его выполнить.

Чтобы запустить программу Java, JVM интерпретирует байт-код . Поскольку интерпретаторы обычно намного медленнее, чем собственный код, выполняемый на реальном процессоре, JVM может запустить другой компилятор, который теперь будет компилировать наш байт-код в машинный код, который может выполняться процессором . Этот так называемый своевременный компилятор намного сложнее, чем компилятор javac , и он выполняет сложную оптимизацию для создания высококачественного машинного кода.

3. Более подробный обзор JIT-компилятора

Реализация JDK от Oracle основана на проекте OpenJDK с открытым исходным кодом. Это включает в себя виртуальную машину HotSpot , доступную, начиная с версии Java 1.3. Он содержит два обычных JIT-компилятора: клиентский компилятор, также называемый C1, и серверный компилятор, называемый opto или C2 .

C1 предназначен для более быстрой работы и создания менее оптимизированного кода, в то время как C2, с другой стороны, требует немного больше времени для запуска, но создает более оптимизированный код. Клиентский компилятор лучше подходит для десктопных приложений, так как мы не хотим делать длительные паузы для JIT-компиляции. Серверный компилятор лучше подходит для долго работающих серверных приложений, которые могут тратить больше времени на компиляцию.

3.1. Многоуровневая компиляция

Сегодня установка Java использует оба JIT-компилятора во время обычного выполнения программы.

Как мы упоминали в предыдущем разделе, наша Java-программа, скомпилированная с помощью javac , начинает свое выполнение в интерпретируемом режиме. JVM отслеживает каждый часто вызываемый метод и компилирует их. Для этого он использует C1 для компиляции. Но HotSpot по-прежнему следит за будущими вызовами этих методов. Если количество вызовов увеличится, JVM еще раз перекомпилирует эти методы, но на этот раз с использованием C2.

Это стратегия по умолчанию, используемая HotSpot, называемая многоуровневой компиляцией .

3.2. Компилятор сервера

Давайте теперь немного сосредоточимся на C2, так как он самый сложный из двух. C2 чрезвычайно оптимизирован и производит код, который может конкурировать с C++ или быть даже быстрее. Сам серверный компилятор написан на определенном диалекте C++.

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

4. Проект GraalVM

Project GraalVM — это исследовательский проект, созданный Oracle. Мы можем рассматривать Graal как несколько взаимосвязанных проектов: новый JIT-компилятор, основанный на HotSpot, и новую виртуальную машину-полиглот. Он предлагает всеобъемлющую экосистему, поддерживающую большой набор языков (Java и другие языки на основе JVM; JavaScript, Ruby, Python, R, C/C++ и другие языки на основе LLVM).

Мы, конечно, сосредоточимся на Java.

4.1. Graal — JIT-компилятор, написанный на Java.

Graal — это высокопроизводительный JIT-компилятор. Он принимает байт-код JVM и создает машинный код.

Есть несколько ключевых преимуществ написания компилятора на Java. Во-первых, безопасность, то есть отсутствие сбоев, а исключений и реальных утечек памяти. Кроме того, у нас будет хорошая поддержка IDE, и мы сможем использовать отладчики, профилировщики или другие удобные инструменты. Кроме того, компилятор может быть независимым от HotSpot и сможет создавать более быструю JIT-компилированную версию самого себя.

Компилятор Graal был создан с учетом этих преимуществ. Он использует новый интерфейс компилятора JVM — JVMCI для связи с виртуальной машиной . Чтобы включить использование нового JIT-компилятора, нам нужно установить следующие параметры при запуске Java из командной строки:

-XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

Это означает, что мы можем запускать простую программу тремя различными способами: с помощью обычных многоуровневых компиляторов, с версией Graal для JVMCI на Java 10 или с самой GraalVM .

4.2. Интерфейс компилятора JVM

JVMCI является частью OpenJDK, начиная с JDK 9, поэтому мы можем использовать любой стандартный OpenJDK или Oracle JDK для запуска Graal.

На самом деле JVMCI позволяет нам исключить стандартную многоуровневую компиляцию и подключить наш совершенно новый компилятор (например, Graal) без необходимости что-либо менять в JVM.

Интерфейс довольно прост. Когда Graal компилирует метод, он передает байт-код этого метода в качестве входных данных для JVMCI. На выходе мы получим скомпилированный машинный код. И вход, и выход - это просто массивы байтов:

interface JVMCICompiler {
byte[] compileMethod(byte[] bytecode);
}

В реальных сценариях нам обычно требуется дополнительная информация, такая как количество локальных переменных, размер стека и информация, собранная при профилировании в интерпретаторе, чтобы мы знали, как код работает на практике.

По сути, при вызове compileMethod () интерфейса JVMCICompiler нам нужно передать объект CompilationRequest . Затем он вернет метод Java, который мы хотим скомпилировать, и в этом методе мы найдем всю необходимую информацию.

4.3. Грааль в действии

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

public class CountUppercase {
static final int ITERATIONS = Math.max(Integer.getInteger("iterations", 1), 1);

public static void main(String[] args) {
String sentence = String.join(" ", args);
for (int iter = 0; iter < ITERATIONS; iter++) {
if (ITERATIONS != 1) {
System.out.println("-- iteration " + (iter + 1) + " --");
}
long total = 0, start = System.currentTimeMillis(), last = start;
for (int i = 1; i < 10_000_000; i++) {
total += sentence
.chars()
.filter(Character::isUpperCase)
.count();
if (i % 1_000_000 == 0) {
long now = System.currentTimeMillis();
System.out.printf("%d (%d ms)%n", i / 1_000_000, now - last);
last = now;
}
}
System.out.printf("total: %d (%d ms)%n", total, System.currentTimeMillis() - start);
}
}
}

Теперь мы скомпилируем его и запустим:

javac CountUppercase.java
java -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

Это приведет к выводу, подобному следующему:

1 (1581 ms)
2 (480 ms)
3 (364 ms)
4 (231 ms)
5 (196 ms)
6 (121 ms)
7 (116 ms)
8 (116 ms)
9 (116 ms)
total: 59999994 (3436 ms)

Мы видим, что вначале требуется больше времени . Это время прогрева зависит от различных факторов, таких как объем многопоточного кода в приложении или количество потоков, используемых виртуальной машиной. Если ядер меньше, время прогрева может быть больше.

Если мы хотим видеть статистику компиляций Graal, нам нужно добавить следующий флаг при выполнении нашей программы:

-Dgraal.PrintCompilation=true

Это покажет данные, относящиеся к скомпилированному методу, затраченное время, обработанные байт-коды (включая встроенные методы), размер созданного машинного кода и объем памяти, выделенной во время компиляции. Результат выполнения занимает довольно много места, поэтому мы не будем его здесь показывать.

4.4. Сравнение с компилятором верхнего уровня

Давайте теперь сравним приведенные выше результаты с выполнением той же программы, скомпилированной с помощью компилятора верхнего уровня. Для этого нам нужно указать виртуальной машине не использовать компилятор JVMCI:

java -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:-UseJVMCICompiler 
1 (510 ms)
2 (375 ms)
3 (365 ms)
4 (368 ms)
5 (348 ms)
6 (370 ms)
7 (353 ms)
8 (348 ms)
9 (369 ms)
total: 59999994 (4004 ms)

Мы видим, что разница между отдельными временами меньше. Это также приводит к более короткому начальному времени.

4.5. Структура данных Graal

Как мы уже говорили ранее, Graal фактически превращает массив байтов в другой массив байтов. В этом разделе мы сосредоточимся на том, что стоит за этим процессом. Следующие примеры основаны на выступлении Криса Ситона на JokerConf 2017 .

Основная работа компилятора, как правило, состоит в том, чтобы воздействовать на нашу программу. Это означает, что он должен символизировать его соответствующей структурой данных. Graal использует для такой цели граф, так называемый граф программ-зависимостей .

В простом сценарии, когда мы хотим добавить две локальные переменные, т . е. x + y , у нас будет один узел для загрузки каждой переменной и еще один узел для их добавления . Помимо этого, у нас также есть два ребра, представляющие поток данных :

./c5621c2861013aafae650566e3407370.png

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

Давайте теперь представим другой тип ребер, которые описывают поток управления . Для этого мы расширим наш пример, вызывая методы для извлечения наших переменных вместо того, чтобы читать их напрямую. Когда мы это делаем, нам нужно следить за порядком вызова методов. Мы представим этот порядок красными стрелками:

./21064b43c55209d0137684bd25976a42.png

Здесь мы видим, что узлы на самом деле не изменились, но мы добавили ребра потока управления.

4.6. Актуальные графики

Мы можем изучить настоящие графы Graal с помощью IdealGraphVisualiser . Для его запуска мы используем команду mx igv . Нам также необходимо настроить JVM, установив флаг -Dgraal.Dump .

Давайте рассмотрим простой пример:

int average(int a, int b) {
return (a + b) / 2;
}

Это очень простой поток данных:

./54dbe27f8b25800254f52c4f67651b84.png

На графике выше мы можем видеть четкое представление нашего метода. Параметры P(0) и P(1) входят в операцию сложения, которая входит в операцию деления с константой C(2). Наконец, результат возвращается.

Теперь мы изменим предыдущий пример, чтобы его можно было применить к массиву чисел:

int average(int[] values) {
int sum = 0;
for (int n = 0; n < values.length; n++) {
sum += values[n];
}
return sum / values.length;
}

Мы видим, что добавление цикла привело нас к гораздо более сложному графу:

./707a9b09d19df73a52c86882b7ad6896.png

Что мы можем заметить здесь:

  • начальный и конечный узлы цикла
  • узлы, представляющие чтение массива и чтение длины массива
  • края потока данных и управления, как и раньше.

Эту структуру данных иногда называют морем узлов или супом узлов . Мы должны упомянуть, что компилятор C2 использует аналогичную структуру данных, так что это не что-то новое, созданное исключительно для Graal.

Следует помнить, что Graal оптимизирует и компилирует нашу программу, модифицируя вышеупомянутую структуру данных. Мы можем понять, почему написание JIT-компилятора Graal на Java было хорошим выбором: граф — это не что иное, как набор объектов со ссылками, соединяющими их в качестве ребер. Эта структура полностью совместима с объектно-ориентированным языком, которым в данном случае является Java .

4.7. Режим опережающего компилятора

Также важно отметить, что мы также можем использовать компилятор Graal в режиме компиляции Ahead-of-Time в Java 10 . Как мы уже говорили, компилятор Graal был написан с нуля. Он соответствует новому чистому интерфейсу JVMCI, который позволяет нам интегрировать его с HotSpot. Это не означает, что компилятор привязан к нему.

Одним из способов использования компилятора является использование подхода на основе профиля для компиляции только горячих методов, но мы также можем использовать Graal для полной компиляции всех методов в автономном режиме без выполнения кода . Это так называемая «компиляция с опережением времени», JEP 295, но мы не будем здесь углубляться в технологию компиляции AOT.

Основная причина, по которой мы будем использовать Graal таким образом, заключается в том, чтобы ускорить время запуска до тех пор, пока обычная многоуровневая компиляция в HotSpot не сможет взять верх.

5. Вывод

В этой статье мы рассмотрели функциональные возможности нового JIT-компилятора Java в рамках проекта Graal.

Сначала мы описали традиционные JIT-компиляторы, а затем обсудили новые возможности Graal, особенно новый интерфейс компилятора JVM. Затем мы проиллюстрировали, как работают оба компилятора, и сравнили их производительность.

После этого мы говорили о структуре данных, которую Graal использует для управления нашей программой, и, наконец, о режиме компиляции AOT как еще одном способе использования Graal.

Как всегда, исходный код можно найти на GitHub . Помните, что JVM необходимо настроить с помощью определенных флагов, которые были описаны здесь.