В предыдущей статье мы рассмотрели устройство JIT компилятора и способы мониторинга его работы. В этой статье мы рассмотрим счетчики, которые JVM использует для принятия решения о необходимости компиляции кода, потоки компиляции, оптимизации, выполняемые JVM при компиляции, а также что такое деоптимизация кода.
Счетчики вызовов методов и итераций циклов
Главным фактором влияющим на решение JVM о компиляции какого-либо кода является частота его исполнения. Решение принимается на основе двух счетчиков: счетчика количества вызовов метода и счетчика количества итераций циклов в методе.
Когда JVM исполняет какой-либо метод, она проверяет значения этих двух счетчиков, и принимает решение о необходимости его компиляции. У этого типа компиляции нет официального названия, но часто его называют стандартной компиляцией.
Аналогично, после каждой итерации цикла проверяется значение счетчика цикла и принимается решение о необходимости его компиляции.
При выключенной многоуровневой компиляции стандартная компиляция управляется параметром -XX:CompileThreshold. Значением по умолчанию является 10000. Несмотря на то, что параметр всего один, превышение порога определяется суммой значений двух счетчиков. В настоящее время этот флаг ни на что не влияет (многоуровневая компиляция включена по умолчанию), но знать о нем полезно, ведь существует довольно много унаследованных систем.
Ранее уменьшением значения этого параметра добивались ускорения старта приложения при использовании серверного компилятора, поскольку это приводило к более ранней компиляции кода. Также уменьшение его значения могло способствовать компиляции методов, которые иначе никогда бы не скомпилировались.
Последнее утверждение довольно интересно. Ведь если программа исполняется бесконечно, не должен ли весь ее код в конце концов скомпилироваться? На самом деле не должен, поскольку значения счетчиков не только увеличиваются при каждом вызове метода или итерации цикла, но и периодически уменьшаются. Таким образом, они являются отражением текущего «нагрева» метода или цикла.
Получается, что до внедрения многоуровневой компиляции методы, выполнявшиеся довольно часто, но недостаточно часто, чтобы превысить порог, никогда бы не были скомпилированы. В настоящее время такие методы будут скомпилированы компилятором C1, хотя, возможно, их производительность была бы выше, будь они скомпилированы компилятором C2. При желании можно поиграть параметрами -XX:Tier3InvocationThreshold (значение по умолчанию 200) и -XX:Tier4InvocationThreshold (значение по умолчанию 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. При включенной многоуровневой компиляции одна треть из них (но минимум один) будут отданы компилятору C1, остальные достанутся компилятору C2. Значением по умолчанию для этого флага является сумма потоков из таблицы выше. При выключенной многоуровневой компиляции все потоки достанутся компилятору C2.
В каком случае имеет смысл менять настройки по умолчанию? Ранние версии Java 8 (до update 191) при запуске в Docker контейнере не корректно определяли количество ядер. Вместо количества ядер, выделенных контейнеру определялось количество ядер на сервере. В этом случае есть смысл задать количество потоков вручную, исходя из значений, приведенных в таблице выше.
Аналогично, при запуске приложения в одноядерной виртуальной машине может оказаться предпочтительнее иметь только один поток компиляции, чтобы избежать борьбы за процессорное время. Но надо иметь ввиду, что выгода от наличия всего одного потока проявляется только при старте и «прогреве» приложения, после этого количество методов, ожидающих компиляцию, будет не велико.
Еще один параметр, влияющий на количество потоков компиляции - это -XX:+BackgroundCompilation. Его значение по умолчанию - true. Он означает, что компиляция должна происходить в асинхронном режиме. Если установить его в false, каждый раз при наличии кода, который необходимо скомпилировать, JVM будет ожидать завершения компиляции, прежде чем этот код исполнить.
Оптимизации
Как мы знаем, JVM использует результаты профилирования методов в процессе компиляции. JVM хранит данные профиля в объектах, называемых method data objects (MDO). Объекты MDO используются интерпретатором и компилятором C1 для записи информации, которая затем используется для принятия решения о том, какие оптимизации возможно применить к компилируемому коду. Объекты MDO хранят информацию о вызванных методах, выбранных ветвях в операторах ветвления, наблюдаемых типах в точках вызова. Как только принято решение о компиляции, компилятор строит внутреннее представление компилируемого кода, которое затем оптимизируется. Компиляторы способны проводить широкий набор оптимизаций, включающий:
встраивание (inlining);
escape-анализ (escape-analysis);
размотка (раскрутка) цикла (loop unrolling);
мономорфная диспетчеризация (monomorphic dispatch);
intrinsic-методы.
Встраивание
Встраивание - это копирование вызываемого метода в место его вызова. Это позволяет устранить накладные расходы, связанные с вызовом метода, такие как:
подготовка параметров;
поиск по таблице виртуальных методов;
создание и инициализация объекта Stack Frame;
передача управления;
опциональный возврат значения.
Встраивание является одной из оптимизаций, выполняемых JVM в первую очередь, оно включено по умолчанию. Отключить его можно флагом -XX:-Inline, хотя делать этого не рекомендуется. JVM принимает решение о необходимости встраивания метода на основе нескольких факторов, некоторые из которых приведены ниже.
Размер метода. «Горячие» методы являются кандидатами для встраивания если их размер меньше 325 байт (или меньше размера заданного параметром -XX:MaxFreqInlineSize). Если метод вызывается не так часто, он является кандидатом для встраивания только если его размер меньше 35 байт (или меньше размера заданного параметром -XX:MaxInlineSize).
Позиция метода в цепочке вызовов. Не подлежат встраиванию методы с позицией больше 9 (или значения заданного параметром -XX:MaxInlineLevel).
Размер памяти, занимаемой уже скомпилированными версиями метода в code cache. Встраиванию не подлежат методы, скомпилированные на последнем уровне, версии которых занимают более 1000 байт при выключенной многоуровневой компиляции и 2000 байт при включенной (или значения заданного параметром -XX:InlineSmallCode).
Escape-анализ
Escape-анализ - это техника анализа кода, которая позволяет определить пределы достижимости какого-либо объекта. Например, escape-анализ может использоваться для определения является ли объект созданный внутри метода достижимым за пределами области видимости метода. Сам по себе escape-анализ не является оптимизацией, но оптимизации могут выполняются по его результатам.
Предотвращение выделений памяти в куче
Создание новых объектов внутри циклов может создать нагрузку на систему выделения памяти. Создание большого числа короткоживущих объектов потребует частых сборок мусора в молодом поколении. Если частота создания объектов будет достаточно большой, короткоживущие объекты могут попасть и в старое поколение, что потребует уже «дорогостоящей» полной сборки мусора. Если JVM убедится, что объект не достижим за пределами области видимости метода, она может применить технику оптимизации, называемую скаляризация. Поля объекта станут скалярными значениями и будут храниться в регистрах процессора. Если регистров не достаточно, скалярные значения могут храниться в стеке.
Блокировки и escape-анализ
JVM способна использовать результаты escape-анализа для оптимизации производительности блокировок. Это относится только к блокировкам с помощью ключевого слова synchronized, блокировки из пакета java.util.concurrent таким оптимизациям не подвержены. Возможными оптимизации приведены ниже.
Удаление блокировок с объектов недостижимых за пределами области видимости (lock elision).
Объединение последовательных синхронизированных секций, использующих один и тот же объект синхронизации (lock coarsening). Выключить эту оптимизацию можно флагом -XX:-EliminateLocks.
Определение и удаление вложенных блокировок на одном и том же объекте (nested locks). Выключить эту оптимизацию можно флагом -XX:-EliminateNestedLocks.
Ограничения escape-анализа
Поскольку регистры процессора и стек являются ресурсами довольно ограниченными, существуют ограничения и на их использование. Так, например, массивы размером более 64 элементов не участвуют в escape-анализе. Этот размер можно задавать параметром -XX:EliminateAllocationArraySizeLimit. Представьте код, создающий временный массив в цикле. Если массив не достижим за пределами метода, массив не должен создаваться в куче. Но если его размер больше 64 элементов, он будет создаваться именно там, даже при условии, что реально используется не весь массив.
Еще одно ограничение заключается в том, что частичный escape-анализ не поддерживается. Если объект выходит за пределы области видимости метода хотя бы по одной из веток, оптимизация по предотвращению создания объекта в куче не применима. Пример подобного кода приведен ниже.
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 {
int getSides();
}
class Triangle implements Shape {
public int getSides() {
return 3;
}
}
class Square implements Shape {
public int getSides() {
return 4;
}
}
class Octagon implements Shape {
public int getSides() {
return 8;
}
}
class Example {
private Random random = new Random();
private Shape triangle = new Triangle();
private Shape square = new Square();
private Shape octagon = new Octagon();
public int getSidesBimorphic() {
Shape currentShape = null;
switch (random.nextInt(2)) {
case 0:
currentShape = triangle;
break;
case 1:
currentShape = square;
break;
}
return currentShape.getSides();
}
public int getSidesMegamorphic() {
Shape currentShape = null;
switch (random.nextInt(3))
{
case 0:
currentShape = triangle;
break;
case 1:
currentShape = square;
break;
case 2:
currentShape = octagon;
break;
}
return currentShape.getSides();
}
public int getSidesPeeledMegamorphic() {
Shape currentShape = null;
switch (random.nextInt(3))
{
case 0:
currentShape = triangle;
break;
case 1:
currentShape = square;
break;
case 2:
currentShape = octagon;
break;
}
// peel one observed type from the original call site
if (currentShape instanceof Triangle) {
return ((Triangle) currentShape).getSides();
}
else {
return currentShape.getSides(); // now only bimorphic
}
}
}
Intrinsic-методы
Intrinsic-методы - это оптимизированные нативные реализации методов готовые к использованию JVM. Обычно это базовые, критичные к производительности методы, использующие специфичные функции операционной системы (ОС) или архитектуры процессора. Из-за этого они являются платформо-зависимыми и некоторые из них могут поддерживаться не каждой платформой. Примеры intrinsic-методов приведены в таблице ниже.
Метод | Описание |
java.lang.System.arraycopy() | Быстрое копирование, используя векторную поддержку процессора. |
java.lang.System.currentTimeMillis() | Быстрая реализация предоставляемая большинством ОС. |
java.lang.Math.min() | Может быть выполнено без ветвления на некоторых процессорах. |
Другие методы класса java.lang.Math | Прямая поддержка инструкций некоторыми процессорами. |
Криптографические функции | Может использоваться аппаратная поддержка на некоторых платформах. |
Шаблоны intrinsic-методов содержатся в исходном коде OpenJDK в файлах с расширением .ad (architecture dependent). Для архитектуры x86_64 они находятся в файле hotspot/src/cpu/x86/vm/x86_64.ad.
Деоптимизации
Когда мы рассматривали мониторинг работы компилятора в первой части, мы упомянули, что в логе могут появиться сообщения о деоптимизации кода. Деоптимизация означает откат ранее скомпилированного кода. В результате деоптимизации производительность приложения будет временно снижена. Существует два вида деоптимизации: недействительный код (not entrant code) и зомби код (zombie code).
Недействительный код
Код может стать недействительным в двух случаях:
при использовании полиморфизма;
в случае многоуровневой компиляции.
Полиморфизм
Рассмотрим пример:
Validator validator;
if (document.isOrder()) {
validator = new OrderValidator();
} else {
validator = new CommonValidator();
}
ValidationResult validationResult = validator.validate(document);
Выбор валидатора зависит от типа документа. Пусть для заказов у нас есть собственный валидатор. Предположим, что необходимо провалидировать большое количество заказов. Компилятор зафиксирует, что всегда используется валидатор заказов. Он встроит метод validate (если это возможно) и применит другие оптимизации. Далее, если на валидацию придет документ другого типа, предыдущее предположение окажется неверным, и сгенерированный код будет помечен как недействительный (non entrant). JVM перейдет на интерпретацию этого кода, и в будущем сгенерирует новую его версию.
Многоуровневая компиляция
В случае многоуровневой компиляции, когда код компилируется на новом уровне, его предыдущая версия также помечается недействительной.
Зомби код
Если в логе компиляции появилось сообщение о зомби коде, это значит, что все объекты, использующие предыдущие оптимизации были удалены из памяти и, как следствие, из code cache был удален код, ранее помеченный недействительным.
Список литературы и ссылки
Java Performance: In-Depth Advice for Tuning and Programming Java 8, 11, and Beyond, Scott Oaks. ISBN: 978-1-492-05611-9.
Optimizing Java: Practical Techniques for Improving JVM Application Performance, Benjamin J. Evans, James Gough, and Chris Newland. ISBN: 978-1-492-02579-5.