Привет, Хабр!
Метод считает расстояние между двумя точками и создаёт для этого пару короткоживущих объектов. Прогоняем его через 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 минут». Записаться
Расписание бесплатных уроков августа смотрите в дайджесте.