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

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

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

1. Обзор

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

В более ранних версиях Java нам приходилось вручную выбирать между двумя типами JIT-компиляторов, доступными в Hotspot JVM. Один оптимизирован для более быстрого запуска приложений, а другой обеспечивает более высокую общую производительность. Java 7 представила многоуровневую компиляцию, чтобы получить лучшее из обоих миров.

В этом руководстве мы рассмотрим JIT-компиляторы клиента и сервера. Мы рассмотрим многоуровневую компиляцию и ее пять уровней компиляции. Наконец, мы увидим, как работает компиляция методов, отслеживая журналы компиляции.

2. JIT-компиляторы

Компилятор JIT компилирует байт-код в собственный код для часто выполняемых секций . Эти разделы называются горячими точками, отсюда и название Hotspot JVM. В результате Java может работать с производительностью, аналогичной полностью скомпилированному языку. Давайте рассмотрим два типа JIT-компиляторов, доступных в JVM.

2.1. C1 – компилятор клиента

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

Исторически сложилось так, что мы использовали C1 для недолговечных приложений и приложений, где время запуска было важным нефункциональным требованием. До Java 8 нам приходилось указывать флаг -client для использования компилятора C1. Однако, если мы используем Java 8 или выше, этот флаг не будет действовать.

2.2. C2 — Компилятор сервера

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

Исторически сложилось так, что мы использовали C2 для долго работающих серверных приложений. До Java 8 нам приходилось указывать флаг -server для использования компилятора C2. Однако этот флаг не действует в Java 8 и более поздних версиях.

Следует отметить, что JIT- компилятор Graal также доступен, начиная с Java 10, в качестве альтернативы C2. В отличие от C2, Graal может работать как в режиме своевременной, так и в режиме предварительной компиляции для создания собственного кода.

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

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

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

3.1. Лучшее из обоих миров

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

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

./dea5548804fb891532cb22eacde4e608.png

Таким образом, C1 быстрее повышает производительность, а C2 улучшает производительность на основе дополнительной информации о горячих точках.

3.2. Точное профилирование

Дополнительным преимуществом многоуровневой компиляции является более точная информация о профилировании. До многоуровневой компиляции JVM собирала информацию о профилировании только во время интерпретации.

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

3.3. Кэш кода

Кэш кода — это область памяти, в которой JVM хранит весь байт-код, скомпилированный в собственный код. Многоуровневая компиляция увеличила объем кода, который необходимо кэшировать, до четырех раз.

Начиная с Java 9, JVM сегментирует кеш кода на три области:

  • Сегмент, не относящийся к методу — внутренний связанный код JVM (около 5 МБ, настраивается с помощью -XX:NonNMethodCodeHeapSize )
  • Сегмент профилированного кода — скомпилированный код C1 с потенциально коротким временем жизни (около 122 МБ по умолчанию, настраивается с помощью -XX:ProfiledCodeHeapSize )
  • Непрофилированный сегмент — скомпилированный код C2 с потенциально длительным временем жизни (аналогично 122 МБ по умолчанию, настраивается с помощью -XX:NonProfiledCodeHeapSize )

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

3.4. Деоптимизация

Несмотря на то, что скомпилированный код C2 высоко оптимизирован и долгоживущий, его можно деоптимизировать. В результате JVM временно откатится к интерпретации.

Деоптимизация происходит, когда оптимистичные предположения компилятора оказываются неверными — например, когда информация профиля не соответствует поведению метода:

./74e9e0ef025176883d52f84d78ab70f5.png

В нашем примере при изменении горячего пути JVM деоптимизирует скомпилированный и встроенный код.

4. Уровни компиляции

Несмотря на то, что JVM работает только с одним интерпретатором и двумя JIT-компиляторами, существует пять возможных уровней компиляции . Причина этого в том, что компилятор C1 может работать на трех разных уровнях. Разница между этими тремя уровнями заключается в объеме выполненного профилирования.

4.1. Уровень 0 – Интерпретируемый код

Изначально JVM интерпретирует весь код Java . На этом начальном этапе производительность обычно не так хороша по сравнению с скомпилированными языками.

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

4.2. Уровень 1 — Простой скомпилированный код C1

На этом уровне JVM компилирует код с помощью компилятора C1, но без сбора информации о профилировании. JVM использует уровень 1 для методов, которые считаются тривиальными .

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

4.3. Уровень 2 — Ограниченный скомпилированный код C1

