1. Обзор
В этом уроке мы более подробно рассмотрим новый компилятор Java Just-In-Time (JIT), называемый Graal.
Мы посмотрим, что представляет собой проект Graal , и опишем одну из его частей-высокопроизводительный динамический JIT-компилятор.
2. Что такое JIT-Компилятор?
Давайте сначала объясним, что делает JIT-компилятор.
Когда мы компилируем нашу Java-программу (например, с помощью команды javac ), мы получим исходный код, скомпилированный в двоичное представление нашего кода – байт-код JVM . Этот байт-код проще и компактнее, чем наш исходный код, но обычные процессоры в наших компьютерах не могут его выполнить.
Чтобы иметь возможность запускать программу Java, JVM интерпретирует байт-код . Поскольку интерпретаторы обычно намного медленнее, чем машинный код, выполняемый на реальном процессоре, JVM может запустить другой компилятор, который теперь скомпилирует наш байт-код в машинный код, который может быть запущен процессором . Этот так называемый компилятор just-in-time намного сложнее, чем компилятор 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 для компиляции. Но точка доступа по-прежнему следит за будущими вызовами этих методов. Если количество вызовов увеличится, JVM перекомпилирует эти методы еще раз, но на этот раз с использованием C2.
Это стратегия по умолчанию, используемая точкой доступа, называемая многоуровневая компиляция .
3.2. Серверный Компилятор
Давайте теперь немного сосредоточимся на С2, поскольку он является самым сложным из двух. C2 был чрезвычайно оптимизирован и создает код, который может конкурировать с C++ или быть еще быстрее. Сам серверный компилятор написан на определенном диалекте C++.
Тем не менее, это связано с некоторыми проблемами. Из-за возможных ошибок сегментации в C++ это может привести к сбою виртуальной машины. Кроме того, за последние несколько лет в компиляторе не было реализовано никаких серьезных улучшений. Код в C2 стало трудно поддерживать, поэтому мы не могли ожидать новых серьезных улучшений с текущим дизайном. Имея это в виду, новый JIT-компилятор создается в проекте с именем Graal VM.
4. Проект GraalVM
Проект Graal VM – это исследовательский проект, созданный Oracle. Мы можем рассматривать Graal как несколько связанных проектов: новый JIT-компилятор, основанный на HotSpot, и новая виртуальная машина polyglot. Он предлагает комплексную экосистему, поддерживающую большой набор языков (Java и другие языки на основе JVM; JavaScript, Ruby, Python, R, C/C++ и другие языки на основе LLVM).
Мы, конечно, сосредоточимся на Java.
4.1. Graal – JIT-компилятор, написанный на Java
Graal-это высокопроизводительный JIT-компилятор. Он принимает байт-код JVM и создает машинный код.
Существует несколько ключевых преимуществ написания компилятора на Java. Прежде всего, безопасность, то есть никаких сбоев, кроме исключений, и никаких реальных утечек памяти. Кроме того, у нас будет хорошая поддержка IDE, и мы сможем использовать отладчики, профилировщики или другие удобные инструменты. Кроме того, компилятор может быть независимым от точки доступа, и он сможет создать более быструю 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); }
В реальных сценариях нам обычно потребуется дополнительная информация, такая как количество локальных переменных, размер стека и информация, собранная в результате профилирования в интерпретаторе, чтобы мы знали, как код выполняется на практике.
По сути, при вызове метода compile () интерфейса JVMCI компилятора |/нам нужно будет передать Запрос на компиляцию объекта. Затем он вернет метод 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 , у нас будет один узел для загрузки каждой переменной и другой узел для их добавления . Кроме того, у нас также будет два ребра, представляющих поток данных :
Края потока данных отображаются синим цветом . Они указывают, что при загрузке локальных переменных результат переходит в операцию сложения.
Теперь давайте представим другой тип ребер, те, которые описывают поток управления . Для этого мы расширим наш пример, вызвав методы для извлечения наших переменных, а не для их непосредственного чтения. Когда мы это делаем, нам нужно отслеживать порядок вызова методов. Мы представим этот порядок красными стрелками:
Здесь мы видим, что узлы на самом деле не изменились, но у нас есть добавленные ребра потока управления.
4.6. Фактические графики
Мы можем исследовать реальные графы Грааля с помощью Idealgraphvisualizer . Чтобы запустить его, мы используем команду mx igv . Нам также необходимо настроить JVM, установив -Dgraal.Дамп флаг.
Давайте рассмотрим простой пример:
int average(int a, int b) { return (a + b) / 2; }
Это очень простой поток данных:
На приведенном выше графике мы видим четкое представление нашего метода. Параметры 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; }
Мы видим, что добавление цикла привело нас к гораздо более сложному графу:
Что мы можем заметить вот:
- начальный и конечный узлы цикла
- узлы, представляющие чтение массива и чтение длины массива
- границы потока данных и управления, как и раньше.
Эту структуру данных иногда называют морем узлов или супом узлов . Мы должны упомянуть, что компилятор C2 использует аналогичную структуру данных, так что это не что-то новое, разработанное исключительно для Graal.
Следует отметить, что Graal оптимизирует и компилирует нашу программу, изменяя вышеупомянутую структуру данных. Мы можем понять, почему на самом деле было хорошим выбором написать компилятор Graal JIT на Java: граф-это не что иное, как набор объектов со ссылками, соединяющими их в качестве ребер. Эта структура идеально совместима с объектно-ориентированным языком, которым в данном случае является Java .
4.7. Опережающий режим компилятора
Также важно отметить, что мы также можем использовать компилятор Graal в режиме опережающего компилятора в Java 10 . Как мы уже говорили, компилятор Graal был написан с нуля. Он соответствует новому чистому интерфейсу, JVMCI, который позволяет нам интегрировать его с точкой доступа. Однако это не означает, что компилятор привязан к нему.
Одним из способов использования компилятора является использование профильного подхода для компиляции только горячих методов, но мы также можем использовать Graal для полной компиляции всех методов в автономном режиме без выполнения кода . Это так называемая “Компиляция с опережением времени”, JEP 295, но мы не будем углубляться в технологию компиляции AOT здесь.
Основная причина, по которой мы будем использовать Graal таким образом, заключается в том, чтобы ускорить время запуска до тех пор, пока обычный многоуровневый подход к компиляции в точке доступа не сможет взять верх.
5. Заключение
В этой статье мы изучили функциональные возможности нового JIT-компилятора Java в рамках проекта Graal.
Сначала мы описали традиционные JIT-компиляторы, а затем обсудили новые функции Graal, особенно новый интерфейс компилятора JVM. Затем мы проиллюстрировали, как работают оба компилятора, и сравнили их производительность.
После этого мы поговорили о структуре данных, которую Graal использует для управления нашей программой, и, наконец, о режиме компилятора AOT как еще одном способе использования Graal.
Как всегда, исходный код можно найти на GitHub . Помните, что JVM должна быть настроена с определенными флагами, которые были описаны здесь.