JIT-компилятор и как он нам поможет победить latency

JIT-компилятор — встроенный в JVM компилятор байткода в машинный код. Проведя множество интервью с кандидатами на роль Core Java Developer, я был удивлен тем, что многие кандидаты даже не подозревали о существовании такой технологии в JVM, а те, кто краем уха что-то слышал, не могли объяснить, как она работает. Может быть для написания J2EE или веб-приложений этих знаний и не требуется, но для работы в области low-latency trading эти знания — ключевые.

Описание JIT-компилятора хорошо дано в книгах:

В данной статье я просто изложу основы, без знания которых непонятны будут другие статьи по low-latency оптимизации JVM.

Основы

Самая первая версия JVM поставлялась вообще без JIT-компилятора. Байт-код интерпретировался интерпретатором на лету и, конечно, работал очень медленно. Чтобы понять, как медленно работает Java в режиме интерпретатора, запустите любое Java-приложение с параметром -Xint, который полностью отключает JIT-компилятор.

Для борьбы с этой черепашьей медленностью в JVM был внесен JIT-компилятор, который собирает по мере работы программы статистику по наиболее часто выполняемым участкам кода (hot spots) и через некоторое время на лету подменяет байт-код нативным машинным кодом, специфичным для данного процессора. Нативный код исполняется намного быстрее интерпретируемого, следовательно по мере работы Java-приложения все больше байт-кода заменяется на машинный и приложение начинает работать все шустрее и шустрее.

Более того, по мере долгой работы приложения может выясниться, что точки кода, которые были «горячими», «остыли», а «горячими» стали другие порции кода. JIT-компилятор следит за этим и делает перекомпиляцию на лету. Это означает, что JIT-компилятор не просто оптимизирует Java-программу по мере ее исполнения, а делает это постоянно.

Два в одном

В современной JVM на самом деле два JIT-компилятора: клиентский и серверный. Разные Java-приложения имеют разные требования: клиентские программы должны быстро стартовать, чтобы пользователь мог быстро приступить к работе, а серверные — не так критичны к времени старта (разницы нет, займет старт 1 минуту или 1 минуту 10 секунд), работают долго (несколько дней или даже недель), но критичны к общей производительности.

Эти требования отражены в двух JIT-компиляторах: клиентский (параметр запуска -client) компилирует байт-код быстро и без особых оптимизаций; и серверный (параметр запуска -server) долго собирает статистику, потом долго компилирует код, но зато нативный код получается хорошо оптимизированным. Клиентский компилятор принято в литературе обозначать C1, а серверный — C2. Таким образом C1 является throughput-компилятором, который быстренько компилирует интерпретируемый код в нативный, а потом собирает статистику для запуска C2 компилятора, а C2 является оптимизирующим компилятором.

Как выбрать?

Выбор компилятора для вашего приложения теоретически осуществляется с помощью параметров -client и -server. Но JVM делает хитрый ход: если машина, где вы запускаете приложение, серверного класса — она запустит серверный компилятор, даже если вы в стартовом параметре укажете -client.

Что такое «машина серверного класса»? Это машина, на которой установлена OS Solaris или OS Linux, или Windows-машина, на которой есть больше 16 Gb памяти и/или несколько процессоров. Понятно, что в наше время не удивишь никого десктопами даже с 32 Gb RAM, а уж процессоры с количеством ядер меньше двух вообще сейчас днем с огнем не сыщешь. Так что очень сильно надо постараться (об этом ниже), чтобы запустить JVM только с клиентским компилятором на современных машинах.

Сюрпризы Java 8

Начиная с Java 8 вообще отказались от ключей -server и -client. Они там теперь не работают. По умолчанию начиная с Java 8 используется так называемая ступенчатая, поэтапаная компиляция (tiered compilation). Она сочетает в себе достоинства и -client и -server. При запуске сначала код некоторое время исполняется интерпретатором, потом быстренько компилируется клиентским компилятором C1, потом собирается статистика и, если приложение еще продолжает работать, вступает в ход серверный компилятор C2.