На уровне 2 JVM компилирует код с помощью компилятора C1 с легким профилированием. JVM использует этот уровень , когда очередь C2 заполнена . Цель состоит в том, чтобы как можно скорее скомпилировать код для повышения производительности.

Позже JVM перекомпилирует код на уровне 3, используя полное профилирование. Наконец, когда очередь C2 становится менее загруженной, JVM перекомпилирует ее на уровне 4.

4.4. Уровень 3 — Полный скомпилированный код C1

На уровне 3 JVM компилирует код с помощью компилятора C1 с полным профилированием. Уровень 3 является частью пути компиляции по умолчанию. Таким образом, JVM использует его во всех случаях, кроме тривиальных методов или когда очереди компилятора переполнены .

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

4.5. Уровень 4 — Скомпилированный код C2

На этом уровне JVM компилирует код с помощью компилятора C2 для максимальной долговременной производительности. Уровень 4 также является частью пути компиляции по умолчанию. JVM использует этот уровень для компиляции всех методов, кроме тривиальных .

Учитывая, что код уровня 4 считается полностью оптимизированным, JVM прекращает сбор информации о профилировании. Однако он может решить деоптимизировать код и отправить его обратно на уровень 0.

5. Параметры компиляции

Многоуровневая компиляция включена по умолчанию, начиная с Java 8 . Настоятельно рекомендуется использовать его, если нет веских причин для его отключения.

5.1. Отключение многоуровневой компиляции

Мы можем отключить многоуровневую компиляцию, установив флаг –XX:-TieredCompilation . Когда мы устанавливаем этот флаг, JVM не будет переходить между уровнями компиляции. В результате нам нужно будет выбрать, какой JIT-компилятор использовать: C1 или C2.

Если явно не указано иное, JVM решает, какой JIT-компилятор использовать, исходя из нашего ЦП. Для многоядерных процессоров или 64-разрядных виртуальных машин JVM выберет C2. Чтобы отключить C2 и использовать только C1 без дополнительных затрат на профилирование, мы можем применить параметр -XX:TieredStopAtLevel=1 .

Чтобы полностью отключить оба JIT-компилятора и запустить все с помощью интерпретатора, мы можем применить флаг -Xint . Однако следует отметить, что отключение JIT-компиляторов отрицательно скажется на производительности .

5.2. Установка порогов для уровней

Порог компиляции — это количество вызовов метода перед компиляцией кода . В случае многоуровневой компиляции мы можем установить эти пороги для уровней компиляции 2-4. Например, мы можем установить параметр -XX:Tier4CompileThreshold=10000 .

Чтобы проверить пороговые значения по умолчанию, используемые в конкретной версии Java, мы можем запустить Java, используя флаг -XX:+PrintFlagsFinal :

java -XX:+PrintFlagsFinal -version | grep CompileThreshold
intx CompileThreshold = 10000
intx Tier2CompileThreshold = 0
intx Tier3CompileThreshold = 2000
intx Tier4CompileThreshold = 15000

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

6. Компиляция метода

Давайте теперь посмотрим на жизненный цикл компиляции метода:

./6c552d576320a106f99bba78484109d7.png

Таким образом, JVM первоначально интерпретирует метод, пока его вызовы не достигнут уровня Tier3CompileThreshold . Затем он компилирует метод с помощью компилятора C1, в то время как информация о профилировании продолжает собираться . Наконец, JVM компилирует метод с помощью компилятора C2, когда его вызовы достигают уровня Tier4CompileThreshold . В конце концов, JVM может решить деоптимизировать скомпилированный код C2. Это означает, что весь процесс будет повторяться.

6.1. Журналы компиляции

По умолчанию журналы компиляции JIT отключены. Чтобы включить их, мы можем установить флаг -XX:+PrintCompilation . Журналы компиляции имеют следующий формат:

  • Отметка времени — в миллисекундах с момента запуска приложения.

  • Идентификатор компиляции — инкрементный идентификатор для каждого скомпилированного метода.

  • Атрибуты — состояние компиляции с пятью возможными значениями:

  • % — Произошла замена в стеке

  • s — метод синхронизирован

  • ! – Метод содержит обработчик исключений

  • б – компиляция произошла в режиме блокировки

  • n — компиляция преобразовала оболочку в нативный метод

  • Уровень компиляции – от 0 до 4

  • Имя метода

  • Размер байт-кода

  • Индикатор деоптимизации – с двумя возможными значениями:

  • Сделано не входящими - стандартная деоптимизация C1 или оптимистичные предположения компилятора оказались ошибочными

  • Сделано зомби - механизм очистки для сборщика мусора, чтобы освободить место из кеша кода.

