Привет, Хабр!

Метод считает расстояние между двумя точками и создаёт для этого пару короткоживущих объектов. Прогоняем его через JMH со счётчиком аллокаций и получаем ноль байт на операцию — два new в теле, а куча не растёт вообще.

Дописываем в тот же метод одну строку с if, ничего не меняя по смыслу: объекты по‑прежнему никуда не утекают, ни один не сохраняется и не возвращается наружу. Аллокации возвращаются, все 32 байта.

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

Начнём с исходного варианта, который не аллоцирует:

@Benchmark
public double distance() {
    Point a = new Point(1.0, 2.0);
    Point b = new Point(4.0, 6.0);
    return Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));
}
Benchmark                           Mode  Cnt   Score   Units
Bench.distance                      avgt    5   2,104   ns/op
Bench.distance:·gc.alloc.rate.norm  avgt    5   0,001   B/op

Дальше разберём, что именно сделал компилятор с этими двумя объектами, а потом посмотрим на то самое if, которое всё возвращает обратно.

Стековой аллокации в HotSpot нет вообще

Настоящая стековая аллокация выглядела бы так: компилятор берёт полную раскладку объекта — заголовок и поля, и кладёт её в кадр текущего метода. Ссылка остаётся ссылкой, просто указывает на стек.

C2 поступает радикальнее. Убедившись, что объект не покидает метод, он вообще не создаёт объект. Каждое поле превращается в отдельную локальную переменную, дальше с ними работает распределитель регистров, и до памяти они могут вообще не доехать. Ни заголовка, ни ссылки, ни записи в кучу, ни участия сборщика мусора — оптимизация называется скалярной заменой агрегатов.

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

Часть этих значений распределитель регистров может вытеснить в стековые слоты кадра, и снаружи это выглядит похоже на стековую аллокацию. Но объекта там по‑прежнему нет — есть отдельные разложенные поля, и вернуть их обратно в объект компилятор обязан только в одном случае: если сработает деоптимизация и выполнение вернётся в интерпретатор, которому нужен настоящий объект.

Escape analysis при этом — не оптимизация, а анализ. Он классифицирует каждую аллокацию по тому, куда утекает ссылка, а решение принимает уже оптимизатор. Состояний три:

  • NoEscape — объект не покидает метод,

  • ArgEscape — уходит аргументом в вызываемый метод, но не дальше,

  • GlobalEscape — попадает в поле, в статику, в возвращаемое значение или в исключение.

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

Как узнать, что стало с вашим объектом

Гадать здесь не нужно, компилятор отчитывается сам.

java -XX:+UnlockDiagnosticVMOptions \
     -XX:+PrintEliminateAllocations \
     -XX:+PrintEscapeAnalysis \
     -cp . Bench
++++ Eliminated: 41 Allocate
++++ Eliminated: 58 Allocate
======== Connection graph for Bench::distance
JavaObject NoEscape(NoEscape) [ 41F 58F ]

Строки с Eliminated — это и есть скалярная замена. Номер рядом относится к узлу в промежуточном представлении, и по нему можно сопоставить, какая именно аллокация исчезла, если их в методе несколько.

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

java -jar target/benchmarks.jar Bench.distance -prof gc

Проверить, что вы смотрите именно на эффект от анализа, а не на что‑то другое, помогает его отключение:

java -jar target/benchmarks.jar Bench.distance -prof gc -jvmArgs '-XX:-DoEscapeAnalysis'
Bench.distance:·gc.alloc.rate.norm  avgt    5   32,000   B/op

Тридцать два байта — это два Point по 16. Разница между двумя запусками и есть та работа, которую C2 сделал за вас.

Ветвление, после которого аллокации возвращаются

Теперь обещанная строчка. Добавим совершенно невинное ветвление:

@Benchmark
public double distanceCond(boolean flag) {
    Point a = new Point(1.0, 2.0);
    if (flag) {
        a = new Point(3.0, 5.0);
    }
    Point b = new Point(4.0, 6.0);
    return Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));
}
Bench.distanceCond:·gc.alloc.rate.norm  avgt    5   32,000   B/op

Ни один объект по‑прежнему никуда не утекает — человек, читающий этот код, скажет, что оптимизировать тут нечего. C2 не согласен.

Дело в точке слияния управляющего потока. После if переменная a может указывать либо на первый объект, либо на второй, и в промежуточном представлении в этом месте появляется узел выбора между двумя ссылками. Заменить объект на набор скаляров компилятор умеет только тогда, когда точно знает, о каком объекте идёт речь в каждой точке использования, а здесь он этого не знает. Оба объекта помечаются как утекающие, и аллокации остаются.

