Когда Hotspot может размещать объекты в стеке?
Поскольку где-то около Java 6, Hotspot JVM может выполнять анализ с удалением и размещать неэкранирующие объекты в стеке, а не в куче, собираемой мусором. Это приводит к ускорению сгенерированного кода и снижает нагрузку на сборщик мусора.
Каковы правила, когда Hotspot может размещать объекты в стеке? Другими словами, когда я могу положиться на это, чтобы сделать распределение стека?
редактировать: этот вопрос является дубликатом, но (IMO) ответ ниже является лучшим ответом, чем тот, который имеется в исходном вопросе.
1 ответ
Я провел некоторые эксперименты, чтобы увидеть, когда Hotspot может выделять стеки. Оказывается, что его выделение стека немного более ограничено, чем вы можете ожидать на основании доступной документации. В статье Choi "Escape Analysis for Java" упоминается, что объект, который только когда-либо назначается локальным переменным, всегда может быть размещен в стеке. Но это неправда.
Все это детали реализации текущей реализации Hotspot, поэтому они могут измениться в будущих версиях. Это относится к моей установке OpenJDK версии 1.8.0_121 для X86-64.
Краткое резюме, основанное на некоторых экспериментах, выглядит следующим образом:
Hotspot может размещать экземпляр объекта в стеке, если
- все его использования встроены
- он никогда не присваивается никаким статическим или объектным полям, только локальным переменным
- в каждой точке программы локальные переменные, содержащие ссылки на объект, должны определяться во времени JIT и не зависеть от непредсказуемого потока условного управления.
- Если объект является массивом, его размер должен быть известен во время JIT, а для индексации в нем должны использоваться константы времени JIT.
Чтобы знать, когда эти условия выполняются, вам нужно немного узнать о том, как работает Hotspot. Полагаться на Hotspot для точного распределения стека в определенной ситуации может быть рискованно, так как здесь задействовано множество нелокальных факторов. Особенно зная, все ли встроено, предсказать сложно.
На практике простые итераторы, как правило, выделяются стеками, если вы просто используете их для итерации. Для составных объектов только внешний объект может быть выделен в стеке, поэтому списки и другие коллекции всегда вызывают выделение кучи.
Если у тебя есть HashMap<Integer,Something>
и вы используете его в myHashMap.get(42)
, 42
может выделяться в стеке в тестовой программе, но не в полном приложении, потому что вы можете быть уверены, что во всей программе будет более двух типов ключевых объектов в HashMaps, и, следовательно, методы hashCode и equals для выигранного ключа не встроенный
Кроме того, я не вижу никаких общепринятых правил, и это будет зависеть от специфики кода.
Горячие точки внутри
Первая важная вещь, которую нужно знать, это то, что анализ выхода выполняется после встраивания. Это означает, что анализ escape Hotspot в этом отношении является более мощным, чем описание в документе Choi, поскольку объект, возвращенный из метода, но локальный для метода вызывающей стороны, все еще может быть размещен в стеке. Из-за этого итераторы почти всегда могут быть выделены стеком, если вы делаете, например, for(Foo item : myList) {...}
(и реализация myList.iterator()
достаточно просто, что они обычно.)
Hotspot компилирует оптимизированные версии методов только после того, как определяет, что метод "горячий", поэтому код, который не запускается много раз, вообще не оптимизируется, и в этом случае нет никакого выделения стека или вставки вообще. Но для тех методов вы обычно не заботитесь.
Встраивание
Решения по встраиванию основаны на данных профилирования, которые Hotspot собирает первыми. Объявленные типы не имеют большого значения, даже если метод является виртуальным, Hotspot может встроить его в зависимости от типов объектов, которые он видит во время профилирования. Нечто подобное справедливо и для ветвей (т.е. операторов if и других конструкций потока управления): если во время профилирования Hotspot никогда не видит, что определенная ветвь взята, она скомпилирует и оптимизирует код, исходя из предположения, что ветвь никогда не берется. В обоих случаях, если Hotspot не может доказать, что его предположения всегда будут верны, он вставит проверки в скомпилированный код, известный как "необычные ловушки", и, если такая ловушка будет нажата, Hotspot будет де-оптимизировать и, возможно, повторно оптимизировать, взяв новая информация в учет.
Hotspot определит, какие типы объектов встречаются в качестве получателей на определенных сайтах вызовов. Если Hotspot видит только один тип или только два разных типа, встречающихся на сайте вызова, он может встроить вызываемый метод. Если есть только один или два очень распространенных типа, а другие типы встречаются гораздо реже, Hotspot также должен иметь возможность указывать методы общих типов, включая проверку того, какой код ему нужно принять. (Я не совсем уверен в этом последнем случае с одним или двумя общими типами и более необычными типами, хотя). Если существует более двух общих типов, Hotspot вообще не будет включать вызов, а вместо этого генерирует машинный код для косвенного вызова.
"Тип" здесь относится к точному типу объекта. Реализованные интерфейсы или общие суперклассы не учитываются. Даже если на сайте вызова присутствуют разные типы получателей, но все они наследуют одну и ту же реализацию метода (например, несколько классов, которые все наследуют hashCode
от Object
), Hotspot по-прежнему будет генерировать косвенный вызов, а не встроенный. (Так что в таких случаях imo hotspot довольно глупа. Я надеюсь, что будущие версии улучшат это.)
Hotspot также будет использовать только встроенные методы, которые не слишком велики. "Не слишком большой" определяется -XX:MaxInlineSize=n
а также -XX:FreqInlineSize=n
опции. Встроенные методы с размером байт-кода JVM ниже MaxInlineSize всегда встроены, методы с размером байт-кода JVM ниже FreqInlineSize встроены, если вызов "горячий". Большие методы никогда не используются. По умолчанию MaxInlineSize равен 35, а FreqInlineSize зависит от платформы, но для меня это 325. Поэтому убедитесь, что ваши методы не слишком велики, если вы хотите, чтобы они были встроенными. Иногда это может помочь выделить общий путь из большого метода, чтобы его можно было встроить в вызывающие объекты.
профилирование
При профилировании важно знать, что сайты профилирования основаны на байт-коде JVM, который сам по себе никак не встроен. Так что если у вас есть, например, статический метод
static <T,U> List<U> map(List<T> list, Function<T,U> func) {
List<U> result = new ArrayList();
for(T item : list) { result.add(func.call(item)); }
return result;
}
который отображает SAM Function
вызывается по списку и возвращает преобразованный список, Hotspot обработает вызов func.call
как единый сайт вызовов всей программы. Вы могли бы назвать это map
функционировать в нескольких местах вашей программы, передавая разные функции на каждом сайте вызовов (но один и тот же для одного сайта вызовов). В этом случае вы можете ожидать, что Hotspot может встроить map
, а затем также вызов func.call
так как при каждом использовании map
есть только один func
тип. Если бы это было так, Hotspot мог бы очень тесно оптимизировать цикл. К сожалению, Hotspot не достаточно умен для этого. Он сохраняет только один профиль для func.call
позвоните на сайт, собрав все func
типы, которые вы передаете map
все вместе. Вы, вероятно, будете использовать более двух разных реализаций func
Таким образом, Hotspot не сможет встроить вызов func.call
, Ссылка для более подробной информации, и архивная ссылка, поскольку оригинал, кажется, ушел.
(Кроме того, в Kotlin эквивалентный цикл может быть полностью встроенным, поскольку компилятор Kotlin может выполнять встраивание вызовов на уровне байт-кода. Поэтому для некоторых применений он может быть значительно быстрее, чем Java.)
Скалярная замена
Еще одна важная вещь, которую нужно знать, это то, что Hotspot фактически не реализует распределение объектов в стеке. Вместо этого он реализует скалярную замену, что означает, что объект деконструируется в составляющие его поля, и эти поля выделяются в стеке, как обычные локальные переменные. Это означает, что не осталось никакого объекта. Скалярная замена работает только в том случае, если нет необходимости создавать указатель на выделенный стеком объект. Некоторые формы выделения стека, например, в C++ или Go, могут выделять полные объекты в стеке, а затем передавать им ссылки или указатели на вызываемые функции, но в Hotspot это не работает. Следовательно, если когда-либо возникает необходимость передать ссылку на объект не встроенному методу, даже если ссылка не будет экранирована от вызываемого метода, Hotspot всегда будет выделять кучу такого объекта.
В принципе, Hotspot может быть умнее, но сейчас это не так.
Тестовая программа
Я использовал следующую программу и варианты, чтобы увидеть, когда Hotspot выполнит скалярную замену.
// Minimal example for which the JVM does not scalarize the allocation. If field is final, or the second allocation is unconditional, it will.
class Scalarization {
int field = 0xbd;
long foo(long i) { return i * field; }
public static void main(String[] args) {
long result = 0;
for(long i=0; i<100; i++) {
result += test();
}
System.out.println("Result: "+result);
}
static long test() {
long ctr = 0x5;
for(long i=0; i<0x10000; i++) {
Scalarization s = new Scalarization();
ctr = s.foo(ctr);
if(i == 0) s = new Scalarization();
ctr = s.foo(ctr);
}
return ctr;
}
}
Если вы скомпилируете и запустите эту программу с javac Scalarization.java; java -verbose:gc Scalarization
Вы можете увидеть, сработала ли скалярная замена по количеству сборщиков мусора. Если скалярная замена работает, сборка мусора в моей системе не происходит, если скалярная замена не работает, я вижу несколько сборок мусора.
Варианты, что Hotspot умеет скалярно работать, работают значительно быстрее, чем версии, где это не так. Я проверил сгенерированный машинный код ( инструкции), чтобы убедиться, что Hotspot не выполняет никаких неожиданных оптимизаций. Если горячая точка способна скалярно заменить распределения, она также может выполнить некоторые дополнительные оптимизации цикла, развернув его на несколько итераций, а затем объединяя эти итерации вместе. Таким образом, в скалярных версиях эффективное число циклов меньше с каждым итератом, выполняющим несколько итераций на уровне исходного кода. Таким образом, разница в скорости связана не только с распределением и затратами на сборку мусора.
наблюдения
Я попробовал несколько вариантов вышеупомянутой программы. Одно условие для скалярной замены состоит в том, что объект никогда не должен быть назначен объектному (или статическому) полю, и, вероятно, также не должен быть в массиве. Так в коде вроде
Foo f = new Foo();
bar.field = foo;
Foo
объект не может быть заменен скалярным. Это верно, даже если bar
сама скалярная замена, а также, если вы никогда больше не используете bar.field
, Таким образом, объект может быть назначен только локальным переменным.
Одного этого недостаточно, Hotspot также должна иметь возможность статически определять во время JIT, какой экземпляр объекта будет целью вызова. Например, используя следующие реализации foo
а также test
и удаление field
вызывает выделение кучи:
long foo(long i) { return i * 0xbb; }
static long test() {
long ctr = 0x5;
for(long i=0; i<0x10000; i++) {
Scalarization s = new Scalarization();
ctr = s.foo(ctr);
if(i == 50) s = new Scalarization();
ctr = s.foo(ctr);
}
return ctr;
}
В то время как если вы затем удалите условие для второго назначения, выделение кучи больше не произойдет:
static long test() {
long ctr = 0x5;
for(long i=0; i<0x10000; i++) {
Scalarization s = new Scalarization();
ctr = s.foo(ctr);
s = new Scalarization();
ctr = s.foo(ctr);
}
return ctr;
}
В этом случае Hotspot может статически определять, какой экземпляр является целью для каждого вызова s.foo
,
С другой стороны, даже если второе назначение s
это подкласс Scalarization
с совершенно другой реализацией, пока назначение является безусловным, Hotspot будет все еще скалярно распределять.
Hotspot не может перемещать объект в кучу, которая была ранее заменена скаляром (по крайней мере, без деоптимизации). Скалярная замена - дело "все или ничего". Так в оригинале test
метод обоих распределений Scalarization
всегда происходит в куче.
Conditionals
Одна важная деталь заключается в том, что Hotspot будет прогнозировать условия на основе данных профилирования. Если условное присвоение никогда не выполняется, Hotspot скомпилирует код в соответствии с этим предположением, а затем сможет выполнить скалярную замену. Если в более поздний момент времени условие будет выполнено, Hotspot потребуется перекомпилировать код с этим новым предположением. Новый код не будет выполнять скалярную замену, поскольку Hotspot больше не может определять экземпляр получателя следующих вызовов статически.
Например, в этом варианте test
:
static long limit = 0;
static long test() {
long ctr = 0x5;
long i = limit;
limit += 0x10000;
for(; i<limit; i++) { // In this form if scalarization happens is nondeterministic: if the condition is hit before profiling starts scalarization happens, else not.
Scalarization s = new Scalarization();
ctr = s.foo(ctr);
if(i == 0xf9a0) s = new Scalarization();
ctr = s.foo(ctr);
}
return ctr;
}
условное назначение выполняется только один раз за время существования программы. Если это назначение происходит достаточно рано, прежде чем Hotspot начнет полное профилирование test
Метод, Hotspot никогда не замечает, что берется условие, и компилирует код, который выполняет скалярную замену. Если профилирование уже началось, когда выполняется условие, Hotspot не будет выполнять скалярную замену. С испытательным значением 0xf9a0
то, произойдет ли скалярная замена, на моем компьютере недетерминировано, так как точно, когда начинается профилирование, может отличаться (например, потому что профилирование и оптимизированный код компилируются в фоновых потоках). Поэтому, если я запускаю вышеуказанный вариант, он иногда выполняет несколько сборок мусора, а иногда нет.
Статический анализ кода в Hotspot гораздо более ограничен, чем то, что могут сделать C/C++ и другие статические компиляторы, поэтому Hotspot не так умно следит за потоком управления в методе через несколько условных и других структур управления, чтобы определить экземпляр, к которому относится переменная, даже если это будет статически определимо для программиста или более умного компилятора. Во многих случаях информация профилирования компенсирует это, но об этом нужно знать.
Массивы
Массивы могут быть распределены в стеке, если их размер известен во время JIT. Однако индексация в массив не поддерживается, если Hotspot также не может статически определить значение индекса во время JIT. Таким образом, выделенные массивы стека довольно бесполезны. Поскольку большинство программ не используют массивы напрямую, а используют стандартные коллекции, это не очень актуально, так как встроенные объекты, такие как массив, содержащий данные в ArrayList, уже должны быть выделены в куче из-за их вложенности. Я полагаю, что причина этого ограничения заключается в том, что не существует операции индексации для локальных переменных, поэтому для довольно редкого случая использования потребуются дополнительные функции генерации кода.