6.2. Пример

Продемонстрируем жизненный цикл компиляции метода на простом примере. Во-первых, мы создадим класс, реализующий средство форматирования JSON:

public class JsonFormatter implements Formatter {

private static final JsonMapper mapper = new JsonMapper();

@Override
public <T> String format(T object) throws JsonProcessingException {
return mapper.writeValueAsString(object);
}

}

Далее мы создадим класс, который реализует тот же интерфейс, но реализует средство форматирования XML:

public class XmlFormatter implements Formatter {

private static final XmlMapper mapper = new XmlMapper();

@Override
public <T> String format(T object) throws JsonProcessingException {
return mapper.writeValueAsString(object);
}

}

Теперь мы напишем метод, использующий две разные реализации средства форматирования. В первой половине цикла мы будем использовать реализацию JSON, а затем переключимся на XML для остальных:

public class TieredCompilation {

public static void main(String[] args) throws Exception {
for (int i = 0; i < 1_000_000; i++) {
Formatter formatter;
if (i < 500_000) {
formatter = new JsonFormatter();
} else {
formatter = new XmlFormatter();
}
formatter.format(new Article("Tiered Compilation in JVM", "ForEach"));
}
}

}

Наконец, мы установим флаг -XX:+PrintCompilation , запустим основной метод и просмотрим журналы компиляции.

6.3. Журналы просмотра

Давайте сосредоточимся на выводе журнала для наших трех пользовательских классов и их методов.

Первые две записи журнала показывают, что JVM скомпилировала основной метод и JSON-реализацию метода форматирования на уровне 3. Следовательно, оба метода были скомпилированы компилятором C1. Скомпилированный код C1 заменил первоначально интерпретированную версию:

567  714       3       com.foreach.tieredcompilation.JsonFormatter::format (8 bytes)
687 832 % 3 com.foreach.tieredcompilation.TieredCompilation::main @ 2 (58 bytes)

Через несколько сотен миллисекунд JVM скомпилировала оба метода на уровне 4. Следовательно, скомпилированные версии C2 заменили предыдущие версии, скомпилированные с помощью C1 :

659  800       4       com.foreach.tieredcompilation.JsonFormatter::format (8 bytes)
807 834 % 4 com.foreach.tieredcompilation.TieredCompilation::main @ 2 (58 bytes)

Всего через несколько миллисекунд мы видим наш первый пример деоптимизации. Здесь JVM пометила устаревшие (не входящие) скомпилированные версии C1:

812  714       3       com.foreach.tieredcompilation.JsonFormatter::format (8 bytes)   made not entrant
838 832 % 3 com.foreach.tieredcompilation.TieredCompilation::main @ 2 (58 bytes) made not entrant

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

1015  834 %     4       com.foreach.tieredcompilation.TieredCompilation::main @ 2 (58 bytes)   made not entrant
1018 800 4 com.foreach.tieredcompilation.JsonFormatter::format (8 bytes) made not entrant

Далее мы впервые увидим XML-реализацию метода форматирования . JVM скомпилировала его на уровне 3 вместе с основным методом:

1160 1073       3       com.foreach.tieredcompilation.XmlFormatter::format (8 bytes)
1202 1141 % 3 com.foreach.tieredcompilation.TieredCompilation::main @ 2 (58 bytes)

Через несколько сотен миллисекунд JVM скомпилировала оба метода на уровне 4. Однако на этот раз основным методом использовалась реализация XML:

1341 1171       4       com.foreach.tieredcompilation.XmlFormatter::format (8 bytes)
1505 1213 % 4 com.foreach.tieredcompilation.TieredCompilation::main @ 2 (58 bytes

Как и раньше, через несколько миллисекунд JVM пометила устаревшие (не новые) скомпилированные версии C1:

1492 1073       3       com.foreach.tieredcompilation.XmlFormatter::format (8 bytes)   made not entrant
1508 1141 % 3 com.foreach.tieredcompilation.TieredCompilation::main @ 2 (58 bytes) made not entrant

JVM продолжала использовать скомпилированные методы уровня 4 до конца нашей программы.

7. Заключение

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

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

Как всегда, исходный код доступен на GitHub .