В этот раз мы решили разнообразить поток технических интервью реальным хардором и подготовили материал на основе доклада Руслана cheremin Черемина (Deutsche Bank) про анализ работы пары Escape Analysis и Scalar Replacement, сделанный им на JPoint 2016 в апреле минувшего года.

Видеозапись доклада перед вами:



А под катом мы выложили полную текстовую расшифровку с отдельными слайдами.

Начнем с небольшого лирического отступления, касающегося терминологии.

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.


Источник: dolzhenko.blogspot.ru

Конечно, так бывает очень редко, это исключительный случай. В более реалистичных примерах по разным данным в среднестатистическом приложении escape-анализ способен устранить порядка 15% аллокаций, ну, а если сильно повезет — то до 70%.

Когда этот инструмент вышел в 2010 году, я был, честно говоря, очень им вдохновлен. Я тогда как раз только закончил проект, где было много околонаучных вычислений, в частности, мы активно жонглировали всякими векторами. И у нас было очень много объектов, которые живут от предыдущей инструкции до следующей. Когда я на это смотрел, у меня в голове возникала крамольная мысль, что на С здесь было бы лучше. И прочитав про эту оптимизацию, я понял, что она могла бы решить подобные проблемы. Однако у Sun в релизе был очень скромный пример ее работы, поэтому я ждал какого-то более обширного описания (в каких ситуациях она работает, в каких — нет; что нужно, чтобы это работало). И ждал я довольно долго.

К сожалению, за 7 лет я нашел упоминания лишь о трех случаях применения, один из которых был примером самого Sun. Проблемой всех примеров было то, что в статьях приводился кусок кода с комментарием: «вот так оно работает». А если я переставлю инструкции —  не сломается ли скаляризация от этого? А если вместо ArrayList я возьму LinkedList, будет ли это работать? Мне это было непонятно. В итоге я решил, что я так и не дождусь чужих исследований, т.е. эту работу придется сделать самому.

Путь экспериментов


Что я хотел получить? В первую очередь, я хотел какое-то интуитивное понимание. Понятно, JIT-компиляция вообще — это очень сложная штука, и она зависит от многих вещей. Чтобы понимать ее в деталях, надо работать в Oracle. Такой задачи у меня не было. Мне необходимо какое-то интуитивное понимание, чтобы я смотрел на код и мог оценить, что вот здесь — почти наверняка да, а тут — почти наверняка нет, а вот тут — возможно (надо исследовать, может удастся добиться, чтобы эта конкретная аллокация скаляризовалась). А для этого нужен какой-то набор примеров, на которых можно посмотреть, когда работает, когда не работает. И фреймворк, чтобы было легко писать эти примеры.

Моя задача была экспериментальной: допустим, у меня есть JDK на компьютере —  какую информацию о принципах работы escape-анализа я могу вытащить, не обращаясь с вопросами к авторитетам? То есть это такой естественнонаучный подход: у нас есть почти черный ящик, в который мы «тыкаем» и смотрим, как он будет работать.

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



Важно здесь то, что еще до escape-анализа за дело берутся другие инструменты оптимизации. Например — инлайнинг, девиртуализация, сворачивание констант и выделение частых или не частых маршрутов (на самом деле их гораздо больше, но здесь я указал те, которые чаще всего влияют на escape-анализ). И чтобы, по результатам escape-анализа, какие-то объекты скаляризовались, необходимо, чтобы хорошо отработали все предыдущие звенья цепи, предыдущие оптимизации, до escape-анализа и скаляризации. И что-то сломаться, не получиться может на любом этапе, но, как мы увидим, чаще всего что-то ломается как раз-таки еще до escape-анализа. И лишь в некоторых случаях именно сам escape-анализ не справляется с задачей.

Инструментарий




Несколько лет назад, пытаясь экспериментировать со скаляризацией, я в основном опирался на GarbageCollectorMXBean.getCollectionCount(). Это довольно грубая метрика. Но теперь у нас есть более ясная мерика — ThreadMBean.getThreadAllocatedBytes(threadId), который прямо по ID потока говорит, сколько байт было аллоцировано этим конкретным потоком. Для экспериментирования больше ничего и не надо, однако первую, старую, метрику я использовал поначалу, чтобы сверять результаты. Еще один способ контроля — отключить скаляризацию соответствующим ключом (-XX:-EliminateAllocations) и посмотреть, действительно ли наблюдаемый эффект определяется escape-анализом.

Если результат теста нас удивляет, есть ключики PrintCompilation и PrintInlining, позволяющие получить больше информации. Есть еще третий ключик, LogCompilation, который выдает все то же самое, только гораздо больше, и в xml формате — его выдачу можно скормить утилитке JITWatch, которая вам все представит в красивом UI.