Ступенчатую компиляцию можно отключить стартовым параметром -XX:-TieredCompilation. И тогда JVM будет использовать только серверный JIT-компилятор.

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

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

В реалии JIT-компиляция осуществляется в несколько этапов-уровней:

Level 0 (tier 0): интерпретатор — работает несколько секунд при запуске

C1 Level 1 (tier 1): первоначальная клиентская компиляция

C1 Level 2 (tier 2): дополнительная клиентская компиляция, но работает медленнее, чем level 1, потому что идет одновременно сбор статистики

C1 Level 3 (tier 3): дополнительная клиентская компиляция, работает быстрее, чем level 2, но опять идет сбор статистики

C2 Level 4 (tier 4): серверная компиляция. Оптимизирующая компиляция. Код работает быстрее.

Переход с одного уровня на другой осуществляется для каждого участка кода после определенного количества его исполнения. Увидеть значения по умолчанию можно в списке опций, выдаваемых стартовым параметром -XX:+PrintFlagsFinal

intx CompileThreshold = 10000 {pd product}
intx Tier0BackedgeNotifyFreqLog = 10 {product}
intx Tier0InvokeNotifyFreqLog = 7 {product}
intx Tier0ProfilingStartPercentage = 200 {product}
intx Tier23InlineeNotifyFreqLog = 20 {product}
intx Tier2BackEdgeThreshold = 0 {product}
intx Tier2BackedgeNotifyFreqLog = 14 {product}
intx Tier2CompileThreshold = 0 {product}
intx Tier2InvokeNotifyFreqLog = 11 {product}
intx Tier3BackEdgeThreshold = 60000 {product}
intx Tier3BackedgeNotifyFreqLog = 13 {product}
intx Tier3CompileThreshold = 2000 {product}
intx Tier3DelayOff = 2 {product}
intx Tier3DelayOn = 5 {product}
intx Tier3InvocationThreshold = 200 {product}
intx Tier3InvokeNotifyFreqLog = 10 {product}
intx Tier3LoadFeedback = 5 {product}
intx Tier3MinInvocationThreshold = 100 {product}
intx Tier4BackEdgeThreshold = 40000 {product}
intx Tier4CompileThreshold = 15000 {product}
intx Tier4InvocationThreshold = 5000 {product}
intx Tier4LoadFeedback = 3 {product}
intx Tier4MinInvocationThreshold = 600 {product}

Переход от одного уровня к другому не линейный. Он может быть разным в зависимости от конкретной ситуации. Самый простой сценарий: tier 0 -> после 10.000 вызовов -> tier 3 -> после 15.000 вызовов -> tier 4. Про более сложные сценарии можно посмотреть в этой презентации.

Рекомпиляция и декомпиляция

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

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

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

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

Опции JIT-компиляции

-XX:+/-TieredCompilation — см. выше

-XX:CompileThreshold=<> Tier2CompileThreshold=<> Tier3CompileThreshold=<> Tier4CompileThreshold=<> — этими опциями мы можем менять пороговые значения, когда вступает в игру тот или иной уровень компиляции. Если, например, мы не хотим ждать 15.000 вызовов метода, чтобы получить код уровня Level 4, можно выставит значение Tier4CompileThreshold=1000. Мы получим Level 4 код после 1000 вызовов метода, но учтите, что за 1000 вызовов особой статистики не соберешь, и на минимуме статистики мы получим минимум оптимизаций серверного уровня.

-XX:CICompilerCount=<> — компилятор работает в нескольких потоках. С помощью этого параметра мы управляем количеством потоков.

-XX:+/-UseCodeCacheFlushing — будем ли мы очищать время от времени кэш, куда записывается нативный код или нет

-XX:InitialCodeCacheSize=<> — размер кэша для нативного кода

-XX:+/-DontCompileHugeMethods — будет ли компилятор заниматься большими методами или нет

Нативный код

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

Для этого требуется подключение к JVM сторонней библиотеки hsdis для вашей операционной системы и стартовые параметры: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly.

Добавить комментарий

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

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s