Jit-Компилятор Java Hotspot — Устройство, Мониторинг И Настройка (Часть 2)

В предыдущий В этой статье мы рассмотрели структуру JIT-компилятора и способы контроля его работы.

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



Счетчики вызовов методов и циклов

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

Решение принимается на основе двух счетчиков: счетчика количества вызовов метода и счетчика количества итераций цикла в методе.

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

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

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

Когда многоуровневая компиляция отключена, стандартная компиляция контролируется параметром -XX:Порог компиляции .

Значение по умолчанию — 10000. Хотя параметр всего один, превышение порога определяется суммой значений двух счетчиков.

В настоящее время этот флаг не имеет никакого эффекта (многоуровневая компиляция включена по умолчанию), но об этом полезно знать, поскольку существует довольно много устаревших систем.

Раньше уменьшение значения этого параметра использовалось для ускорения запуска приложения при использовании серверного компилятора, поскольку это приводило к более ранней компиляции кода.

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

Последнее утверждение весьма интересно.

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

Таким образом, они являются отражением текущего «нагрева» метода или цикла.

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

В настоящее время такие методы будут компилироваться компилятором C1, хотя, вероятно, они работали бы лучше, если бы их компилировали компилятор C2. При желании можно поиграться с параметрами -XX:Tier3InvoctionThreshold (значение по умолчанию 200) и -XX:Tier4InvoctionThreshold (значение по умолчанию — 5000), но вряд ли это имеет большой практический смысл.

Те же параметры ( -XX:TierXBackEdgeThreshold ) также существуют для установки пороговых значений счетчиков циклов.



Темы компиляции

Как только JVM решает скомпилировать метод или цикл, он ставится в очередь.

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

Таким образом, более важный код будет скомпилирован раньше.

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

Существует специальная формула расчета количества потоков в зависимости от количества ядер.

Некоторые значения приведены в таблице:

Количество ядер Количество потоков C1 Количество потоков C2
1 1 1
2 1 1
4 1 2
8 1 2
16 2 6
32 3 7
64 4 8
128 4 10
Вы можете установить произвольное количество потоков с помощью параметра -XX:CICompilerCount .

Это общее количество потоков компиляции, которые будет использовать JVM. При включенной многоуровневой компиляции одна треть из них (но хотя бы одна) будет отдана компилятору С1, остальные — компилятору С2. Значением по умолчанию для этого флага является сумма потоков из таблицы выше.

Когда многоуровневая компиляция отключена, все потоки переходят к компилятору C2. Когда имеет смысл менять настройки по умолчанию? Ранние версии Java 8 (до обновления 191) некорректно определяли количество ядер при работе в контейнере Docker. Вместо количества ядер, выделенных контейнеру, определялось количество ядер на сервере.

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

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

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

Еще один параметр, влияющий на количество потоков компиляции, — -XX:+BackgroundCompilation .

Его значение по умолчанию — true. Это означает, что компиляция должна происходить асинхронно.

Если установлено значение false, то при наличии кода, который необходимо скомпилировать, JVM будет ждать завершения компиляции перед выполнением этого кода.



Оптимизации

Как мы знаем, JVM использует результаты профилирования методов в процессе компиляции.

JVM хранит данные профиля в объектах, называемых объектами данных метода (MDO).

Объекты MDO используются интерпретатором и компилятором C1 для записи информации, которая затем используется для принятия решения о том, какие оптимизации можно применить к компилируемому коду.

Объекты MDO хранят информацию о вызываемых методах, ветвях, взятых в операторах ветвления, и наблюдаемых типах в точках вызова.

После принятия решения о компиляции компилятор создает внутреннее представление компилируемого кода, которое затем оптимизируется.

Компиляторы способны выполнять широкий спектр оптимизаций, в том числе:

  • встраивание;
  • escape-анализ;
  • разворачивание петли;
  • мономорфная диспетчеризация;
  • внутренние методы.



Встраивание

Встраивание — это копирование вызванного метода в то место, где он вызывается.

Это устраняет накладные расходы, связанные с вызовом метода, например:

  • подготовка параметров;
  • поиск по таблице виртуальных методов;
  • создание и инициализация объекта Stack Frame;
  • передача контроля;
  • необязательное возвращаемое значение.

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

Вы можете отключить его с помощью флага -XX:-Встроенный , хотя это не рекомендуется.

JVM решает, следует ли встраивать метод, на основе нескольких факторов, некоторые из которых перечислены ниже.

  • Размер метода.

    «Горячие» методы являются кандидатами на встраивание, если их размер меньше 325 байт (или меньше размера, указанного параметром -XX:MaxFreqInlineSize ).

    Если метод вызывается не очень часто, он является кандидатом на встраивание только в том случае, если его размер меньше 35 байт (или меньше размера, указанного параметром -XX:МаксИнлининесизе ).

  • Позиция метода в цепочке вызовов.

    Методы с позицией больше 9 (или значения, указанного параметром) не могут быть внедрены.

    -XX:МаксИнлинелевель ).

  • Размер памяти, занимаемой уже скомпилированными версиями метода в кеше кода.

    Методы, скомпилированные на последнем уровне, версии которых занимают более 1000 байт при отключенной многоуровневой компиляции и 2000 байт при включении (или значения, заданного параметром -XX:InlineSmallCode ).



