Когда наш компилятор делает что-то, чтобы сделать среду выполнения менее эффективной, я имею в виду удаление лишних новых операторов. В общем, выделение объектов, выполнение оператора new — вещь очень дорогая. Не потому, что вы выделяете память, распределители часто работают быстро. А потому, что вы будете нагружать сборщик мусора. Тогда он должен будет прийти и очистить эту память. Таким образом, вы значительно увеличиваете ее рабочую нагрузку, поэтому любая виртуальная машина пытается избежать этого. Как вы можете это сделать?

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

Допустим, вы сделали здесь распределение, вы сказали Test obj = new Test();

class Test {
   int a, b;
}

public void foo() {
   Test obj = new Test();
   obj.a = 42;
   obj.b = 10;

   System.out.println(obj.a + obj.b);
}

Затем вы исключительноиспользуете этот объект в функции foo(). Зачем размещать что-либо в куче здесь? Он сразу умрет, и его должен будет собрать сборщик мусора. Конечно, здесь не будет распределения. Вместо этого сработает знаменитая оптимизация Scalar Replacement, которая заменит использование этого объекта на использование его полей. Он как бы взрывается, аннигилирует на атомы, и мы будем работать с его полями, как с обычными местными. То есть выделения не будет, просто появятся два локала с нужными значениями и все.

Это очень эффективная тактика. Во-первых, new не срабатывал, и сборщик мусора не напрягался. Во-вторых, вы потратили даже меньше памяти, чем на обычный объект, потому что обычный объект тоже имеет заголовки, а здесь вы работаете только с полями. В-третьих, поля этого объекта, скорее всего, окажутся в регистрах — а значит, мы будем работать с ними гораздо быстрее, чем если бы это было просто объект в памяти.

Давайте посмотрим на другой пример:

class Test {
   int a, b;
}

public void bar(Test arg){
   arg.b = 10;
   System.out.println(arg.a + arg.b);
}

public void foo() {
   Test obj = new Test();
   obj.a = 42;
   bar(obj); 
}

У нас есть выделение объекта Test, мы немного использовали его внутри метода foo(), а затем передали его в bar()метод, что тоже очень хорошо, и утечки здесь нет. Предположим, что метод bar() не был встроен по какой-то причине. Тогда объект больше нельзя будет просто взорвать. Похоже, что утечки нет. Но с другой стороны, если разбить объект на атомы, то придется передавать не только один указатель на начало объекта, а все его поля, т.е. придется скопировать его, что может быть просто неэффективно. Становится дорого передавать в функции внутренности объекта, а не просто ссылку на этот объект, и это оказывается пессимизацией, а не оптимизацией.

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

В контексте метода foo() опишем этот объект: он будет иметь заголовок и поля. Сборщик мусора будет обрабатывать его несколько по-другому, так как он не будет пытаться собрать его, хотя обработка по-прежнему необходима. В результате, с одной стороны, мы сможем передавать указатель на него внутри новых функций, а с другой стороны, как только мы выйдем из «foo», фрейм будет стерт, и мы тут же будем собирать память из-под него. Это также очень мощная оптимизация.

И, наконец, есть действительно плохой сценарий:

class Test {
   int a, b;
}

static Test t;

public void foo(boolean shouldEscape){
   Test obj = new Test();
   obj.a = 42;
   obj.b = 10;

   System.out.println(obj.a + obj.b);

   if (shouldEscape) {
       t = obj;
   }
}

Допустим, у меня есть метод, в котором объект obj в основном локальный, но есть один неприятный холодный путь выполнения при условии shouldEscape, где утечка все еще происходит. Я пишу ссылку на этот объект в глобальном поле t. С точки зрения любого анализа побегов здесь будет утечка. Здесь нельзя выполнить ни скалярную замену, ни выделение стека. Но с другой стороны, мы видим, что это всего лишь один путь, всего одна точка, где что-то пошло не так.

Есть более мощные анализы: partial escape analysis, который не только говорит, что есть утечка (бинарный ответ, да или нет), но и говорит: здесь объект используется локально, а на этих точки действительно протекает. И тогда мы можем частично взорвать объект. Глобальной аллокации не будет, в самом начале начинаем работать с полями, а непосредственно перед точкой утечки эвакуируем наш объект в кучу. То есть мы создаем новый объект, прописываем его поля и назначаем куда надо.

Такая оптимизация существует, например, в компиляторе Graal. Он очень эффективен в случае потокового кода или кода Scala, то есть Graal очень хорошо себя чувствует на этих бенчмарках, во многом благодаря оптимизации Partial Escape Analysis.

Что происходит, так это то, что удаление new представляет собой полную противоположность неявным исключениям.Компилятор делает все, чтобы среда выполнения ничего не делала. Много new также бесплатны, вам не нужно бояться добавлять их в свой код, JVM попробует их удалить.

Скалярная замена — очень популярная оптимизация, она есть в HotSpot и не только. Частичный анализ экранирования встречается немного реже, но он встречается в Graal, а также во многих других современных виртуальных машинах.

Распределение стека — более редкая оптимизация. Его нет в HotSpot, но есть в IBM OpenJ9. Для HotSpot есть только предложение и прототип, сделанный людьми из IBM. Они показывают, что это можно сделать, что это даст преимущества на бенчмарках. Но, похоже, все еще на уровне предложения.

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

Спасибо за чтение!

Рекомендации

https://habr.com/ru/company/jugru/blog/719614/