Логичен вопрос: почему бы не использовать JMH? JMH действительно может это делать. У него есть профайлер, -prof gc, который выводит те же аллокации, и даже нормированные на одну итерацию.



И поначалу и я пытался зайти с этой стороны. Но дело в том, что JMH в первую очередь заточен на перформанс, который меня не очень интересует. Меня не интересует, сколько времени у меня ушло на итерацию; меня интересует, сработала ли там конкретная оптимизация, иными словами, мне нужен триггерный ответ. А здесь очень много информации, которую я сходу не нашел, как убрать. И в итоге для себя решил, что если я хочу сегодня в течение получаса получить результат, то проще написать самому. Поэтому у меня есть свой «велосипед». Но если кто-то хочет продолжать эти эксперименты или делать какие-то свои, я очень рекомендую взять стандартный инструмент, поскольку стандартный обычно лучше.

Часть 1. Основы


Пример 1.1. Basic


Начнем с простого теста: похожего на пример в релизе Sun.


У нас есть простенький класс Vector2D. Мы создаем три случайных вектора с помощью рандома и выполняем с ними некую операцию (складываем и вычисляем скалярное произведение). Если мы запустим это в современной JVM, сколько объектов здесь будет создано?



В результате в начале что-то аллоцируется (пока еще не прошла компиляция), ну а дальше все очень чистенько — 0 байт на вызов.
Это канонический пример, так что ничего удивительного в том, что он работает.
Для контроля добавляем ключ, отключающий стирание аллокаций — и мы получаем 128 байт на вызов. Это как раз четыре объекта Vector2D: три явно создались, и еще один появился в ходе сложения.



Пример 1.2. Loop accumulate


Добавим цикл в предыдущий пример.
Мы заводим вектор-аккумулятор, к которому будем добавлять вектора внутри цикла.



В этом сценарии все тоже хорошо (для любого значения SIZE, который я исследовал).



Пример 1.3. Replace in loop


На этот раз сделаем умножение на константу — на double, а полученный результат запишем в ту же самую переменную. На самом деле это тот же аккумулятор, только здесь мы умножаем вектор на какое-то число.


Неожиданно, но здесь скаляризация не сработала (2080 байт = 32* (SIZE + 1)).



Прежде чем выяснять почему, рассмотрим еще пару примеров.

Пример 1.4. Control flow


Более простой пример: у нас нет цикла, есть условный переход. Мы случайным образом выбираем координату и создаем Vector2D.



И здесь скаляризация не помогает: все время создается один вектор — те самые 32 байта.



Пример 1.5. Control flow


Попробуем немного изменить этот пример. Я просто внесу создание вектора внутрь обеих веток:



И здесь все отлично скаляризуется.



Начинает вырисовываться картина — что здесь происходит?

«Merge points»




Представим, что у нас есть поток исполнения в программе. Есть одна ветвь, в которой мы создали объект 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-а, и он попался под руку. Реальный пример обычно лучше, чем синтетический.



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

Пример 2.1. EqualsBuilder


Я написал простой кусок кода — только два int-а, выписанных явно (но даже если бы там были указаны поля, сути это бы не изменило).



Вполне ожидаемо, эта ситуация скаляризуется.



Пример 2.2. EqualsBuilder


Немного изменим пример: вместо двух int-ов поставим две строки.



В результате скаляризация не работает.



Не будем пока лезть в метод .append(...). Для начала у нас есть ключи, которые хотя бы вкратце рассказывают, что происходит в компиляторе.



Выясняется, что метод append не заинлайнился, соответственно, escape-анализ не может понять: вот эта ссылка на builder, которая ушла внутрь метода .append() как this — что там с ней происходит, внутри метода? Это неизвестно (потому что внутрь метода .append компилятор не заглядывает — JIT не делает меж-процедурную оптимизацию). Может, ее там в глобальную переменную присвоили. И в подобных ситуациях escape-анализ сдается.

Что означает диагностика «hot method too big»? Она означает, что метод — горячий, т.е. вызывался достаточно много раз, и размер его байткода больше, чем некий предел, порог инлайнинга (предел именно для частых методов). Этот предел  — он задается ключом FreqInlineSize, и по-умолчанию он 325. А в диагностике мы видим 327 — то есть мы промахнулись всего на 2 байта.
Вот содержимое метода — легко поверить, что там есть 327 байт:



Как мы можем проверить нашу гипотезу? Мы можем добавить ключ FreqInlineSize, и увеличить порог инлайнинга, допустим, до 328:



В профиле компиляции мы видим, что .append() теперь инлайнится, и все отлично скаляризуется:



Уточню: когда я здесь (и далее) меняю флаги JVM, параметры JIT-компиляции, я делаю это не для того, чтобы исправить ситуацию, а чтобы проверить гипотезу. Я бы не рекомендовал играться с параметрами JIT-компиляции, поскольку они подобраны специально обученными людьми. Вы, конечно, можете попробовать, но эффект сложно предсказать — каждый такой параметр влияет не на один конкретный метод, в котором захотелось что-то скаляризовать, а на всю программу в целом.

Вывод 2.


  • Инлайнинг — лучший друг адаптивных рантаймов
  • а краткость ему очень сильно помогает.

Пишите методы покороче. В частности, в примере с .append() есть большая простыня, которая работает с массивами — пытается сделать сравнение массивов. Если ее просто вынести в отдельный метод, то все отлично инлайнится и скаляризуется (я пробовал). Это такой черный (хотя может и белый) ход для этой эвристики инлайнинга: метод в 328 байт не инлайнится, но он же, разбитый на два метода по 200 байт — отлично инлайнится, потому что каждый метод по отдельности пролезает под порогом.

Часть 3. Multi-values return


Рассмотрим возвращение из метода кортежа (tuple) — нескольких значений за раз.
Возьмем какой-нибудь простой объект, типа Pair, и совсем тривиальный пример: мы возвращаем пару строк, случайно выбранных из какого-то заранее заполненного пула. Чтобы компилятор вообще не выкинул этот код, я внесу некий побочный эффект: что-то с этими строками типа посчитаю, и верну результат.



Этот сценарий — скаляризуется. И это вполне рабочий пример, им можно пользоваться: если метод будет горячий и заинлайнится, такие multi-value return отлично скаляризуются.



Пример 3.1. value or null


Немного изменим пример: при каких-то обстоятельствах вернем null.



Как видно, аллокация останется (среднее количество байт на вызов не целое, потому что иногда возвращается null, который ничего не стоит).



Пример 3.2. Mixed types?


Более сложный пример: у нас есть интерфейс-Pair и 2 реализации этого интерфейса. В зависимости от искусственного условия, возвращаем либо ту реализацию, либо другую.



Здесь тоже остается аллокация:



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





Что здесь происходит? Ну, если мы попробуем ручками заинлайнить все методы, то увидим тот же сценарий с merge points (=ссылка может прийти двумя путями), что и в самом первом нашем эксперименте:



Вывод 3:


Будьте проще: меньше веток — меньше вероятность запутать escape-анализ

Пример 4. Итераторы


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

Вот очень простой сценарий с итерацией по коллекции. Мы создаем коллекцию один раз, мы не пересоздаем ее на каждую итерацию, но мы пересоздаем итератор: на каждом запуске метода мы бежим по коллекции итератором, считаем некий побочный эффект (просто чтобы компилятор не выкинул этот кусок).


Рассмотрим этот сценарий для разных коллекций. Допустим, сначала для ArrayList-а

Пример 4.1. ArrayList.iterator





Для ArrayList-а итератор действительно скаляризуется (размер SIZE здесь взят условный: как правило, это стабильно работает для широкого спектра SIZE). Для LinkedList это тоже работает. Я не буду долго перебирать все варианты — вот сводная таблица тех коллекций, что я попробовал:



В Java 8 все эти итераторы (по крайней мере в простых сценариях) скаляризуются.
Но в самом свежем апдейте Java 7 все хитрее. Давайте мы на нее пристальнее посмотрим (все знают, что 1.7 уже end of life, 1.7.0_80 это последний апдейт, который есть).

Для LinkedList с размером 2 все хорошо:



А вот для LinkedList с размером 65 — нет.



Что происходит?
Берем волшебные ключики, и для размера 2 мы получаем такой кусок лога инлайнинга:



А для размера 65:



Ближе к началу того же лога можно найти еще вот такой дополнительный фрагмент картинки:



Происходит следующее: в самом начале метод, который мы профилируем, пошел на компиляцию — JIT поставил его в очередь. JIT работает асинхронно, т.е. у него есть очередь, туда скидываются задачи на компиляцию, а он в отдельном потоке (или даже нескольких потоках) с какой-то скоростью выгребает их из очереди, и компилирует. То есть между моментом, когда ему поставили задачу, и тем моментом, когда новый код будет оптимизирован, проходит некоторое время.