Анализ побега

Escape-анализ — это метод анализа кода, который позволяет определить пределы достижимости объекта.

Например, escape-анализ можно использовать, чтобы определить, доступен ли объект, созданный внутри метода, за пределами области действия метода.

Сам по себе эскейп-анализ не является оптимизацией, но на основе его результатов можно провести оптимизацию.



Предотвращение выделения кучи

Создание новых объектов внутри циклов может создать нагрузку на систему распределения памяти.

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

Если частота создания объектов достаточно высока, недолговечные объекты могут оказаться в старом поколении, что потребует и без того «дорогой» полной сборки мусора.

Если JVM определяет, что объект недоступен за пределами метода, она может применить метод оптимизации, называемый скаляризацией.

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

Если регистров недостаточно, скалярные значения можно хранить в стеке.



Анализ замков и побегов

JVM может использовать результаты анализа выхода для оптимизации производительности блокировки.

Это применимо только к блокировкам, использующим ключевое слово Synchronized; блокировки из пакета java.util.concurrent не подлежат такой оптимизации.

Возможные варианты оптимизации приведены ниже.

  • Снятие блокировок с объектов, которые недоступны за пределами области видимости (lock elision).

  • Объединение последовательных синхронизированных разделов с использованием одного и того же объекта синхронизации (укрупнение блокировки).

    Вы можете отключить эту оптимизацию, используя флаг -XX:-Устранить блокировки .

  • Определение и удаление вложенных блокировок на одном и том же объекте.

    Вы можете отключить эту оптимизацию, используя флаг -XX:-EliminateNestedLocks .



Ограничения анализа побега

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

Например, массивы размером более 64 элементов не экранируются.

Этот размер можно установить с помощью параметра -XX:EliminateAllocationArraySizeLimit .

Представьте себе код, который в цикле создает временный массив.

Если массив недоступен вне метода, его не следует создавать в куче.

Но если его размер больше 64 элементов, он будет создан там, даже если на самом деле используется не весь массив.

Еще одним ограничением является то, что частичное экранирование не поддерживается.

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

Пример такого кода приведен ниже.

  
  
  
  
   

for (int i = 0; i < 100_000_000; i++) { Object mightEscape = new Object(i); if (condition) { result += inlineableMethod(mightEscape); } else { result += tooBigToInline(mightEscape); } }

Но если вам удастся локализовать создание объекта внутри ветки, в которой объект не выходит за рамки, то эта оптимизация будет применена в этой ветке.



if (condition) { Object mightEscape = new Object(i); result += inlineableMethod(mightEscape); } else { Object mightEscape = new Object(i); result += tooBigToInline(mightEscape); } }



Разматывание (раскручивание) цикла

После встраивания всех возможных вызовов методов в цикл JVM может оценить «стоимость» каждой итерации и определить, следует ли применять оптимизацию, называемую разматыванием цикла.

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

Каждая итерация цикла отрицательно влияет на работу процессора, т.к.

сбрасывает конвейер команд. Чем короче тело цикла, тем выше «стоимость» итерации.

В результате раскрутки цикла получится следующий код:

int i; for ( i = 1; i < n; i++) { a[i] = (i % b[i]); }

преобразуется в код типа:

int i; for (i = 1; i < n - 3; i += 4) { a[i] = (i % b[i]); a[i + 1] = ((i + 1) % b[i + 1]); a[i + 2] = ((i + 2) % b[i + 2]); a[i + 3] = ((i + 3) % b[i + 3]); } for (; i < n; i++) { a[i] = (i % b[i]); }

JVM решает, следует ли разворачивать цикл, на основе нескольких критериев:

  • в зависимости от типа счетчика цикла он должен быть одного из типов int, short или char;
  • по значению, на которое изменяется счетчик цикла на каждой итерации;
  • по количеству точек выхода из цикла.



Мономорфная отправка

Многие оптимизации, выполняемые компилятором C2, основаны на эмпирических наблюдениях.

Одним из примеров является оптимизация, называемая мономорфной диспетчеризацией.

Он основан на том, что очень часто в точках вызова тип объекта остается неизменным во время выполнения.

Это связано с особенностями объектно-ориентированного проектирования.

Например, при вызове метода объекта наблюдаемый тип объекта при первом и последующих вызовах будет одинаковым.

Если это предположение верно, то вызов метода на этом этапе можно оптимизировать.

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

Достаточно один раз определить тип целевого объекта и заменить вызов виртуального метода быстрой проверкой типа и прямым вызовом метода для типа целевого объекта.

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

JVM также поддерживает биморфную диспетчеризацию.

Это позволяет вам выполнять быстрые вызовы методов в одной точке для объектов двух разных типов.

Вызовы, которые не являются ни мономорфными, ни биморфными, называются мегаморфными.

Если вы не видите очень большого количества типов в точке вызова, использование одного трюка может дать вам некоторый прирост производительности.

Достаточно «отделить» от точки вызова несколько типов с помощью оператора instanceof, чтобы в ней осталось только 2 конкретных типа.

Ниже приведены примеры вызовов биморфа, мегаморфа и сплит-мегаморфа.



interface Shape {

Теги: #программирование #java #jvm #jit-компилятор #компиляторы #компилятор

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.