В этот раз мы решили разнообразить поток технических интервью реальным хардором и подготовили материал на основе доклада Руслана cheremin Черемина (Deutsche Bank) про анализ работы пары Escape Analysis и Scalar Replacement, сделанный им на JPoint 2016 в апреле минувшего года.
Видеозапись доклада перед вами:
А под катом мы выложили полную текстовую расшифровку с отдельными слайдами.
Начнем с небольшого лирического отступления, касающегося терминологии.
Escape-анализ — это техника анализа кода, которая позволяет статически (во время компиляции) определить область достижимости для ссылки какого-то объекта. Грубо говоря, есть инструкция, которая аллоцирует объект, и в ходе анализа мы пытаемся понять, может ли иная инструкция каким-то образом получить ссылку на созданный объект.
Escape-анализ — это не оптимизация сама по себе, это просто анализ, но его результаты могут использоваться для последующих оптимизаций. Обычно, конечно, нас интересует достижимость не с точностью до инструкции, а что-то вроде «достижим ли объект, созданный в некотором методе — вне этого метода». И в рамках задачи оптимизации нас больше всего интересуют ситуации, где ответ будет «нет, вне метода объект не достижим».
Скаляризация (Scalar Replacement). Скаляризация — это замена объекта, который существует только внутри метода, локальными переменными. Мы берем объект (по факту его еще нет — он будет создан при выполнении программы) и говорим, что нам его создавать не нужно: мы можем все его поля положить в локальные переменные, трансформировать код так, чтобы он обращался к этим полям, а аллокацию из кода стереть.
Мне нравится метафора, что EA/SR это такой статический garbage collector. Обычный (динамический) GC выполняется в рантайме, сканирует граф объектов и выполняет reachability analysis — находит уже не достижимые объекты и освобождает занятую ими память. Пара «escape-анализ — скаляризация» делает то же самое во время JIT-компиляции. Escape-анализ также смотрит на код и говорит: «Созданный здесь объект после этой инструкции уже ниоткуда не достижим, соответственно при определенных условиях мы можем его вообще не создавать».
Пара Escape Analysis и Scalar Replacement появилась в Java уже довольно давно, в 2009-м, сначала как экспериментальная опция, а с 2010 была включена по умолчанию.
Есть ли результаты? В узких кругах в Deutsche Bank ходит реальный фрагмент графика загрузки garbage collector-а, сделанный в 2010 году. Картинка иллюстрирует, что иногда для оптимизации можно вообще ничего не делать, а просто дождаться очередного апдейта Java.
![](https://habrastorage.org/getpro/habr/post_images/45b/ff4/ba3/45bff4ba3515ae31128d839bb42cff8d.jpg)
Источник: dolzhenko.blogspot.ru
Конечно, так бывает очень редко, это исключительный случай. В более реалистичных примерах по разным данным в среднестатистическом приложении escape-анализ способен устранить порядка 15% аллокаций, ну, а если сильно повезет — то до 70%.
Когда этот инструмент вышел в 2010 году, я был, честно говоря, очень им вдохновлен. Я тогда как раз только закончил проект, где было много околонаучных вычислений, в частности, мы активно жонглировали всякими векторами. И у нас было очень много объектов, которые живут от предыдущей инструкции до следующей. Когда я на это смотрел, у меня в голове возникала крамольная мысль, что на С здесь было бы лучше. И прочитав про эту оптимизацию, я понял, что она могла бы решить подобные проблемы. Однако у Sun в релизе был очень скромный пример ее работы, поэтому я ждал какого-то более обширного описания (в каких ситуациях она работает, в каких — нет; что нужно, чтобы это работало). И ждал я довольно долго.
К сожалению, за 7 лет я нашел упоминания лишь о трех случаях применения, один из которых был примером самого Sun. Проблемой всех примеров было то, что в статьях приводился кусок кода с комментарием: «вот так оно работает». А если я переставлю инструкции — не сломается ли скаляризация от этого? А если вместо ArrayList я возьму LinkedList, будет ли это работать? Мне это было непонятно. В итоге я решил, что я так и не дождусь чужих исследований, т.е. эту работу придется сделать самому.
Что я хотел получить? В первую очередь, я хотел какое-то интуитивное понимание. Понятно, JIT-компиляция вообще — это очень сложная штука, и она зависит от многих вещей. Чтобы понимать ее в деталях, надо работать в Oracle. Такой задачи у меня не было. Мне необходимо какое-то интуитивное понимание, чтобы я смотрел на код и мог оценить, что вот здесь — почти наверняка да, а тут — почти наверняка нет, а вот тут — возможно (надо исследовать, может удастся добиться, чтобы эта конкретная аллокация скаляризовалась). А для этого нужен какой-то набор примеров, на которых можно посмотреть, когда работает, когда не работает. И фреймворк, чтобы было легко писать эти примеры.
Прежде чем мы перейдем к самим экспериментам — еще небольшое теоретическое отступление. Важно понимать, что escape-анализ и скаляризация — это лишь часть большого набора оптимизаций, который есть в серверном компиляторе. В очень общих чертах процесс оптимизации C2 представлен на рисунке.
![](https://habrastorage.org/getpro/habr/post_images/4a4/0e8/e0c/4a40e8e0cd5fcae283409ddf97fceda3.png)
Важно здесь то, что еще до escape-анализа за дело берутся другие инструменты оптимизации. Например — инлайнинг, девиртуализация, сворачивание констант и выделение частых или не частых маршрутов (на самом деле их гораздо больше, но здесь я указал те, которые чаще всего влияют на escape-анализ). И чтобы, по результатам escape-анализа, какие-то объекты скаляризовались, необходимо, чтобы хорошо отработали все предыдущие звенья цепи, предыдущие оптимизации, до escape-анализа и скаляризации. И что-то сломаться, не получиться может на любом этапе, но, как мы увидим, чаще всего что-то ломается как раз-таки еще до escape-анализа. И лишь в некоторых случаях именно сам escape-анализ не справляется с задачей.
![](https://habrastorage.org/getpro/habr/post_images/e2b/b87/465/e2bb87465e9e1ad415b623e0868ac3d0.png)
Несколько лет назад, пытаясь экспериментировать со скаляризацией, я в основном опирался на
Если результат теста нас удивляет, есть ключики PrintCompilation и PrintInlining, позволяющие получить больше информации. Есть еще третий ключик, LogCompilation, который выдает все то же самое, только гораздо больше, и в xml формате — его выдачу можно скормить утилитке JITWatch, которая вам все представит в красивом UI.
Логичен вопрос: почему бы не использовать JMH? JMH действительно может это делать. У него есть профайлер,
![](https://habrastorage.org/getpro/habr/post_images/500/b76/a6f/500b76a6fad2c8df32e39259ae34b80b.png)
И поначалу и я пытался зайти с этой стороны. Но дело в том, что JMH в первую очередь заточен на перформанс, который меня не очень интересует. Меня не интересует, сколько времени у меня ушло на итерацию; меня интересует, сработала ли там конкретная оптимизация, иными словами, мне нужен триггерный ответ. А здесь очень много информации, которую я сходу не нашел, как убрать. И в итоге для себя решил, что если я хочу сегодня в течение получаса получить результат, то проще написать самому. Поэтому у меня есть свой «велосипед». Но если кто-то хочет продолжать эти эксперименты или делать какие-то свои, я очень рекомендую взять стандартный инструмент, поскольку стандартный обычно лучше.
Начнем с простого теста: похожего на пример в релизе Sun.
У нас есть простенький класс Vector2D. Мы создаем три случайных вектора с помощью рандома и выполняем с ними некую операцию (складываем и вычисляем скалярное произведение). Если мы запустим это в современной JVM, сколько объектов здесь будет создано?
![](https://habrastorage.org/getpro/habr/post_images/9f0/992/1ff/9f09921ff786b852c9c5e20c24be0420.png)
В результате в начале что-то аллоцируется (пока еще не прошла компиляция), ну а дальше все очень чистенько — 0 байт на вызов.
Это канонический пример, так что ничего удивительного в том, что он работает.
Для контроля добавляем ключ, отключающий стирание аллокаций — и мы получаем 128 байт на вызов. Это как раз четыре объекта Vector2D: три явно создались, и еще один появился в ходе сложения.
![](https://habrastorage.org/getpro/habr/post_images/215/9e7/276/2159e72767cc232a2a7bc19c66c46215.png)
Добавим цикл в предыдущий пример.
Мы заводим вектор-аккумулятор, к которому будем добавлять вектора внутри цикла.
![](https://habrastorage.org/getpro/habr/post_images/ee8/e80/ca7/ee8e80ca7aa66474d7e90fd9c77c46d6.png)
В этом сценарии все тоже хорошо (для любого значения
![](https://habrastorage.org/getpro/habr/post_images/992/892/1f9/9928921f90a0dd4341d3bd2d4755fbf6.png)
На этот раз сделаем умножение на константу — на double, а полученный результат запишем в ту же самую переменную. На самом деле это тот же аккумулятор, только здесь мы умножаем вектор на какое-то число.
![](https://habrastorage.org/getpro/habr/post_images/820/f8c/29b/820f8c29bd16aa29feac7089def90390.png)
Неожиданно, но здесь скаляризация не сработала (2080 байт = 32* (SIZE + 1)).
![](https://habrastorage.org/getpro/habr/post_images/fea/1bd/a77/fea1bda77923eb459033227cd4c30159.png)
Прежде чем выяснять почему, рассмотрим еще пару примеров.
Более простой пример: у нас нет цикла, есть условный переход. Мы случайным образом выбираем координату и создаем Vector2D.
![](https://habrastorage.org/getpro/habr/post_images/d62/55b/68b/d6255b68b70c78a41f2b5cebb72ff062.png)
И здесь скаляризация не помогает: все время создается один вектор — те самые 32 байта.
![](https://habrastorage.org/getpro/habr/post_images/dba/4ca/75e/dba4ca75e07a9cb9634d85da4aa7c377.png)
Попробуем немного изменить этот пример. Я просто внесу создание вектора внутрь обеих веток:
![](https://habrastorage.org/getpro/habr/post_images/3ff/a15/659/3ffa1565937a45e78e693d2ec5981857.png)
И здесь все отлично скаляризуется.
![](https://habrastorage.org/getpro/habr/post_images/fe8/314/45b/fe831445b65fab5dc8f0fe1dc2d2d03e.png)
Начинает вырисовываться картина — что здесь происходит?
![](https://habrastorage.org/getpro/habr/post_images/8fb/db7/e68/8fbdb7e682f8432041f35888482d3871.png)
Представим, что у нас есть поток исполнения в программе. Есть одна ветвь, в которой мы создали объект v1, и вторая ветвь, в которой создали объект v2. В третью переменную, v3, мы записываем ссылку либо на первый объект, либо на второй, в зависимости от того, по какому маршруту пошло выполнение. В конце мы возвращаем какое-то поле через ссылку v3. Предположим, что произошла скаляризация и поля v1.x, v1.y, v2.x, v2.y превратились в локальные переменные, допустим, v1$x, v1$y, v2$x, v2$y. А что делать со ссылкой v3? А точнее: во что должно превратиться обращение к полю v3.x?
Это вопрос. В каких-то простых примерах, как здесь, или в примере 1.4, решение интуитивно понятно: если этот код, это все, что у нас есть — то нужно просто return внести внутрь условия, будет два return-а, по одному на каждую ветку, и каждый будет возвращать свое значение. Но случаи бывают более сложные, и в итоге разработчики JVM решили, что они просто не будут оптимизировать этот сценарий, т.к. в общем случае сделать это — разобраться, поле какого объекта нужно использовать — оказалось слишком сложно (см например баг JDK-6853701, или соответствующие комментарии в исходном коде JVM).
Подводя итог этому примеру, скаляризации не будет, если:
Если вы хотите увеличить шансы на скаляризацию, то одна ссылка должна указывать на один объект. Даже если она всегда указывает на один объект, но в разных сценариях исполнения это могут быть разные объекты — даже это сбивает с толку escape-анализ.
Это класс из commons.lang, идея которого состоит в том, что вы equals-ы можете генерировать таким вот образом, добавляя поля вашего класса в Builder. Честно говоря, я сам его не использую, мне просто нужен был пример какого-то Builder-а, и он попался под руку. Реальный пример обычно лучше, чем синтетический.
![](https://habrastorage.org/getpro/habr/post_images/2f4/bb8/028/2f4bb802895232bfe62dc101b1d60ed1.png)
Конечно, было бы хорошо, если бы эта штука скаляризовалась, потому что создавать объекты на каждый вызов equals — не очень хорошая идея.
Я написал простой кусок кода — только два int-а, выписанных явно (но даже если бы там были указаны поля, сути это бы не изменило).
![](https://habrastorage.org/getpro/habr/post_images/e11/fad/019/e11fad01972bcf9836cee3aace32d134.png)
Вполне ожидаемо, эта ситуация скаляризуется.
![](https://habrastorage.org/getpro/habr/post_images/926/f19/158/926f19158cf6d1358c6b97dfff39d0ce.png)
Немного изменим пример: вместо двух int-ов поставим две строки.
![](https://habrastorage.org/getpro/habr/post_images/b18/c89/085/b18c89085329ac0c29212e023d58fbf3.png)
В результате скаляризация не работает.
![](https://habrastorage.org/getpro/habr/post_images/a1d/0eb/014/a1d0eb014b9f47508b520e12898106cf.png)
Не будем пока лезть в метод .append(...). Для начала у нас есть ключи, которые хотя бы вкратце рассказывают, что происходит в компиляторе.
![](https://habrastorage.org/getpro/habr/post_images/cc6/79d/1f9/cc679d1f979366dbbcc272fca4771ab0.png)
Выясняется, что метод append не заинлайнился, соответственно, escape-анализ не может понять: вот эта ссылка на builder, которая ушла внутрь метода .append() как this — что там с ней происходит, внутри метода? Это неизвестно (потому что внутрь метода .append компилятор не заглядывает — JIT не делает меж-процедурную оптимизацию). Может, ее там в глобальную переменную присвоили. И в подобных ситуациях escape-анализ сдается.
Что означает диагностика «hot method too big»? Она означает, что метод — горячий, т.е. вызывался достаточно много раз, и размер его байткода больше, чем некий предел, порог инлайнинга (предел именно для частых методов). Этот предел — он задается ключом FreqInlineSize, и по-умолчанию он 325. А в диагностике мы видим 327 — то есть мы промахнулись всего на 2 байта.
Вот содержимое метода — легко поверить, что там есть 327 байт:
![](https://habrastorage.org/getpro/habr/post_images/f62/f83/509/f62f8350993576b4e1be7b1179eb862f.png)
Как мы можем проверить нашу гипотезу? Мы можем добавить ключ FreqInlineSize, и увеличить порог инлайнинга, допустим, до 328:
![](https://habrastorage.org/getpro/habr/post_images/c6d/241/e65/c6d241e653ff1aaaa26121cf96267a81.png)
В профиле компиляции мы видим, что .append() теперь инлайнится, и все отлично скаляризуется:
![](https://habrastorage.org/getpro/habr/post_images/ae1/8e3/50c/ae18e350cec83cf35cfaff1aabbde81f.png)
Уточню: когда я здесь (и далее) меняю флаги JVM, параметры JIT-компиляции, я делаю это не для того, чтобы исправить ситуацию, а чтобы проверить гипотезу. Я бы не рекомендовал играться с параметрами JIT-компиляции, поскольку они подобраны специально обученными людьми. Вы, конечно, можете попробовать, но эффект сложно предсказать — каждый такой параметр влияет не на один конкретный метод, в котором захотелось что-то скаляризовать, а на всю программу в целом.
Пишите методы покороче. В частности, в примере с .append() есть большая простыня, которая работает с массивами — пытается сделать сравнение массивов. Если ее просто вынести в отдельный метод, то все отлично инлайнится и скаляризуется (я пробовал). Это такой черный (хотя может и белый) ход для этой эвристики инлайнинга: метод в 328 байт не инлайнится, но он же, разбитый на два метода по 200 байт — отлично инлайнится, потому что каждый метод по отдельности пролезает под порогом.
Рассмотрим возвращение из метода кортежа (tuple) — нескольких значений за раз.
Возьмем какой-нибудь простой объект, типа Pair, и совсем тривиальный пример: мы возвращаем пару строк, случайно выбранных из какого-то заранее заполненного пула. Чтобы компилятор вообще не выкинул этот код, я внесу некий побочный эффект: что-то с этими строками типа посчитаю, и верну результат.
![](https://habrastorage.org/getpro/habr/post_images/9cd/a67/f4a/9cda67f4aaf69b2170ce8c66a1fc12b5.png)
Этот сценарий — скаляризуется. И это вполне рабочий пример, им можно пользоваться: если метод будет горячий и заинлайнится, такие multi-value return отлично скаляризуются.
![](https://habrastorage.org/getpro/habr/post_images/146/172/cb8/146172cb85019893ad84c775e7e9df89.png)
Немного изменим пример: при каких-то обстоятельствах вернем null.
![](https://habrastorage.org/getpro/habr/post_images/fbb/763/4e5/fbb7634e54f7d6f4d5a091a512b00d75.png)
Как видно, аллокация останется (среднее количество байт на вызов не целое, потому что иногда возвращается null, который ничего не стоит).
![](https://habrastorage.org/getpro/habr/post_images/05d/32b/4bc/05d32b4bc6dc1371bd587bd121593f54.png)
Более сложный пример: у нас есть интерфейс-Pair и 2 реализации этого интерфейса. В зависимости от искусственного условия, возвращаем либо ту реализацию, либо другую.
![](https://habrastorage.org/getpro/habr/post_images/09a/f2d/32b/09af2d32b9beff37427022b49d01662e.png)
Здесь тоже остается аллокация:
![](https://habrastorage.org/getpro/habr/post_images/c2a/8fc/d0c/c2a8fcd0c5bc00bce46e4c8c573bae56.png)
Честно говоря, изначально я был уверен, что дело было именно в разных типах, и долго в это верил, пока не сделал следующий пример с одинаковыми типами, который также не скаляризуется.
![](https://habrastorage.org/getpro/habr/post_images/337/968/a18/337968a189733f7e27d3833379528738.png)
Что здесь происходит? Ну, если мы попробуем ручками заинлайнить все методы, то увидим тот же сценарий с merge points (=ссылка может прийти двумя путями), что и в самом первом нашем эксперименте:
![](https://habrastorage.org/getpro/habr/post_images/886/5da/4a3/8865da4a3f220741161445609ca97bfb.png)
Будьте проще: меньше веток — меньше вероятность запутать escape-анализ
Еще один частый паттерн и очень часто появляющийся промежуточный объект, создания которого хотелось бы избежать.
Вот очень простой сценарий с итерацией по коллекции. Мы создаем коллекцию один раз, мы не пересоздаем ее на каждую итерацию, но мы пересоздаем итератор: на каждом запуске метода мы бежим по коллекции итератором, считаем некий побочный эффект (просто чтобы компилятор не выкинул этот кусок).
![](https://habrastorage.org/getpro/habr/post_images/b68/fd2/7e8/b68fd27e8a10043f36001019d5887b6f.png)
Рассмотрим этот сценарий для разных коллекций. Допустим, сначала для ArrayList-а
![](https://habrastorage.org/getpro/habr/post_images/ad9/fe6/ec0/ad9fe6ec0ea4121f3024e95fbf705fd4.png)
Для ArrayList-а итератор действительно скаляризуется (размер SIZE здесь взят условный: как правило, это стабильно работает для широкого спектра SIZE). Для LinkedList это тоже работает. Я не буду долго перебирать все варианты — вот сводная таблица тех коллекций, что я попробовал:
![](https://habrastorage.org/getpro/habr/post_images/61c/acd/7b2/61cacd7b26cb20380f06b082791cc523.png)
В Java 8 все эти итераторы (по крайней мере в простых сценариях) скаляризуются.
Но в самом свежем апдейте Java 7 все хитрее. Давайте мы на нее пристальнее посмотрим (все знают, что 1.7 уже end of life, 1.7.0_80 это последний апдейт, который есть).
Для LinkedList с размером 2 все хорошо:
![](https://habrastorage.org/getpro/habr/post_images/787/a0c/a20/787a0ca2045dd29544d5646b699fe9cd.png)
А вот для LinkedList с размером 65 — нет.
![](https://habrastorage.org/getpro/habr/post_images/e6a/942/a91/e6a942a9178c1d531afa6d1efbbee4c6.png)
Что происходит?
Берем волшебные ключики, и для размера 2 мы получаем такой кусок лога инлайнинга:
![](https://habrastorage.org/getpro/habr/post_images/545/ae0/ae0/545ae0ae0291c8073a0a638be68eebc8.png)
А для размера 65:
![](https://habrastorage.org/getpro/habr/post_images/460/689/6cb/4606896cbdd85c9a23b4f9cc9946dfc9.png)
Ближе к началу того же лога можно найти еще вот такой дополнительный фрагмент картинки:
![](https://habrastorage.org/getpro/habr/post_images/57c/ccf/0d6/57cccf0d6e7ee3844d672f257829357d.png)
Происходит следующее: в самом начале метод, который мы профилируем, пошел на компиляцию — JIT поставил его в очередь. JIT работает асинхронно, т.е. у него есть очередь, туда скидываются задачи на компиляцию, а он в отдельном потоке (или даже нескольких потоках) с какой-то скоростью выгребает их из очереди, и компилирует. То есть между моментом, когда ему поставили задачу, и тем моментом, когда новый код будет оптимизирован, проходит некоторое время.
И вот наш метод
Да, а что именно означают диагностики:
![](https://habrastorage.org/getpro/habr/post_images/0e4/fd4/343/0e4fd4343ce6b323c7c52f5095ded055.png)
Они означают, что, оценивая размер уже скомпилированных методов, мы смотрим на их машинный код, а не байт-код (т.к. это более адекватная метрика). И эти две эвристики — по размеру байт-кода и машинного кода — не обязательно согласованы. Метод из всего пяти байт-кодов может вызывать несколько других методов, которые будут вклеены, и увеличат размер его машинного кода выше порогов. С этой рассогласованностью ничего нельзя сделать кардинально, только подстраивать более-менее пороги разных эвристик, ну и надеяться, что в среднем все будет более-менее хорошо.
Пороги — в частности, InlineSmallCode — отличаются в разных версиях. В 8-ке InlineSmallCode вдвое больше, поэтому в Java 8 этот сценарий отрабатывает успешно: методы инлайнятся и итератор скаляризуется — а в 7-ке нет.
В этом примере важно, что он неустойчив. Вам должно (не)повезти, чтобы задачи на компиляцию пошли в таком порядке. Если бы на момент второй перекомпиляции метод
Мы можем проверить эту нашу гипотезу: поиграться с порогами. И действительно, при их подгонке скаляризация начинает срабатывать:
![](https://habrastorage.org/getpro/habr/post_images/d95/7bb/27d/d957bb27d4c6cf9a34a25bfac672000f.png)
Есть отдельный интересный вариант коллекции — обертка вокруг массива, Arrays.asList(). Хотелось бы, чтобы эта обертка ничего не стоила, чтобы JIT ее скаляризовал.
Я начну здесь с довольно странного сценария — сделаю из массива список, а потом по списку пойду, как будто по массиву, индексом:
![](https://habrastorage.org/getpro/habr/post_images/987/ff8/f25/987ff8f25ba690ddc2888f3c226a8ad6.png)
Здесь все работает, создание обертки скаляризуется.
![](https://habrastorage.org/getpro/habr/post_images/6a7/8f6/01f/6a78f601fd26ba68ac7f0e71d2c11fdd.png)
А теперь вернемся к итератору — нет же особого смысла оборачивать массив в список, чтобы потом ходить по списку, как по массиву:
![](https://habrastorage.org/getpro/habr/post_images/1d3/da3/899/1d3da3899daca40688c0b7d0f6596b60.png)
Увы, даже в самой свежей версии java аллокация остается.
![](https://habrastorage.org/getpro/habr/post_images/d9d/bd6/531/d9dbd653192a52e1670b8e0180332e7b.png)
При этом в PrintInlining мы ничего особенного не видим.
![](https://habrastorage.org/getpro/habr/post_images/ed4/ba1/de5/ed4ba1de530b7da4798e028e1b143373.png)
Но если посмотреть внимательнее, то заметно, что итератор в Arrays$ArrayList не свой — его реализация унаследована целиком от AbstractList-а:
![](https://habrastorage.org/getpro/habr/post_images/637/e42/086/637e42086949f3b577f6f33f0a6a1c84.png)
И AbstractList$Itr — это внутренний класс, не-статический внутренний класс. И вот то, что он не-статический — почему-то мешает скаляризации. Если переписать класс итератора (то есть скопировать весь класс Arrays$ArrayList к себе, и модифицировать), сделать итератор «отвязанным» — в итератор передается массив, и итератор не содержит больше ссылки на объект списка — тогда в этом сценарии будет успешно скаляризоваться как аллокация итератора, так и аллокация самой обертки Arrays$ArrayList.
![](https://habrastorage.org/getpro/habr/post_images/402/cb4/1a9/402cb41a9c4d5e1c6f62b566759e6d07.png)
![](https://habrastorage.org/getpro/habr/post_images/b00/a18/2f1/b00a182f1b674d2f1022f699d9b8b79f.png)
Это довольно загадочный случай, и, похоже, что это баг в JIT-е, но на сей день мораль такова: вложенные объекты сбивают скаляризацию с толку.
У нас есть еще сколько-то таких вот коллекций-синглетонов, и все они, и их итераторы, успешно скаляризуются и в актуальной, и в предыдущей версиях java, кроме упомянутого выше Arrays.asList.
![](https://habrastorage.org/getpro/habr/post_images/1c3/90f/d63/1c390fd63483d18b2a7a8f4c7b6eeaa6.png)
Вложенные объекты не очень хорошо скаляризуются.
Сразу уточню — на скаляризацию массивов переменного размера (т.е. размера, который JIT не сумеет предсказать) даже не надейтесь. Мы работаем с массивами постоянной длины.
Рассмотрим такой пример: мы берем массив, туда что-то записываем по ячейкам, потом оттуда что-то вычитываем.
![](https://habrastorage.org/getpro/habr/post_images/31b/e25/1e1/31be251e152acbf0cc79ab630e479ff7.png)
Для размера 1 — все нормально.
![](https://habrastorage.org/getpro/habr/post_images/b16/edb/1ec/b16edb1ec3a7419f2866100c8a9bb2d4.png)
А для размера 2 — ничего не получается.
![](https://habrastorage.org/getpro/habr/post_images/500/184/504/500184504508b49e8a48607ebd939573.png)
Попробуем немного другой доступ: возьмем тот же размер 2 и просто напросто развернем (unroll) цикл ручками — возьмем и обратимся по явному индексу:
![](https://habrastorage.org/getpro/habr/post_images/009/1f4/a24/0091f4a247427d8db334c10f56739e3f.png)
В этом случае, как ни странно, скаляризация сработает.
![](https://habrastorage.org/getpro/habr/post_images/9a8/63a/df9/9a863adf92be77cc914604292b139959.png)
Не буду долго рассуждать — ниже приведена сводная табличка. Этот случай с развернутым вручную циклом скаляризуется вплоть до размера 64. Если есть какой-то переменный индекс, размеры 1 и 2 еще кое-как скаляризуются, дальше — нет.
![](https://habrastorage.org/getpro/habr/post_images/c09/3a7/4cb/c093a74cb2a2d816612a63f6bb486eb1.png)
Как мне кто-то в блоге написал, в «JVM для всего есть свой ключик». Этот верхний порог (-XX:EliminateAllocationArraySizeLimit = 64) также можно задавать, хотя, мне кажется, в этом нет смысла. В предельном случае будет 64 дополнительных локальных переменных, что слишком много.
Точно такой же код, только с массивом примитивных типов — int-ом, short-ом…
![](https://habrastorage.org/getpro/habr/post_images/517/8ec/f14/5178ecf143e95a46913946c3a44a48ed.png)
Все работает точно в тех же случаях, что и для объектов.
![](https://habrastorage.org/getpro/habr/post_images/2c0/373/4fa/2c03734face8d6f0124cb386050520db.png)
Почему не получается скаляризовать массив, по которому проходим циклом? Потому что непонятно, какой именно индекс скрывается за i. Если у вас есть в коде обращение типа array[2], то JIT может превратить это в локальную переменную типа array$2. А во что превратить array[i]? Нужно знать, чему именно равна i. В каких-то частных случаях, в случае коротких массивов JIT может это «угадать», в общем случае — нет.
В библиотеке guava есть такой замечательный метод, как
Здесь у меня пример, где expression генерируется случайно. И интересно: как зависит скаляризация массива vararg в этом примере от того, с какой вероятностью expression становится false?
![](https://habrastorage.org/getpro/habr/post_images/989/87a/166/98987a166a0fb7e10efbd89eaef95f27.png)
Для начала возьмем вероятность провала — 10-7:
![](https://habrastorage.org/getpro/habr/post_images/3b6/cd9/916/3b6cd99165731c8e4dfab62136b3e7e8.png)
Здесь скаляризация не очень удается.
При уменьшении вероятности до 10-9, поначалу все, вроде, идет в хорошую сторону, но потом все-таки скаляризация отваливается.
![](https://habrastorage.org/getpro/habr/post_images/9f9/9a5/70f/9f99a570f6f159dc1acda7c65db6a444.png)
Если же вероятность совсем маленькая, мы более-менее стабильно приходим сюда:
![](https://habrastorage.org/getpro/habr/post_images/f0b/e21/5d9/f0be215d92f4ba6d5f407a3556dc1b0f.png)
… к устойчивой скаляризации.
Получается, что такой паттерн — с checkArguments, или аналогичным vararg — можно использовать, можно рассчитывать на скаляризацию, но только если вы действительно ожидаете, что expression никогда не будет false. В случае с checkArguments, если expression оказывается false, то это вообще-то означает, что мы наступили на какой-то баг в своем коде. И если у нас нет багов, то, по крайней мере в горячем коде, этот false никогда не возникает, и вся эта конструкция с vararg в идеале ничего не будет нам стоить, с точки зрения аллокации.
Что не очень радует: скаляризация иногда хрупкая и не стабильная (особенно в несвежих JVM). Иногда все зависит от того, в каком порядке задачи пошли на компиляцию, и это конечно огорчает. Нужно понимать ограничения и всегда полезно тестировать важные сценарии. Если в критичном по перформансу коде есть расчет на какую-то скаляризацию, это обязательно нужно протестировать.
Напоследок — краткая сводка рекомендаций:
Если вы дочитали до конца и вам хочется еще – в апреле мы проводим JPoint 2017 (7-8 апреля) в Москве и JBreak 2017 (4 апреля) в Новосибирске. Предварительные программы обеих конференций уже готовы, много докладов опубликовано – есть на что посмотреть, поэтому рекомендую.
Видеозапись доклада перед вами:
А под катом мы выложили полную текстовую расшифровку с отдельными слайдами.
Начнем с небольшого лирического отступления, касающегося терминологии.
Escape-анализ и его место в оптимизации
Escape-анализ — это техника анализа кода, которая позволяет статически (во время компиляции) определить область достижимости для ссылки какого-то объекта. Грубо говоря, есть инструкция, которая аллоцирует объект, и в ходе анализа мы пытаемся понять, может ли иная инструкция каким-то образом получить ссылку на созданный объект.
Escape-анализ — это не оптимизация сама по себе, это просто анализ, но его результаты могут использоваться для последующих оптимизаций. Обычно, конечно, нас интересует достижимость не с точностью до инструкции, а что-то вроде «достижим ли объект, созданный в некотором методе — вне этого метода». И в рамках задачи оптимизации нас больше всего интересуют ситуации, где ответ будет «нет, вне метода объект не достижим».
Скаляризация (Scalar Replacement). Скаляризация — это замена объекта, который существует только внутри метода, локальными переменными. Мы берем объект (по факту его еще нет — он будет создан при выполнении программы) и говорим, что нам его создавать не нужно: мы можем все его поля положить в локальные переменные, трансформировать код так, чтобы он обращался к этим полям, а аллокацию из кода стереть.
Мне нравится метафора, что EA/SR это такой статический garbage collector. Обычный (динамический) GC выполняется в рантайме, сканирует граф объектов и выполняет reachability analysis — находит уже не достижимые объекты и освобождает занятую ими память. Пара «escape-анализ — скаляризация» делает то же самое во время JIT-компиляции. Escape-анализ также смотрит на код и говорит: «Созданный здесь объект после этой инструкции уже ниоткуда не достижим, соответственно при определенных условиях мы можем его вообще не создавать».
Пара Escape Analysis и Scalar Replacement появилась в Java уже довольно давно, в 2009-м, сначала как экспериментальная опция, а с 2010 была включена по умолчанию.
Есть ли результаты? В узких кругах в Deutsche Bank ходит реальный фрагмент графика загрузки garbage collector-а, сделанный в 2010 году. Картинка иллюстрирует, что иногда для оптимизации можно вообще ничего не делать, а просто дождаться очередного апдейта Java.
![](https://habrastorage.org/getpro/habr/post_images/45b/ff4/ba3/45bff4ba3515ae31128d839bb42cff8d.jpg)
Источник: dolzhenko.blogspot.ru
Конечно, так бывает очень редко, это исключительный случай. В более реалистичных примерах по разным данным в среднестатистическом приложении escape-анализ способен устранить порядка 15% аллокаций, ну, а если сильно повезет — то до 70%.
Когда этот инструмент вышел в 2010 году, я был, честно говоря, очень им вдохновлен. Я тогда как раз только закончил проект, где было много околонаучных вычислений, в частности, мы активно жонглировали всякими векторами. И у нас было очень много объектов, которые живут от предыдущей инструкции до следующей. Когда я на это смотрел, у меня в голове возникала крамольная мысль, что на С здесь было бы лучше. И прочитав про эту оптимизацию, я понял, что она могла бы решить подобные проблемы. Однако у Sun в релизе был очень скромный пример ее работы, поэтому я ждал какого-то более обширного описания (в каких ситуациях она работает, в каких — нет; что нужно, чтобы это работало). И ждал я довольно долго.
К сожалению, за 7 лет я нашел упоминания лишь о трех случаях применения, один из которых был примером самого Sun. Проблемой всех примеров было то, что в статьях приводился кусок кода с комментарием: «вот так оно работает». А если я переставлю инструкции — не сломается ли скаляризация от этого? А если вместо ArrayList я возьму LinkedList, будет ли это работать? Мне это было непонятно. В итоге я решил, что я так и не дождусь чужих исследований, т.е. эту работу придется сделать самому.
Путь экспериментов
Что я хотел получить? В первую очередь, я хотел какое-то интуитивное понимание. Понятно, JIT-компиляция вообще — это очень сложная штука, и она зависит от многих вещей. Чтобы понимать ее в деталях, надо работать в Oracle. Такой задачи у меня не было. Мне необходимо какое-то интуитивное понимание, чтобы я смотрел на код и мог оценить, что вот здесь — почти наверняка да, а тут — почти наверняка нет, а вот тут — возможно (надо исследовать, может удастся добиться, чтобы эта конкретная аллокация скаляризовалась). А для этого нужен какой-то набор примеров, на которых можно посмотреть, когда работает, когда не работает. И фреймворк, чтобы было легко писать эти примеры.
Моя задача была экспериментальной: допустим, у меня есть JDK на компьютере — какую информацию о принципах работы escape-анализа я могу вытащить, не обращаясь с вопросами к авторитетам? То есть это такой естественнонаучный подход: у нас есть почти черный ящик, в который мы «тыкаем» и смотрим, как он будет работать.
Прежде чем мы перейдем к самим экспериментам — еще небольшое теоретическое отступление. Важно понимать, что escape-анализ и скаляризация — это лишь часть большого набора оптимизаций, который есть в серверном компиляторе. В очень общих чертах процесс оптимизации C2 представлен на рисунке.
![](https://habrastorage.org/getpro/habr/post_images/4a4/0e8/e0c/4a40e8e0cd5fcae283409ddf97fceda3.png)
Важно здесь то, что еще до escape-анализа за дело берутся другие инструменты оптимизации. Например — инлайнинг, девиртуализация, сворачивание констант и выделение частых или не частых маршрутов (на самом деле их гораздо больше, но здесь я указал те, которые чаще всего влияют на escape-анализ). И чтобы, по результатам escape-анализа, какие-то объекты скаляризовались, необходимо, чтобы хорошо отработали все предыдущие звенья цепи, предыдущие оптимизации, до escape-анализа и скаляризации. И что-то сломаться, не получиться может на любом этапе, но, как мы увидим, чаще всего что-то ломается как раз-таки еще до escape-анализа. И лишь в некоторых случаях именно сам escape-анализ не справляется с задачей.
Инструментарий
![](https://habrastorage.org/getpro/habr/post_images/e2b/b87/465/e2bb87465e9e1ad415b623e0868ac3d0.png)
Несколько лет назад, пытаясь экспериментировать со скаляризацией, я в основном опирался на
GarbageCollectorMXBean.getCollectionCount()
. Это довольно грубая метрика. Но теперь у нас есть более ясная мерика — ThreadMBean.getThreadAllocatedBytes(threadId)
, который прямо по ID потока говорит, сколько байт было аллоцировано этим конкретным потоком. Для экспериментирования больше ничего и не надо, однако первую, старую, метрику я использовал поначалу, чтобы сверять результаты. Еще один способ контроля — отключить скаляризацию соответствующим ключом (-XX:-EliminateAllocations
) и посмотреть, действительно ли наблюдаемый эффект определяется escape-анализом.Если результат теста нас удивляет, есть ключики PrintCompilation и PrintInlining, позволяющие получить больше информации. Есть еще третий ключик, LogCompilation, который выдает все то же самое, только гораздо больше, и в xml формате — его выдачу можно скормить утилитке JITWatch, которая вам все представит в красивом UI.
Логичен вопрос: почему бы не использовать JMH? JMH действительно может это делать. У него есть профайлер,
-prof gc
, который выводит те же аллокации, и даже нормированные на одну итерацию. ![](https://habrastorage.org/getpro/habr/post_images/500/b76/a6f/500b76a6fad2c8df32e39259ae34b80b.png)
И поначалу и я пытался зайти с этой стороны. Но дело в том, что JMH в первую очередь заточен на перформанс, который меня не очень интересует. Меня не интересует, сколько времени у меня ушло на итерацию; меня интересует, сработала ли там конкретная оптимизация, иными словами, мне нужен триггерный ответ. А здесь очень много информации, которую я сходу не нашел, как убрать. И в итоге для себя решил, что если я хочу сегодня в течение получаса получить результат, то проще написать самому. Поэтому у меня есть свой «велосипед». Но если кто-то хочет продолжать эти эксперименты или делать какие-то свои, я очень рекомендую взять стандартный инструмент, поскольку стандартный обычно лучше.
Часть 1. Основы
Пример 1.1. Basic
Начнем с простого теста: похожего на пример в релизе Sun.
У нас есть простенький класс Vector2D. Мы создаем три случайных вектора с помощью рандома и выполняем с ними некую операцию (складываем и вычисляем скалярное произведение). Если мы запустим это в современной JVM, сколько объектов здесь будет создано?
![](https://habrastorage.org/getpro/habr/post_images/9f0/992/1ff/9f09921ff786b852c9c5e20c24be0420.png)
В результате в начале что-то аллоцируется (пока еще не прошла компиляция), ну а дальше все очень чистенько — 0 байт на вызов.
Это канонический пример, так что ничего удивительного в том, что он работает.
Для контроля добавляем ключ, отключающий стирание аллокаций — и мы получаем 128 байт на вызов. Это как раз четыре объекта Vector2D: три явно создались, и еще один появился в ходе сложения.
![](https://habrastorage.org/getpro/habr/post_images/215/9e7/276/2159e72767cc232a2a7bc19c66c46215.png)
Пример 1.2. Loop accumulate
Добавим цикл в предыдущий пример.
Мы заводим вектор-аккумулятор, к которому будем добавлять вектора внутри цикла.
![](https://habrastorage.org/getpro/habr/post_images/ee8/e80/ca7/ee8e80ca7aa66474d7e90fd9c77c46d6.png)
В этом сценарии все тоже хорошо (для любого значения
SIZE
, который я исследовал).![](https://habrastorage.org/getpro/habr/post_images/992/892/1f9/9928921f90a0dd4341d3bd2d4755fbf6.png)
Пример 1.3. Replace in loop
На этот раз сделаем умножение на константу — на double, а полученный результат запишем в ту же самую переменную. На самом деле это тот же аккумулятор, только здесь мы умножаем вектор на какое-то число.
![](https://habrastorage.org/getpro/habr/post_images/820/f8c/29b/820f8c29bd16aa29feac7089def90390.png)
Неожиданно, но здесь скаляризация не сработала (2080 байт = 32* (SIZE + 1)).
![](https://habrastorage.org/getpro/habr/post_images/fea/1bd/a77/fea1bda77923eb459033227cd4c30159.png)
Прежде чем выяснять почему, рассмотрим еще пару примеров.
Пример 1.4. Control flow
Более простой пример: у нас нет цикла, есть условный переход. Мы случайным образом выбираем координату и создаем Vector2D.
![](https://habrastorage.org/getpro/habr/post_images/d62/55b/68b/d6255b68b70c78a41f2b5cebb72ff062.png)
И здесь скаляризация не помогает: все время создается один вектор — те самые 32 байта.
![](https://habrastorage.org/getpro/habr/post_images/dba/4ca/75e/dba4ca75e07a9cb9634d85da4aa7c377.png)
Пример 1.5. Control flow
Попробуем немного изменить этот пример. Я просто внесу создание вектора внутрь обеих веток:
![](https://habrastorage.org/getpro/habr/post_images/3ff/a15/659/3ffa1565937a45e78e693d2ec5981857.png)
И здесь все отлично скаляризуется.
![](https://habrastorage.org/getpro/habr/post_images/fe8/314/45b/fe831445b65fab5dc8f0fe1dc2d2d03e.png)
Начинает вырисовываться картина — что здесь происходит?
«Merge points»
![](https://habrastorage.org/getpro/habr/post_images/8fb/db7/e68/8fbdb7e682f8432041f35888482d3871.png)
Представим, что у нас есть поток исполнения в программе. Есть одна ветвь, в которой мы создали объект v1, и вторая ветвь, в которой создали объект v2. В третью переменную, v3, мы записываем ссылку либо на первый объект, либо на второй, в зависимости от того, по какому маршруту пошло выполнение. В конце мы возвращаем какое-то поле через ссылку v3. Предположим, что произошла скаляризация и поля v1.x, v1.y, v2.x, v2.y превратились в локальные переменные, допустим, v1$x, v1$y, v2$x, v2$y. А что делать со ссылкой v3? А точнее: во что должно превратиться обращение к полю v3.x?
Это вопрос. В каких-то простых примерах, как здесь, или в примере 1.4, решение интуитивно понятно: если этот код, это все, что у нас есть — то нужно просто return внести внутрь условия, будет два return-а, по одному на каждую ветку, и каждый будет возвращать свое значение. Но случаи бывают более сложные, и в итоге разработчики JVM решили, что они просто не будут оптимизировать этот сценарий, т.к. в общем случае сделать это — разобраться, поле какого объекта нужно использовать — оказалось слишком сложно (см например баг JDK-6853701, или соответствующие комментарии в исходном коде JVM).
Подводя итог этому примеру, скаляризации не будет, если:
- ссылочная переменная может указывать более чем на один объект;
- даже если такое может случиться в разных сценариях исполнения.
Если вы хотите увеличить шансы на скаляризацию, то одна ссылка должна указывать на один объект. Даже если она всегда указывает на один объект, но в разных сценариях исполнения это могут быть разные объекты — даже это сбивает с толку escape-анализ.
Часть 2. EqualsBuilder
Это класс из commons.lang, идея которого состоит в том, что вы equals-ы можете генерировать таким вот образом, добавляя поля вашего класса в Builder. Честно говоря, я сам его не использую, мне просто нужен был пример какого-то Builder-а, и он попался под руку. Реальный пример обычно лучше, чем синтетический.
![](https://habrastorage.org/getpro/habr/post_images/2f4/bb8/028/2f4bb802895232bfe62dc101b1d60ed1.png)
Конечно, было бы хорошо, если бы эта штука скаляризовалась, потому что создавать объекты на каждый вызов equals — не очень хорошая идея.
Пример 2.1. EqualsBuilder
Я написал простой кусок кода — только два int-а, выписанных явно (но даже если бы там были указаны поля, сути это бы не изменило).
![](https://habrastorage.org/getpro/habr/post_images/e11/fad/019/e11fad01972bcf9836cee3aace32d134.png)
Вполне ожидаемо, эта ситуация скаляризуется.
![](https://habrastorage.org/getpro/habr/post_images/926/f19/158/926f19158cf6d1358c6b97dfff39d0ce.png)
Пример 2.2. EqualsBuilder
Немного изменим пример: вместо двух int-ов поставим две строки.
![](https://habrastorage.org/getpro/habr/post_images/b18/c89/085/b18c89085329ac0c29212e023d58fbf3.png)
В результате скаляризация не работает.
![](https://habrastorage.org/getpro/habr/post_images/a1d/0eb/014/a1d0eb014b9f47508b520e12898106cf.png)
Не будем пока лезть в метод .append(...). Для начала у нас есть ключи, которые хотя бы вкратце рассказывают, что происходит в компиляторе.
![](https://habrastorage.org/getpro/habr/post_images/cc6/79d/1f9/cc679d1f979366dbbcc272fca4771ab0.png)
Выясняется, что метод append не заинлайнился, соответственно, escape-анализ не может понять: вот эта ссылка на builder, которая ушла внутрь метода .append() как this — что там с ней происходит, внутри метода? Это неизвестно (потому что внутрь метода .append компилятор не заглядывает — JIT не делает меж-процедурную оптимизацию). Может, ее там в глобальную переменную присвоили. И в подобных ситуациях escape-анализ сдается.
Что означает диагностика «hot method too big»? Она означает, что метод — горячий, т.е. вызывался достаточно много раз, и размер его байткода больше, чем некий предел, порог инлайнинга (предел именно для частых методов). Этот предел — он задается ключом FreqInlineSize, и по-умолчанию он 325. А в диагностике мы видим 327 — то есть мы промахнулись всего на 2 байта.
Вот содержимое метода — легко поверить, что там есть 327 байт:
![](https://habrastorage.org/getpro/habr/post_images/f62/f83/509/f62f8350993576b4e1be7b1179eb862f.png)
Как мы можем проверить нашу гипотезу? Мы можем добавить ключ FreqInlineSize, и увеличить порог инлайнинга, допустим, до 328:
![](https://habrastorage.org/getpro/habr/post_images/c6d/241/e65/c6d241e653ff1aaaa26121cf96267a81.png)
В профиле компиляции мы видим, что .append() теперь инлайнится, и все отлично скаляризуется:
![](https://habrastorage.org/getpro/habr/post_images/ae1/8e3/50c/ae18e350cec83cf35cfaff1aabbde81f.png)
Уточню: когда я здесь (и далее) меняю флаги JVM, параметры JIT-компиляции, я делаю это не для того, чтобы исправить ситуацию, а чтобы проверить гипотезу. Я бы не рекомендовал играться с параметрами JIT-компиляции, поскольку они подобраны специально обученными людьми. Вы, конечно, можете попробовать, но эффект сложно предсказать — каждый такой параметр влияет не на один конкретный метод, в котором захотелось что-то скаляризовать, а на всю программу в целом.
Вывод 2.
- Инлайнинг — лучший друг адаптивных рантаймов
- а краткость ему очень сильно помогает.
Пишите методы покороче. В частности, в примере с .append() есть большая простыня, которая работает с массивами — пытается сделать сравнение массивов. Если ее просто вынести в отдельный метод, то все отлично инлайнится и скаляризуется (я пробовал). Это такой черный (хотя может и белый) ход для этой эвристики инлайнинга: метод в 328 байт не инлайнится, но он же, разбитый на два метода по 200 байт — отлично инлайнится, потому что каждый метод по отдельности пролезает под порогом.
Часть 3. Multi-values return
Рассмотрим возвращение из метода кортежа (tuple) — нескольких значений за раз.
Возьмем какой-нибудь простой объект, типа Pair, и совсем тривиальный пример: мы возвращаем пару строк, случайно выбранных из какого-то заранее заполненного пула. Чтобы компилятор вообще не выкинул этот код, я внесу некий побочный эффект: что-то с этими строками типа посчитаю, и верну результат.
![](https://habrastorage.org/getpro/habr/post_images/9cd/a67/f4a/9cda67f4aaf69b2170ce8c66a1fc12b5.png)
Этот сценарий — скаляризуется. И это вполне рабочий пример, им можно пользоваться: если метод будет горячий и заинлайнится, такие multi-value return отлично скаляризуются.
![](https://habrastorage.org/getpro/habr/post_images/146/172/cb8/146172cb85019893ad84c775e7e9df89.png)
Пример 3.1. value or null
Немного изменим пример: при каких-то обстоятельствах вернем null.
![](https://habrastorage.org/getpro/habr/post_images/fbb/763/4e5/fbb7634e54f7d6f4d5a091a512b00d75.png)
Как видно, аллокация останется (среднее количество байт на вызов не целое, потому что иногда возвращается null, который ничего не стоит).
![](https://habrastorage.org/getpro/habr/post_images/05d/32b/4bc/05d32b4bc6dc1371bd587bd121593f54.png)
Пример 3.2. Mixed types?
Более сложный пример: у нас есть интерфейс-Pair и 2 реализации этого интерфейса. В зависимости от искусственного условия, возвращаем либо ту реализацию, либо другую.
![](https://habrastorage.org/getpro/habr/post_images/09a/f2d/32b/09af2d32b9beff37427022b49d01662e.png)
Здесь тоже остается аллокация:
![](https://habrastorage.org/getpro/habr/post_images/c2a/8fc/d0c/c2a8fcd0c5bc00bce46e4c8c573bae56.png)
Честно говоря, изначально я был уверен, что дело было именно в разных типах, и долго в это верил, пока не сделал следующий пример с одинаковыми типами, который также не скаляризуется.
![](https://habrastorage.org/getpro/habr/post_images/61c/68b/c01/61c68bc0173fa1817bfe903ada992ace.png)
![](https://habrastorage.org/getpro/habr/post_images/337/968/a18/337968a189733f7e27d3833379528738.png)
Что здесь происходит? Ну, если мы попробуем ручками заинлайнить все методы, то увидим тот же сценарий с merge points (=ссылка может прийти двумя путями), что и в самом первом нашем эксперименте:
![](https://habrastorage.org/getpro/habr/post_images/886/5da/4a3/8865da4a3f220741161445609ca97bfb.png)
Вывод 3:
Будьте проще: меньше веток — меньше вероятность запутать escape-анализ
Пример 4. Итераторы
Еще один частый паттерн и очень часто появляющийся промежуточный объект, создания которого хотелось бы избежать.
Вот очень простой сценарий с итерацией по коллекции. Мы создаем коллекцию один раз, мы не пересоздаем ее на каждую итерацию, но мы пересоздаем итератор: на каждом запуске метода мы бежим по коллекции итератором, считаем некий побочный эффект (просто чтобы компилятор не выкинул этот кусок).
![](https://habrastorage.org/getpro/habr/post_images/b68/fd2/7e8/b68fd27e8a10043f36001019d5887b6f.png)
Рассмотрим этот сценарий для разных коллекций. Допустим, сначала для ArrayList-а
Пример 4.1. ArrayList.iterator
![](https://habrastorage.org/getpro/habr/post_images/ad9/fe6/ec0/ad9fe6ec0ea4121f3024e95fbf705fd4.png)
Для ArrayList-а итератор действительно скаляризуется (размер SIZE здесь взят условный: как правило, это стабильно работает для широкого спектра SIZE). Для LinkedList это тоже работает. Я не буду долго перебирать все варианты — вот сводная таблица тех коллекций, что я попробовал:
![](https://habrastorage.org/getpro/habr/post_images/61c/acd/7b2/61cacd7b26cb20380f06b082791cc523.png)
В Java 8 все эти итераторы (по крайней мере в простых сценариях) скаляризуются.
Но в самом свежем апдейте Java 7 все хитрее. Давайте мы на нее пристальнее посмотрим (все знают, что 1.7 уже end of life, 1.7.0_80 это последний апдейт, который есть).
Для LinkedList с размером 2 все хорошо:
![](https://habrastorage.org/getpro/habr/post_images/787/a0c/a20/787a0ca2045dd29544d5646b699fe9cd.png)
А вот для LinkedList с размером 65 — нет.
![](https://habrastorage.org/getpro/habr/post_images/e6a/942/a91/e6a942a9178c1d531afa6d1efbbee4c6.png)
Что происходит?
Берем волшебные ключики, и для размера 2 мы получаем такой кусок лога инлайнинга:
![](https://habrastorage.org/getpro/habr/post_images/545/ae0/ae0/545ae0ae0291c8073a0a638be68eebc8.png)
А для размера 65:
![](https://habrastorage.org/getpro/habr/post_images/460/689/6cb/4606896cbdd85c9a23b4f9cc9946dfc9.png)
Ближе к началу того же лога можно найти еще вот такой дополнительный фрагмент картинки:
![](https://habrastorage.org/getpro/habr/post_images/57c/ccf/0d6/57cccf0d6e7ee3844d672f257829357d.png)
Происходит следующее: в самом начале метод, который мы профилируем, пошел на компиляцию — JIT поставил его в очередь. JIT работает асинхронно, т.е. у него есть очередь, туда скидываются задачи на компиляцию, а он в отдельном потоке (или даже нескольких потоках) с какой-то скоростью выгребает их из очереди, и компилирует. То есть между моментом, когда ему поставили задачу, и тем моментом, когда новый код будет оптимизирован, проходит некоторое время.
И вот наш метод
iterate()
пошел первый раз на компиляцию, в ходе которой обнаружилось, что метод LinkedList.listIterator()
внутри него еще слишком мало выполнялся. Не наработал еще на то, чтобы его заинлайнить (MinInliningThreshold
= 250 вызовов). Когда же, еще через некоторое время, вызов iterate()
пошел на перекомпиляцию — обнаружилось, что скомпилированный (машинный) код LinkedList.listIterator()
слишком большой. Да, а что именно означают диагностики:
![](https://habrastorage.org/getpro/habr/post_images/0e4/fd4/343/0e4fd4343ce6b323c7c52f5095ded055.png)
Они означают, что, оценивая размер уже скомпилированных методов, мы смотрим на их машинный код, а не байт-код (т.к. это более адекватная метрика). И эти две эвристики — по размеру байт-кода и машинного кода — не обязательно согласованы. Метод из всего пяти байт-кодов может вызывать несколько других методов, которые будут вклеены, и увеличат размер его машинного кода выше порогов. С этой рассогласованностью ничего нельзя сделать кардинально, только подстраивать более-менее пороги разных эвристик, ну и надеяться, что в среднем все будет более-менее хорошо.
Пороги — в частности, InlineSmallCode — отличаются в разных версиях. В 8-ке InlineSmallCode вдвое больше, поэтому в Java 8 этот сценарий отрабатывает успешно: методы инлайнятся и итератор скаляризуется — а в 7-ке нет.
В этом примере важно, что он неустойчив. Вам должно (не)повезти, чтобы задачи на компиляцию пошли в таком порядке. Если бы на момент второй перекомпиляции метод
LinkedList.listIterator()
еще не был бы скомпилирован независимо — у него еще не было бы машинного кода, и он бы прошел по критерию размера байт-кода и успешно заинлайнился бы. Именно поэтому результат и зависит от размера списка — от количества итераций внутри цикла зависит то, насколько быстро разные методы будут отправляться на компиляцию.Мы можем проверить эту нашу гипотезу: поиграться с порогами. И действительно, при их подгонке скаляризация начинает срабатывать:
![](https://habrastorage.org/getpro/habr/post_images/d95/7bb/27d/d957bb27d4c6cf9a34a25bfac672000f.png)
Вывод 4:
- JVM первой свежести лучше, чем не первой свежести;
- -XX:+PrintInlining — очень хорошая диагностика, одна из основных, позволяющих понять, что происходит при скаляризации;
- тестируйте на реальных данных — я имею в виду, что не надо тестировать на размере 2, если вы ожидаете 150. Тестируйте на 150 и вы можете увидеть отличия;
- ArrayList опять обставил LinkedList!
Динамические рантаймы — это рулетка. JIT-компиляции свойственна недетерминированность, это неизбежно. В свежих версиях (8-ке) параметры эвристик чуть лучше согласованы друг с другом, но недетерминированности это не отменяет, просто ее сложнее поймать.
Пример 4.4. Arrays.asList()
Есть отдельный интересный вариант коллекции — обертка вокруг массива, Arrays.asList(). Хотелось бы, чтобы эта обертка ничего не стоила, чтобы JIT ее скаляризовал.
Я начну здесь с довольно странного сценария — сделаю из массива список, а потом по списку пойду, как будто по массиву, индексом:
![](https://habrastorage.org/getpro/habr/post_images/987/ff8/f25/987ff8f25ba690ddc2888f3c226a8ad6.png)
Здесь все работает, создание обертки скаляризуется.
![](https://habrastorage.org/getpro/habr/post_images/6a7/8f6/01f/6a78f601fd26ba68ac7f0e71d2c11fdd.png)
А теперь вернемся к итератору — нет же особого смысла оборачивать массив в список, чтобы потом ходить по списку, как по массиву:
![](https://habrastorage.org/getpro/habr/post_images/1d3/da3/899/1d3da3899daca40688c0b7d0f6596b60.png)
Увы, даже в самой свежей версии java аллокация остается.
![](https://habrastorage.org/getpro/habr/post_images/d9d/bd6/531/d9dbd653192a52e1670b8e0180332e7b.png)
При этом в PrintInlining мы ничего особенного не видим.
![](https://habrastorage.org/getpro/habr/post_images/ed4/ba1/de5/ed4ba1de530b7da4798e028e1b143373.png)
Но если посмотреть внимательнее, то заметно, что итератор в Arrays$ArrayList не свой — его реализация унаследована целиком от AbstractList-а:
![](https://habrastorage.org/getpro/habr/post_images/637/e42/086/637e42086949f3b577f6f33f0a6a1c84.png)
И AbstractList$Itr — это внутренний класс, не-статический внутренний класс. И вот то, что он не-статический — почему-то мешает скаляризации. Если переписать класс итератора (то есть скопировать весь класс Arrays$ArrayList к себе, и модифицировать), сделать итератор «отвязанным» — в итератор передается массив, и итератор не содержит больше ссылки на объект списка — тогда в этом сценарии будет успешно скаляризоваться как аллокация итератора, так и аллокация самой обертки Arrays$ArrayList.
![](https://habrastorage.org/getpro/habr/post_images/402/cb4/1a9/402cb41a9c4d5e1c6f62b566759e6d07.png)
![](https://habrastorage.org/getpro/habr/post_images/b00/a18/2f1/b00a182f1b674d2f1022f699d9b8b79f.png)
Это довольно загадочный случай, и, похоже, что это баг в JIT-е, но на сей день мораль такова: вложенные объекты сбивают скаляризацию с толку.
Пример 4.4. Collections.*
У нас есть еще сколько-то таких вот коллекций-синглетонов, и все они, и их итераторы, успешно скаляризуются и в актуальной, и в предыдущей версиях java, кроме упомянутого выше Arrays.asList.
![](https://habrastorage.org/getpro/habr/post_images/1c3/90f/d63/1c390fd63483d18b2a7a8f4c7b6eeaa6.png)
Вывод 4.4.
Вложенные объекты не очень хорошо скаляризуются.
- итерация по оберткам из Collections.* скаляризуется
- …Кроме Arrays.asList();
- вложенные объекты не скаляризуются (в том числе inner classes);
- -XX:+PrintInlining продолжает помогать в беде.
Пример 5. Constant size arrays
Сразу уточню — на скаляризацию массивов переменного размера (т.е. размера, который JIT не сумеет предсказать) даже не надейтесь. Мы работаем с массивами постоянной длины.
Пример 5.1. Variable index
Рассмотрим такой пример: мы берем массив, туда что-то записываем по ячейкам, потом оттуда что-то вычитываем.
![](https://habrastorage.org/getpro/habr/post_images/31b/e25/1e1/31be251e152acbf0cc79ab630e479ff7.png)
Для размера 1 — все нормально.
![](https://habrastorage.org/getpro/habr/post_images/b16/edb/1ec/b16edb1ec3a7419f2866100c8a9bb2d4.png)
А для размера 2 — ничего не получается.
![](https://habrastorage.org/getpro/habr/post_images/500/184/504/500184504508b49e8a48607ebd939573.png)
Пример 5.2. Constant index
Попробуем немного другой доступ: возьмем тот же размер 2 и просто напросто развернем (unroll) цикл ручками — возьмем и обратимся по явному индексу:
![](https://habrastorage.org/getpro/habr/post_images/009/1f4/a24/0091f4a247427d8db334c10f56739e3f.png)
В этом случае, как ни странно, скаляризация сработает.
![](https://habrastorage.org/getpro/habr/post_images/9a8/63a/df9/9a863adf92be77cc914604292b139959.png)
Не буду долго рассуждать — ниже приведена сводная табличка. Этот случай с развернутым вручную циклом скаляризуется вплоть до размера 64. Если есть какой-то переменный индекс, размеры 1 и 2 еще кое-как скаляризуются, дальше — нет.
![](https://habrastorage.org/getpro/habr/post_images/c09/3a7/4cb/c093a74cb2a2d816612a63f6bb486eb1.png)
Как мне кто-то в блоге написал, в «JVM для всего есть свой ключик». Этот верхний порог (-XX:EliminateAllocationArraySizeLimit = 64) также можно задавать, хотя, мне кажется, в этом нет смысла. В предельном случае будет 64 дополнительных локальных переменных, что слишком много.
Пример 5.3. Primitive arrays
Точно такой же код, только с массивом примитивных типов — int-ом, short-ом…
![](https://habrastorage.org/getpro/habr/post_images/517/8ec/f14/5178ecf143e95a46913946c3a44a48ed.png)
Все работает точно в тех же случаях, что и для объектов.
![](https://habrastorage.org/getpro/habr/post_images/2c0/373/4fa/2c03734face8d6f0124cb386050520db.png)
Почему не получается скаляризовать массив, по которому проходим циклом? Потому что непонятно, какой именно индекс скрывается за i. Если у вас есть в коде обращение типа array[2], то JIT может превратить это в локальную переменную типа array$2. А во что превратить array[i]? Нужно знать, чему именно равна i. В каких-то частных случаях, в случае коротких массивов JIT может это «угадать», в общем случае — нет.
Пример 5.4. Preconditions
В библиотеке guava есть такой замечательный метод, как
checkArguments(expression, errorMessageTemplate, args...)
, который проверяет выражение expression, и выбрасывает исключение с форматированным сообщением если expression == false. У него последний аргумент — vararg, и это интересный пример, как продолжение темы с массивами. И весь этот массив аргументов нам реально нужен, реально используется внутри checkArguments
только если expressions == false, если проверка провалилась. Здесь у меня пример, где expression генерируется случайно. И интересно: как зависит скаляризация массива vararg в этом примере от того, с какой вероятностью expression становится false?
![](https://habrastorage.org/getpro/habr/post_images/989/87a/166/98987a166a0fb7e10efbd89eaef95f27.png)
Для начала возьмем вероятность провала — 10-7:
![](https://habrastorage.org/getpro/habr/post_images/3b6/cd9/916/3b6cd99165731c8e4dfab62136b3e7e8.png)
Здесь скаляризация не очень удается.
При уменьшении вероятности до 10-9, поначалу все, вроде, идет в хорошую сторону, но потом все-таки скаляризация отваливается.
![](https://habrastorage.org/getpro/habr/post_images/9f9/9a5/70f/9f99a570f6f159dc1acda7c65db6a444.png)
Если же вероятность совсем маленькая, мы более-менее стабильно приходим сюда:
![](https://habrastorage.org/getpro/habr/post_images/f0b/e21/5d9/f0be215d92f4ba6d5f407a3556dc1b0f.png)
… к устойчивой скаляризации.
Получается, что такой паттерн — с checkArguments, или аналогичным vararg — можно использовать, можно рассчитывать на скаляризацию, но только если вы действительно ожидаете, что expression никогда не будет false. В случае с checkArguments, если expression оказывается false, то это вообще-то означает, что мы наступили на какой-то баг в своем коде. И если у нас нет багов, то, по крайней мере в горячем коде, этот false никогда не возникает, и вся эта конструкция с vararg в идеале ничего не будет нам стоить, с точки зрения аллокации.
Итог
- Скаляризуется многое из того, что хотелось бы (хотя здесь далеко не все примеры);
- что не скаляризуется, можно понять и простить (т.е. не всегда можно простить разработчика за это, но можно понять, почему это вышло: ментальную модель какую-то вполне реально создать, не слишком сложную); иногда исправить
- в свежих JVM скаляризация работает лучше.
Что не очень радует: скаляризация иногда хрупкая и не стабильная (особенно в несвежих JVM). Иногда все зависит от того, в каком порядке задачи пошли на компиляцию, и это конечно огорчает. Нужно понимать ограничения и всегда полезно тестировать важные сценарии. Если в критичном по перформансу коде есть расчет на какую-то скаляризацию, это обязательно нужно протестировать.
Напоследок — краткая сводка рекомендаций:
- самая свежая JVM;
- короткие методы (проверяем инлайнинг);
- меньше реального полиморфизма (вы можете оперировать интерфейсами, если реализация у него на самом деле 1; 2-3 — тоже неплохо, но, чем больше, тем сложнее);
- одна ссылка указывает на один объект;
- не использовать null — это все опять про простоту;
- не использовать вложенные объекты — это, конечно, ограничение неприятное, но с ним приходится жить;
- важные сценарии нужно тестировать.
Если вы дочитали до конца и вам хочется еще – в апреле мы проводим JPoint 2017 (7-8 апреля) в Москве и JBreak 2017 (4 апреля) в Новосибирске. Предварительные программы обеих конференций уже готовы, много докладов опубликовано – есть на что посмотреть, поэтому рекомендую.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Поделиться с друзьями
leotsarev
В последнем примере. Не знаю, как в Java, но в C# помогает унести редко используемую часть метода в отдельный метод. Тогда checkexpression устроится, а хелпер, бросающий exception, нет (что нам и нужно). Это более менее стандартный паттерн, хорошо виден по стектрейсам стандартных библиотек.
sunless
JIT заменит холодную часть метода на uncommon trap. В случае, если мы таки попали в редкую часть метода — управление свалится в интерпретатор, а в бэкграунде запиститься замещающая компиляция. Для хотспота я бы не стал разбивать код на хелпер методы, чтобы "помочь JITу".
cheremin
Смотря для чего "помогает". Вынесение редких кусков кода в отдельный метод — это и в джаве стандартный прием. Он помогает и с логической точки зрения — основная задача отделена от вспомогательных, и часто помогает JIT-у, потому что часто выполняющийся метод становится короче, и с большей вероятностью вклеивается. Но не в этом случае
Нет, это не то, что нам нужно, ровно наоборот: с точки зрения escape-анализа если ссылка уходит в невклеенный метод — это значит, что она "убегает" за границы анализа, т.е. доступна неопределенному кругу лиц. И значит никакой скаляризации, точка.
… Кроме случая, когда холодная ветка кода прямо совсем уж холодная — вообще ни разу не вызывалась за время сбора профиля. Тогда JIT может спекулятивно предположить, что этот кусок никогда и не будет вызываться, и скомпилировать метод вообще без этого куска, вставив охранную проверку, uncommon trap, со ссылкой на полную версию метода в интерпретируемом режиме, и рекомпиляцию. Именно это здесь и происходит, когда вероятность попадания на исключение пренебрежимо мала.
Если же вероятность попадания в редкую ветку все-таки не нулевая — то выносить ее в отдельный метод с точки зрения скаляризации как раз-таки хуже, потому что редкий метод скорее всего не будет вклеен. Здесь я разбирал такое на примере HashMap