И вот наш метод iterate() пошел первый раз на компиляцию, в ходе которой обнаружилось, что метод LinkedList.listIterator() внутри него еще слишком мало выполнялся. Не наработал еще на то, чтобы его заинлайнить (MinInliningThreshold = 250 вызовов). Когда же, еще через некоторое время, вызов iterate() пошел на перекомпиляцию — обнаружилось, что скомпилированный (машинный) код LinkedList.listIterator() слишком большой.

Да, а что именно означают диагностики:



Они означают, что, оценивая размер уже скомпилированных методов, мы смотрим на их машинный код, а не байт-код (т.к. это более адекватная метрика). И эти две эвристики — по размеру байт-кода и машинного кода — не обязательно согласованы. Метод из всего пяти байт-кодов может вызывать несколько других методов, которые будут вклеены, и увеличат размер его машинного кода выше порогов. С этой рассогласованностью ничего нельзя сделать кардинально, только подстраивать более-менее пороги разных эвристик, ну и надеяться, что в среднем все будет более-менее хорошо.

Пороги — в  частности, InlineSmallCode — отличаются в разных версиях. В 8-ке InlineSmallCode вдвое больше, поэтому в Java 8 этот сценарий отрабатывает успешно: методы инлайнятся и итератор скаляризуется — а в 7-ке нет.

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

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



Вывод 4:


  • JVM первой свежести лучше, чем не первой свежести;
  • -XX:+PrintInlining — очень хорошая диагностика, одна из основных, позволяющих понять, что происходит при скаляризации;
  • тестируйте на реальных данных — я имею в виду, что не надо тестировать на размере 2, если вы ожидаете 150. Тестируйте на 150 и вы можете увидеть отличия;
  • ArrayList опять обставил LinkedList!

Динамические рантаймы — это рулетка. JIT-компиляции свойственна недетерминированность, это неизбежно. В свежих версиях (8-ке) параметры эвристик чуть лучше согласованы друг с другом, но недетерминированности это не отменяет, просто ее сложнее поймать.


Пример 4.4. Arrays.asList()


Есть отдельный интересный вариант коллекции — обертка вокруг массива, Arrays.asList(). Хотелось бы, чтобы эта обертка ничего не стоила, чтобы JIT ее скаляризовал.

Я начну здесь с довольно странного сценария — сделаю из массива список, а потом по списку пойду, как будто по массиву, индексом:



Здесь все работает, создание обертки скаляризуется.



А теперь вернемся к итератору — нет же особого смысла оборачивать массив в список, чтобы потом ходить по списку, как по массиву:



Увы, даже в самой свежей версии java аллокация остается.



При этом в PrintInlining мы ничего особенного не видим.



Но если посмотреть внимательнее, то заметно, что итератор в Arrays$ArrayList не свой — его реализация унаследована целиком от AbstractList-а:



И AbstractList$Itr — это внутренний класс, не-статический внутренний класс. И вот то, что он не-статический — почему-то мешает скаляризации. Если переписать класс итератора (то есть скопировать весь класс Arrays$ArrayList к себе, и модифицировать), сделать итератор «отвязанным» — в итератор передается массив, и итератор не содержит больше ссылки на объект списка — тогда в этом сценарии будет успешно скаляризоваться как аллокация итератора, так и аллокация самой обертки Arrays$ArrayList.





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

Пример 4.4. Collections.*


У нас есть еще сколько-то таких вот коллекций-синглетонов, и все они, и их итераторы, успешно скаляризуются и в актуальной, и в предыдущей версиях java, кроме упомянутого выше Arrays.asList.



Вывод 4.4.


Вложенные объекты не очень хорошо скаляризуются.
  • итерация по оберткам из Collections.* скаляризуется
  • …Кроме Arrays.asList();
  • вложенные объекты не скаляризуются (в том числе inner classes);
  • -XX:+PrintInlining продолжает помогать в беде.

Пример 5. Constant size arrays


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

Пример 5.1. Variable index


Рассмотрим такой пример: мы берем массив, туда что-то записываем по ячейкам, потом оттуда что-то вычитываем.



Для размера 1 — все нормально.



А для размера 2 — ничего не получается.



Пример 5.2. Constant index


Попробуем немного другой доступ: возьмем тот же размер 2 и просто напросто развернем (unroll) цикл ручками — возьмем и обратимся по явному индексу:



В этом случае, как ни странно, скаляризация сработает.



Не буду долго рассуждать — ниже приведена сводная табличка. Этот случай с развернутым вручную циклом скаляризуется вплоть до размера 64. Если есть какой-то переменный индекс, размеры 1 и 2 еще кое-как скаляризуются, дальше — нет.



Как мне кто-то в блоге написал, в «JVM для всего есть свой ключик». Этот верхний порог (-XX:EliminateAllocationArraySizeLimit = 64) также можно задавать, хотя, мне кажется, в этом нет смысла. В предельном случае будет 64 дополнительных локальных переменных, что слишком много.