Ограничение реализационное: Graal в такой ситуации скалярную замену выполняет, C2 — нет. Обойти его в коде можно, вычислив значения до создания объекта:

@Benchmark
public double distanceCondFixed(boolean flag) {
    double px = flag ? 3.0 : 1.0;
    double py = flag ? 5.0 : 2.0;
    Point a = new Point(px, py);          // один new, слияния ссылок нет
    Point b = new Point(4.0, 6.0);
    return Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));
}
Bench.distanceCondFixed:·gc.alloc.rate.norm  avgt    5   0,001   B/op

Ветвление осталось, объект теперь один, и скалярная замена снова работает.

Тридцать пять байткодов решают за вас

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

Escape analysis в C2 работает в пределах единицы компиляции. Если объект передан в метод, который заинлайнили, анализ видит, что с ним там происходит. Если метод не заинлайнили — аргумент помечается как утекающий, и это предположение, а не вывод: компилятор просто не знает, что творится внутри, и выбирает безопасный вариант.

Порог инлайнинга по умолчанию — 35 байткодов для обычных методов, и превысить его несложно.

static double lengthSmall(Point p) {                 // влезает в порог
    return Math.sqrt(p.x * p.x + p.y * p.y);
}

static double lengthBig(Point p) {                   // не влезает
    double sum = 0;
    for (int i = 0; i < 4; i++) {
        sum += p.x * p.x + p.y * p.y;
        if (sum > 1e12) sum = Math.log(sum) + p.x - p.y;
        sum = Math.fma(sum, 1.0000001, p.y);
    }
    return Math.sqrt(sum);
}
Bench.callSmall:·gc.alloc.rate.norm  avgt    5    0,001   B/op
Bench.callBig:·gc.alloc.rate.norm    avgt    5   16,000   B/op

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

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

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -jar benchmarks.jar Bench.callBig
  @ 12  Bench::lengthBig (58 bytes)   too big
  @ 12  Bench::lengthSmall (21 bytes)   inline (hot)

Пометка too big рядом с вызовом — прямая причина того, что объект остался в куче. Поднять порог можно флагом -XX:MaxInlineSize, но лучше этого не делать глобально: инлайнинг раздувает скомпилированный код и вытесняет из кеша инструкций то, что там нужнее.

Отдельно стоит помнить, что виртуальные вызовы межпроцедурный анализ не разбирает вовсе — ссылки, которые уходят в них или возвращаются из них, консервативно считаются утекающими. Метод, вызываемый через интерфейс с несколькими реализациями, оптимизацию не переживёт.

Массивы разбираются тоже, но не всякие

Скалярная замена работает не только с объектами. Массив, длина которого известна на этапе компиляции и невелика, C2 разбирает точно так же — каждый элемент превращается в отдельную локальную переменную.

@Benchmark
public int sumFixed() {
    int[] buf = {1, 2, 3, 4};        // длина известна, элементов мало
    int s = 0;
    for (int v : buf) s += v;
    return s;
}

@Benchmark
public int sumDynamic(int n) {
    int[] buf = new int[n];          // длина приходит извне
    for (int i = 0; i < n; i++) buf[i] = i;
    int s = 0;
    for (int v : buf) s += v;
    return s;
}
Bench.sumFixed:·gc.alloc.rate.norm    avgt    5     0,001   B/op
Bench.sumDynamic:·gc.alloc.rate.norm  avgt    5    80,000   B/op

Массив переменной длины разложить на скаляры невозможно — компилятор не знает, сколько переменных заводить. Но и у фиксированных массивов есть потолок: разбираются только небольшие, порог задаётся флагом и по умолчанию равен 64 элементам.

java -XX:+PrintFlagsFinal -version | grep EliminateAllocationArraySizeLimit
intx EliminateAllocationArraySizeLimit  = 64

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

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

Вместе с объектом пропадает и synchronized

Escape analysis кормит данными не одну оптимизацию. Объект, признанный локальным, не может быть виден другому потоку, а значит синхронизация по нему бессмысленна — и C2 её убирает.

@Benchmark
public int syncOnLocal() {
    StringBuilder sb = new StringBuilder();   // не покидает метод
    synchronized (sb) {                       // блокировка снимается компилятором
        sb.append("a").append(1);
    }
    return sb.length();
}

Проверяется это тем же набором диагностических флагов:

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintEliminateLocks -cp . Bench
++++ Eliminated: 63 Lock
++++ Eliminated: 71 Unlock

Поэтому старенький совет «замените StringBuffer на StringBuilder ради скорости» в горячем локальном коде почти ничего не даёт: синхронизацию в нём и так снимут. Разница остаётся там, где объект действительно уходит наружу и доказать локальность нельзя.

