В новом переводе от команды Spring АйО подробно разбираются концептуальные, методологические и технические ошибки, на которые легко наткнуться при попытке протестировать такие механизмы, как synchronized
и ReentrantLock
. Автор объясняет, почему микробенчмарки часто измеряют не то, что вы думаете, и почему для получения осмысленных результатов лучше использовать макротесты или полагаться на экспертов.
Комментарий от команды Spring АйО
Статья написана в 2005 году, рекомендуем делать поправку на состояние java-тулинга того времени.
Даже если производительность не является ключевым требованием проекта — или вовсе не упоминается среди требований, — разработчикам зачастую трудно полностью игнорировать этот аспект. Возникает ощущение, что пренебрежение производительностью делает тебя «плохим инженером». В стремлении писать высокопроизводительный код разработчики нередко создают небольшие тестовые программы — микробенчмарки — чтобы сравнить относительную эффективность разных подходов. Однако, как уже упоминалось в декабрьском выпуске «Динамическая компиляция и управление производительностью», оценка производительности отдельных идиом или конструкций в языке Java оказывается гораздо сложнее, чем в других, статически компилируемых языках.
Ошибочный микробенчмарк
После публикации моей октябрьской статьи «Более гибкая и масштабируемая блокировка в JDK 5.0» один из коллег прислал мне микробенчмарк SyncLockTest
(см. листинг 1), который, как утверждалось, должен был определить, что быстрее — примитив synchronized
или новый класс ReentrantLock
. Проведя тест на своём ноутбуке, он пришёл к выводу, что synchronized
работает быстрее — в противовес выводам, изложенным в статье, — и представил свой микробенчмарк в качестве «доказательства». Однако весь процесс — от проектирования микробенчмарка до его реализации, выполнения и интерпретации результатов — оказался ошибочным по ряду причин. При этом сам коллега — достаточно опытный и умный специалист, что лишь подчеркивает, насколько сложной может быть подобная задача.
Листинг 1. Ошибочный микробенчмарк SyncLockTest
interface Incrementer {
void increment();
}
class LockIncrementer implements Incrementer {
private long counter = 0;
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
++counter;
} finally {
lock.unlock();
}
}
}
class SyncIncrementer implements Incrementer {
private long counter = 0;
public synchronized void increment() {
++counter;
}
}
class SyncLockTest {
static long test(Incrementer incr) {
long start = System.nanoTime();
for(long i = 0; i < 10000000L; i++)
incr.increment();
return System.nanoTime() - start;
}
public static void main(String[] args) {
long synchTime = test(new SyncIncrementer());
long lockTime = test(new LockIncrementer());
System.out.printf("synchronized: %1$10d\n", synchTime);
System.out.printf("Lock: %1$10d\n", lockTime);
System.out.printf("Lock/synchronized = %1$.3f",
(double)lockTime/(double)synchTime);
}
}
SyncLockTest
реализует два варианта интерфейса и использует метод System.nanoTime()
для замера времени выполнения каждого из них 10 000 000 раз. Оба варианта инкрементируют счётчик потокобезопасным способом: один — с помощью встроенного механизма synchronized
, другой — с использованием нового класса ReentrantLock
. Заявленная цель теста — ответить на вопрос: «Что быстрее — synchronized или ReentrantLock
?» Однако давайте разберёмся, почему этот, на первый взгляд, безобидный бенчмарк на деле не измеряет то, что заявляет, а возможно — не измеряет вообще ничего полезного.
Концептуальные изъяны
Оставив в стороне проблемы реализации, стоит отметить куда более серьёзный концептуальный изъян SyncLockTest
: он некорректно формулирует сам вопрос, на который пытается ответить. Тест якобы измеряет накладные расходы на synchronized
и ReentrantLock
— механизмы, предназначенные для координации действий нескольких потоков. Однако сама тестовая программа использует лишь один поток, а значит, гарантированно не сталкивается с конкуренцией за ресурсы. Таким образом, из теста полностью исключён тот самый сценарий, в котором механизмы блокировки действительно имеют значение!
В ранних реализациях JVM синхронизация без конкуренции за блокировку была медленной, и это широко обсуждалось. Однако с тех пор производительность такой синхронизации значительно улучшилась. (См. раздел «Связанные материалы» для статьи, описывающей некоторые методы, используемые JVM для оптимизации такого рода синхронизации.) С другой стороны, синхронизация с конкуренцией была и остаётся гораздо более затратной. Когда блокировка оказывается конкурентной, JVM не только должна поддерживать очередь ожидающих потоков, но и использовать системные вызовы для приостановки и возобновления потоков, которым не удалось сразу получить доступ к ресурсу. Более того, приложения с высокой степенью конкуренции обычно демонстрируют более низкую пропускную способность. Это происходит не только из-за того, что больше времени уходит на переключение потоков и меньше — на выполнение полезной работы, но и потому, что процессоры могут простаивать, пока потоки находятся в ожидании блокировки. Таким образом, бенчмарк, претендующий на измерение производительности механизма синхронизации, должен учитывать реалистичный уровень конкуренции.
Методологические изъяны
К этому недостатку в конструкции добавляются как минимум две ошибки на этапе выполнения: тест запускался только на однопроцессорной системе (что нехарактерно для программ с высокой степенью параллелизма и, вероятно, даёт существенно иные показатели производительности синхронизации, чем многопроцессорные среды), и только на одной платформе. При тестировании производительности того или иного примитива или идиомы — особенно если она тесно взаимодействует с аппаратным обеспечением, — крайне важно запускать бенчмарки на множестве платформ, прежде чем делать выводы о её эффективности. В случае с такой сложной областью, как параллелизм, разумно использовать дюжину различных тестовых систем, с разным числом процессоров и поколениями CPU (не говоря уже о конфигурации памяти), чтобы получить более объективную картину общей производительности выбранного подхода.
Ошибки реализации
Что касается реализации, SyncLockTest
игнорирует ряд аспектов, связанных с динамической компиляцией. Как упоминалось в декабрьском выпуске, JVM HotSpot сначала выполняет код в интерпретируемом режиме и только после определённого количества запусков компилирует его в машинный код. Если не провести должный «прогрев» JVM, это может серьёзно исказить результаты измерений двумя способами. Во-первых, время, затраченное JIT-компилятором на анализ и компиляцию пути выполнения, попадает в замер общего времени выполнения теста. Во-вторых — и это важнее — если компиляция запускается во время выполнения теста, итоговый результат будет включать как интерпретируемое выполнение, так и время на компиляцию, и оптимизированное выполнение. Такой результат мало что говорит о реальной производительности тестируемого кода. А если метод не будет скомпилирован ни до, ни во время теста, весь прогон будет интерпретироваться, что тоже даёт крайне далёкое от реальности представление о производительности анализируемой идиомы.
Кроме того, SyncLockTest
страдает от проблем, связанных с инлайнингом и деоптимизацией, о которых также шла речь в декабре.
Комментарий от команды Spring АйО
В абзаце выше речь идёт об оптимизации в рамках HotSpot-a, когда dynamic dispatch на самом деле компилируется в static dispatch с целью повышения производительности. Dynamic dispatch - это вызов полиморфный, например есть класс Dog и есть родительский класс Animal.
Оба имеют и реализуют метод eat(). Если нам в метод передали ссылку типа Animal и мы хотим вызвать метод eat(), JVM сначала нужно понять, какой именно код исполнять - код, который в Dog, или который в Animal. Это не сложно, делается буквально один лукап в специальную таблицу, но это стоит перформанса. Статически же диспатч - ситуация, когда мы вызываем метод без полиморфизма, например, метод помеченый модификатором 'static'.
Возвращаясь к примеру выше. Брайн говорит о том, что тестирование первой реализации - SyncIncrementer, вполне может вызвать агресивный инлайн этой реализации и заставить JVM "думать", что никакая другая реализация туда попасть не может. Но во время прогона LockIncrementer - это предположение рушится, и JVM вынуждена деоптимизировать сгенерированный assembly, и вновь перейти в интерпретацию. Поэтому сравнение двух реализаций будет не честным.
Первая серия замеров может измерять код, оптимизированный путём агрессивного инлайнинга и трансформации моноформных вызовов, а вторая — уже деоптимизированный код, если JVM загрузит другой класс, реализующий тот же интерфейс. Когда метод test()
вызывается с экземпляром SyncIncrementer
, среда выполнения обнаруживает, что загружен только один класс, реализующий интерфейс Incrementer, и преобразует виртуальные вызовы increment()
в прямые вызовы метода SyncIncrementer
. Однако при повторном запуске метода test()
с экземпляром LockIncrementer
, метод будет перекомпилирован для использования виртуальных вызовов, и таким образом вторая серия теста будет выполнять больше работы на каждой итерации. Это превращает тест в сравнение «яблок с апельсинами» и может серьёзно исказить результаты, создавая ложное впечатление, что первый протестированный вариант быстрее.
Код бенчмарка не похож на реальный код
Ошибки, обсуждавшиеся ранее, можно исправить путём разумной переработки микробенчмарка: добавив параметры тестирования, такие как степень конкуренции, запустив его на более широком спектре систем и при разных значениях параметров. Однако у теста есть и более фундаментальные изъяны методологического характера, которые не удастся устранить ни настройками, ни модификациями. Чтобы понять, почему это так, нужно взглянуть на ситуацию глазами JVM и понять, что происходит при компиляции SyncLockTest
.
Принцип "гейзенбенчмарка"
При написании микробенчмарка, предназначенного для оценки производительности примитива языка (например, синхронизации), вы сталкиваетесь с эффектом, похожим на принцип Гейзенберга. Вы хотите замерить, насколько быстро работает операция X, и потому стараетесь исключить всё остальное. Но результатом часто становится «пустой» бенчмарк, который компилятор может частично или полностью оптимизировать, зачастую без вашего ведома, и тогда тест выполняется быстрее, чем должен. Если же вы добавляете в тест лишнюю операцию Y, то теперь вы измеряете производительность X+Y, и это вносит шум в ваши измерения X. Более того, само присутствие Y может изменить способ, которым JIT-компилятор будет оптимизировать X.
Хороший микробенчмарк должен находить тонкий баланс между недостатком вспомогательного кода и избыточной зависимостью данных, которая мешает компилятору оптимизировать тест, и избытком лишнего кода, при котором искомая операция тонет в шуме.
Поскольку JIT-компиляция во время выполнения опирается на данные профилирования для оптимизации, компилятор может оптимизировать код теста иначе, чем он бы это сделал с реальным кодом. Как и в случае с любыми бенчмарками, существует серьёзный риск того, что компилятор сможет оптимизировать тест полностью, потому что (и вполне обоснованно) определит, что тестовый код на самом деле ничего не делает и не даёт результата, который бы где-то использовался. Эффективное написание бенчмарков требует от разработчика умения «обмануть» компилятор, заставив его не удалять код как "мёртвый", несмотря на то, что с точки зрения логики он таким и является. Использование переменных-счётчиков в классах Incrementer
— неудачная попытка такого обмана, но современные компиляторы часто оказываются умнее, чем мы думаем, и с лёгкостью устраняют бесполезный код.
Ситуация усугубляется тем, что синхронизация — это встроенная возможность языка. JIT-компиляторы вправе использовать определённые послабления в отношении блоков synchronized
, чтобы снизить их издержки. В некоторых случаях синхронизация может быть удалена полностью, а смежные блоки, синхронизирующиеся на одном и том же мониторе, — объединены. Если мы пытаемся измерить издержки синхронизации, такие оптимизации могут полностью дискредитировать наши замеры, поскольку мы не знаем, сколько синхронизаций (а возможно — все!) фактически было оптимизировано. Ещё хуже то, что способ, которым JIT оптимизирует «пустую» реализацию SyncTest.increment()
, может сильно отличаться от того, как он обрабатывает аналогичный код в реальном приложении.
Но на этом проблемы не заканчиваются. Формальная цель этого микробенчмарка — определить, что быстрее: synchronized
или ReentrantLock
. Однако синхронизация встроена в сам язык, тогда как ReentrantLock
— это обычный Java-класс. В результате компилятор будет оптимизировать «пустую» синхронизацию иначе, чем «пустое» захватывание ReentrantLock. Такие оптимизации делают синхронизацию на вид быстрее. А поскольку компилятор оптимизирует оба случая не только по-разному между собой, но и иначе, чем в реальных приложениях, результаты этого теста практически ничего не говорят о реальной сравнительной производительности этих механизмов.
Удаление "мёртвого" кода
В декабрьской статье я уже поднимал проблему удаления "мёртвого" кода в бенчмарках — поскольку тестовые программы часто не производят никакого осмысленного результата, компилятор может попросту устранить целые фрагменты такого кода, и это искажает замеры времени выполнения. В данном бенчмарке эта проблема проявляется сразу в нескольких аспектах. Сам по себе факт, что компилятор может удалить часть кода как "мёртвую", ещё не фатален. Однако ключевая проблема в том, что степень такой оптимизации может различаться для двух веток теста, что систематически искажает сравнение.
Два класса Incrementer
, по задумке, выполняют некую «бесполезную» работу — инкрементируют переменную. Однако умная JVM замечает, что переменные-счётчики нигде не используются, и потому может полностью устранить код, связанный с их инкрементом. И вот тут начинается настоящая проблема: если synchronized
-блок в методе SyncIncrementer.increment()
оказывается пустым, компилятор может его полностью устранить, тогда как в методе LockIncrementer.increment() всё ещё остаётся код захвата и освобождения блокировки ReentrantLock
, который компилятор может — но не обязательно сможет — удалить.
Можно было бы подумать, что такая оптимизация — это аргумент в пользу synchronized
: мол, компилятор может легче его устранить. Но это — эффект, гораздо более вероятный в «пустых» тестах, чем в реальном, хорошо написанном коде. В настоящих приложениях блоки синхронизации обычно содержат полезную работу, которую невозможно просто отбросить.
Именно эта проблема — то, что компилятор способен оптимизировать один вариант лучше другого, но эта разница проявляется только в «пустых» бенчмарках — делает такой подход к сравнению производительности synchronized
и ReentrantLock
крайне ненадёжным и трудно применимым на практике.
Разворачивание (unrolling) циклов и объединение блокировок
Даже если компилятор не устранит управление счётчиком, он всё равно может по-разному оптимизировать два метода increment()
. Одной из стандартных оптимизаций является разворачивание циклов (loop unrolling), при котором компилятор уменьшает количество условных переходов, повторяя тело цикла несколько раз в одной итерации.
Комментарий от команды Spring АйО
Довольно простая оптимизация. Делимся ссылкой, чтобы далеко не ходить. https://ru.m.wikipedia.org/wiki/%D0%A0%D0%B0%D0%B7%D0%BC%D0%BE%D1%82%D0%BA%D0%B0_%D1%86%D0%B8%D0%BA%D0%BB%D0%B0
Степень разворачивания зависит от того, сколько кода содержится внутри тела цикла. А в LockIncrementer.increment()
этого кода больше, чем в SyncIncrementer.increment()
.
Более того, при разворачивании цикла и инлайнинге метода SyncIncrementer.increment()
получится последовательность групп lock-increment-unlock
. Так как все они синхронизируются на одном и том же мониторе, компилятор может применить оптимизацию объединения блокировок (lock coalescing или lock coarsening) — то есть слить смежные блоки синхронизации в один. Это означает, что SyncIncrementer будет выполнять меньше операций синхронизации, чем ожидалось.
И это ещё не всё: после объединения блокировок тело синхронизированного блока будет содержать только последовательность операций инкремента, которую компилятор может упростить до одного сложения (strength reduction).
Комментарий от команды Spring АйО
Strength Reduction тоже довольно известная оптимизация: https://ru.m.wikipedia.org/wiki/%D0%A1%D0%BD%D0%B8%D0%B6%D0%B5%D0%BD%D0%B8%D0%B5_%D1%81%D1%82%D0%BE%D0%B8%D0%BC%D0%BE%D1%81%D1%82%D0%B8_%D0%BE%D0%BF%D0%B5%D1%80%D0%B0%D1%86%D0%B8%D0%B9
Если такая оптимизация применяется многократно, весь цикл может быть свёрнут в один synchronized
-блок с единственной операцией counter = 10000000
. И да, современные JVM действительно способны на такие оптимизации.
Проблема заключается не столько в самих оптимизациях, сколько в том, что компилятор способен применить разную степень оптимизации к разным вариантам, и типы оптимизаций, возможные в «пустых» тестах, зачастую неприменимы в реальном коде. Это снова приводит к тому, что результаты такого микробенчмарка не отражают реальной производительности сравниваемых механизмов.
Итоговый список недочётов
Этот список не является исчерпывающим, но в нём приведены основные причины, по которым данный микробенчмарк не выполнил то, что задумывал его автор:
Не был выполнен прогрев JVM, и не учитывалось время, затраченное на выполнение JIT-компиляции.
Тест подвержен ошибкам, связанным с трансформацией моноформных вызовов и последующей деоптимизацией.
Код внутри
synchronized
-блока илиReentrantLock
фактически является "мёртвым", что влияет на стратегию оптимизации JIT; компилятор может вообще устранить всю синхронизацию.Тест стремится измерить производительность механизма блокировки, но делает это без учёта конкуренции, и при этом выполняется только на однопроцессорной системе.
Тест запускался лишь на ограниченном числе платформ, что делает выводы о производительности крайне ненадёжными.
Компилятор способен применить больше оптимизаций к
synchronized
, чем кReentrantLock
, но эти оптимизации маловероятны в реальных приложениях, использующих синхронизацию.
Итог: вместо объективного сравнения двух механизмов синхронизации, тест создаёт иллюзию производственного анализа, тогда как на деле отражает лишь поведение компилятора в условиях «бесполезного» кода, далёкого от реальных сценариев использования.
Задай неправильный вопрос — получи неправильный ответ
Самое опасное в микробенчмарках то, что они всегда дают какое-то числовое значение — даже если оно ничего не значит. Они действительно что-то измеряют, вот только нередко остаётся неясным — что именно. Чаще всего такие тесты измеряют лишь производительность самого микробенчмарка, и не более. Однако очень легко обмануть самого себя, поверив, что этот результат отражает производительность конкретной конструкции языка, и на этом основании сделать ошибочные выводы.
Даже если вы написали отличный бенчмарк, его результаты могут быть релевантны лишь для той системы, на которой вы его запускали. Если тестирование проводится на однопроцессорном ноутбуке с ограниченным объёмом памяти, это не даёт никакого понимания о производительности на серверной платформе. Производительность низкоуровневых механизмов аппаратной синхронизации, таких как compare-and-swap, может существенно различаться от одной архитектуры к другой.
Реальность такова, что измерить нечто абстрактное вроде «производительности синхронизации» в виде одного числа просто невозможно. Она зависит от множества факторов: версии JVM, модели процессора, характера рабочей нагрузки, поведения JIT-компилятора, числа ядер и особенностей кода, использующего синхронизацию. Максимум, чего можно добиться — это запуск серии тестов на разных платформах и поиск закономерностей в результатах. Лишь после этого можно начинать делать какие-то выводы о производительности механизмов синхронизации.
В бенчмарках, проводившихся в рамках тестирования JSR 166 (java.util.concurrent
), было показано, что форма кривых производительности существенно различается от платформы к платформе. Стоимость аппаратных механизмов, таких как CAS (compare-and-swap), варьируется в зависимости от архитектуры и числа процессоров (например, на однопроцессорной системе CAS никогда не завершится неудачей). Производительность операций с барьерами памяти у одного Intel P4 с гиперпоточностью (два логических ядра на одном кристалле) выше, чем у системы с двумя отдельными процессорами P4, и обе конфигурации ведут себя иначе, чем Sparc. Поэтому максимум, что можно сделать — это создавать «типичные» сценарии и запускать их на «типичном» оборудовании, надеясь, что это даст хоть какое-то представление о производительности наших реальных программ на реальном железе.
Что такое «типичный» пример? Это такой, в котором сочетаются вычисления, ввод-вывод, синхронизация и конкуренция, а также учитываются такие факторы, как локальность памяти, поведение при выделении памяти, переключение контекста, системные вызовы и межпоточная коммуникация — то есть, по сути, реалистичный бенчмарк выглядит практически как настоящее приложение.
Как написать идеальный микробенчмарк
Итак, как же написать идеальный микробенчмарк? Сначала — напишите хороший оптимизирующий JIT-компилятор. Познакомьтесь с теми, кто уже создавал такие компиляторы (их немного, так что найти их несложно). Пригласите их на ужин и обменяйтесь историями и приёмами, как максимально ускорить выполнение байткода Java. Прочитайте сотни научных работ по оптимизации исполнения Java-кода — и напишите несколько своих. Только после этого вы будете обладать необходимыми знаниями и навыками, чтобы действительно написать корректный микробенчмарк, который способен достоверно измерить стоимость синхронизации, пулов объектов или вызова виртуальных методов.
Вы шутите?
Может показаться, что приведённая выше «рецептура» написания хорошего микробенчмарка чрезмерно осторожна, но на самом деле это отражает реальность: создание корректного микробенчмарка требует глубочайших знаний о динамической компиляции, оптимизации и внутреннем устройстве JVM. Чтобы написать тестовую программу, которая действительно проверяет то, что вы хотите проверить, необходимо понимать, что компилятор сделает с вашим кодом, каковы характеристики его выполнения после JIT-компиляции, и чем сгенерированный код отличается от типичного реального кода, использующего те же конструкции. Без такого уровня понимания вы не сможете сказать, действительно ли ваш тест измеряет то, что вы задумали.
Что же делать?
Если вы действительно хотите узнать, быстрее ли synchronized
, чем альтернативный механизм блокировки (или ответить на другой похожий вопрос о микропроизводительности), у вас есть два пути.
Первый (который вряд ли понравится большинству разработчиков) — довериться экспертам. При разработке класса ReentrantLock
члены экспертной группы JSR 166 провели сотни, если не тысячи часов тестирования производительности на множестве платформ, анализировали машинный код, генерируемый JIT-компилятором, и скрупулёзно изучали результаты. Затем они дорабатывали код — и повторяли всё снова. В разработку и анализ этих классов вложено огромное количество экспертизы и глубокого понимания поведения JIT-компилятора и микропроцессоров — и всё это невозможно свести к результатам одного микробенчмарка, как бы нам этого ни хотелось.
Второй путь — сфокусироваться на «макро»-бенчмарках. Напишите реальные приложения, реализуйте их с использованием обоих подходов, разработайте реалистическую стратегию нагрузочного тестирования и измерьте производительность вашего приложения в обоих вариантах, в условиях, приближённых к боевому окружению. Это трудоёмко, но даст гораздо более надёжный и практически применимый ответ.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.