Пример 5.3. Primitive arrays


Точно такой же код, только с массивом примитивных типов —  int-ом, short-ом…



Все работает точно в тех же случаях, что и для объектов.



Почему не получается скаляризовать массив, по которому проходим циклом? Потому что непонятно, какой именно индекс скрывается за 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?



Для начала возьмем вероятность провала — 10-7:



Здесь скаляризация не очень удается.

При уменьшении вероятности до 10-9, поначалу все, вроде, идет в хорошую сторону, но потом все-таки скаляризация отваливается.



Если же вероятность совсем маленькая, мы более-менее стабильно приходим сюда:


… к устойчивой скаляризации.

Получается, что такой паттерн — с checkArguments, или аналогичным vararg — можно использовать, можно рассчитывать на скаляризацию, но только если вы действительно ожидаете, что expression никогда не будет false. В случае с checkArguments, если expression оказывается false, то это вообще-то означает, что мы наступили на какой-то баг в своем коде. И если у нас нет багов, то, по крайней мере в горячем коде, этот false никогда не возникает, и вся эта конструкция с vararg в идеале ничего не будет нам стоить, с точки зрения аллокации.

Итог


  • Скаляризуется многое из того, что хотелось бы (хотя здесь далеко не все примеры);
  • что не скаляризуется, можно понять и простить (т.е. не всегда можно простить разработчика за это, но можно понять, почему это вышло: ментальную модель какую-то вполне реально создать, не слишком сложную); иногда исправить
  • в свежих JVM скаляризация работает лучше.

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

Напоследок — краткая сводка рекомендаций:

  • самая свежая JVM;
  • короткие методы (проверяем инлайнинг);
  • меньше реального полиморфизма (вы можете оперировать интерфейсами, если реализация у него на самом деле 1; 2-3 — тоже неплохо, но, чем больше, тем сложнее);
  • одна ссылка указывает на один объект;
  • не использовать null — это все опять про простоту;
  • не использовать вложенные объекты — это, конечно, ограничение неприятное, но с ним приходится жить;
  • важные сценарии нужно тестировать.



Если вы дочитали до конца и вам хочется еще – в апреле мы проводим JPoint 2017 (7-8 апреля) в Москве и JBreak 2017 (4 апреля) в Новосибирске. Предварительные программы обеих конференций уже готовы, много докладов опубликовано – есть на что посмотреть, поэтому рекомендую.
P.S.: Какой формат вам кажется более интересным: интервью или вот такая расшифровка доклада?

Проголосовало 65 человек. Воздержалось 6 человек.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Поделиться с друзьями
-->

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


  1. leotsarev
    22.02.2017 10:38
    +2

    В последнем примере. Не знаю, как в Java, но в C# помогает унести редко используемую часть метода в отдельный метод. Тогда checkexpression устроится, а хелпер, бросающий exception, нет (что нам и нужно). Это более менее стандартный паттерн, хорошо виден по стектрейсам стандартных библиотек.


    1. sunless
      22.02.2017 14:14
      +2

      JIT заменит холодную часть метода на uncommon trap. В случае, если мы таки попали в редкую часть метода — управление свалится в интерпретатор, а в бэкграунде запиститься замещающая компиляция. Для хотспота я бы не стал разбивать код на хелпер методы, чтобы "помочь JITу".


    1. cheremin
      23.02.2017 13:22
      +3

      Смотря для чего "помогает". Вынесение редких кусков кода в отдельный метод — это и в джаве стандартный прием. Он помогает и с логической точки зрения — основная задача отделена от вспомогательных, и часто помогает JIT-у, потому что часто выполняющийся метод становится короче, и с большей вероятностью вклеивается. Но не в этом случае


      Тогда checkexpression устроится, а хелпер, бросающий exception, нет (что нам и нужно).

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


      … Кроме случая, когда холодная ветка кода прямо совсем уж холодная — вообще ни разу не вызывалась за время сбора профиля. Тогда JIT может спекулятивно предположить, что этот кусок никогда и не будет вызываться, и скомпилировать метод вообще без этого куска, вставив охранную проверку, uncommon trap, со ссылкой на полную версию метода в интерпретируемом режиме, и рекомпиляцию. Именно это здесь и происходит, когда вероятность попадания на исключение пренебрежимо мала.


      Если же вероятность попадания в редкую ветку все-таки не нулевая — то выносить ее в отдельный метод с точки зрения скаляризации как раз-таки хуже, потому что редкий метод скорее всего не будет вклеен. Здесь я разбирал такое на примере HashMap