Что будет, если объект внезапно понадобится

У скалярной замены есть неприятная обратная сторона, о которой стоит знать при чтении стек‑трейсов и дампов.

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

Практически это значит две вещи.

  • Аллокация, которой в установившемся режиме нет, появляется в момент деоптимизации, и на графике это выглядит как короткий необъяснимый всплеск скорости аллокации.

  • А ещё это одна из причин, по которой отладчик способен показывать объект, которого в скомпилированном коде не существовало: постановка точки останова сама вызывает деоптимизацию метода.

Первые минуты после старта живут по другим правилам

Ко всему прочему, скалярная замена живёт только в C2. Интерпретатор аллоцирует всё честно, C1 — тоже. Пока метод не набрал счётчики и не доехал до верхнего уровня компиляции, каждый ваш new создаёт настоящий объект в куче.

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

java -Xlog:jit+compilation=debug:file=jit.log -jar app.jar

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

Optional, итераторы и автоупаковка под тем же микроскопом

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

Optional в цепочке преобразований чаще всего разбирается полностью — при условии, что все вызовы в цепочке заинлайнились, а результат не сохраняется в поле:

@Benchmark
public int viaOptional() {
    return Optional.ofNullable(name)
            .map(String::length)
            .orElse(0);
}
Bench.viaOptional:·gc.alloc.rate.norm  avgt    5   0,001   B/op

Итераторы коллекций ведут себя так же. Цикл for (T x : list) создаёт объект итератора, и в горячем методе он обычно исчезает, поэтому советы «замените обход коллекции на индексный цикл ради аллокаций» давно устарели для ArrayList.

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

@Benchmark
public long boxedSmall() {
    long s = 0;
    for (int i = 0; i < 100; i++) s += Integer.valueOf(i);   // кеш -128..127
    return s;
}

@Benchmark
public long boxedLarge() {
    long s = 0;
    for (int i = 1000; i < 1100; i++) s += Integer.valueOf(i);
    return s;
}
Bench.boxedSmall:·gc.alloc.rate.norm  avgt    5     0,001   B/op
Bench.boxedLarge:·gc.alloc.rate.norm  avgt    5     0,002   B/op

Здесь обе версии дали ноль, но по разным причинам: первая попала в кеш маленьких значений и вообще не создавала объектов, вторая создавала и потеряла их на скалярной замене. Стоит вынести значение в поле или отдать наружу, и вторая начнёт стабильно аллоцировать, а первая продолжит работать бесплатно.

Лямбда без захвата переменных создаётся один раз и переиспользуется, а вот захватывающая создаёт объект на каждый вызов, и переживёт он скалярную замену только если не уходит в неинлайненный метод — что для лямбды, передаваемой в чужой API, случается редко.

На что из этого можно опираться

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

  • Мелкие методы‑обёртки, возвращающие временный объект, обычно оптимизируются полностью, и бояться их не нужно. Классический Optional в горячем пути или короткоживущий кортеж на два поля с большой вероятностью не доедут до кучи — при условии, что вызывающий метод их не сохраняет и не отдаёт наружу.

  • Обратное тоже верно и встречается чаще: объект, аккуратно созданный и уничтоженный в пределах метода, оказывается в куче, потому что в середине появилось безобидное if или потому что вызываемый метод дорос до 36 байткодов. Проверять это надо замером, а не рассуждением.

  • Если аллокации в горячем методе действительно мешают, порядок действий такой: сначала подтвердить их наличие через -prof gc в JMH, затем найти причину через PrintInlining и PrintEliminateAllocations, и только потом менять код — убирать слияние ссылок, дробить крупный метод, заменять интерфейс на конкретный тип в горячей точке. Переписывать на примитивы и переиспользуемые буферы стоит в последнюю очередь, когда предыдущие шаги не помогли: код от этого читается заметно хуже, а выигрыш часто уже получен компилятором.

И держите в голове, что весь этот механизм — про C2 конкретной сборки JVM.

Разобраться, почему JVM принимает то или иное решение, — полезно. Ещё полезнее увидеть, как эти механизмы проявляются уже в реальных Java‑приложениях и под нагрузкой.

Продолжить разбор можно на бесплатных открытых уроках OTUS:

  • 26 августа, 20:00. «Работа с Kafka через библиотеку Kafka Clients». Записаться

  • 16 сентября, 20:00. «Spring Boot + Elasticsearch: как за 10 минут сделать поиск, который летает?». Записаться

  • 21 сентября, 20:00. «HTTP‑сервер на чистой Java за 30 минут». Записаться

Расписание бесплатных уроков августа смотрите в дайджесте.

Комментарии (0)