Содержание

воскресенье, 27 ноября 2011 г.

synchronized vs ReentrantLock

В свое время, прочитав книгу "Java concurrency in practice" Brian Goetz, я думал, что хорошо разбираюсь в синхронизации. Но время шло, и многое поменялось в мире java concurrency. В книге было написано, что ReentrantLock значительно выигрывает в производительности в java 1.5 и совсем незначительно, начиная с Java 1.6, таким образом при выборе способа синхронизации, вопрос о производительности не должен влиять на решение об использовании той или иной конструкции. После чего я начал всем говорить, что когда нужна простая синхронизация, всегда стоит использовать synchronized, так как вы никогда не забудете освободить монитор, код получится более чистым и однородным, ведь, скорее всего уже написано много кода, использующего старую семантику. К тому же все программисты хорошо знакомы с synchronized. ReentrantLock же стоит использовать только когда вам нужны дополнительные возможности, например, блокировка, ограниченная по времени, честная блокировка, случай большого числа читателей и малого писателей (ReentrantReadWriteLock) и некоторые другие. Однако, посетив лекцию на JavaOne 2011 в Москве [3], а так же недавно пообщавшись с её автором, Алексеем Шипилёвым,я понял, что не все так однозначно, ведь имплементации synchronized и ReentrantLock очень сильно отличаются, о чем я и хочу описать в данном топике. Так же в  я попробую дать несколько рекомендаций по поводу использования упомянутых конструкций.
synchronozed
Сразу после лекции JavaOne [3] я написал топик Блокировки в Java, где попытался дать объяснение как реализован synchronized. Если вкратце, то за synchronized может стоять три типа блокировок: biased, lightweight(thin) и fat(inflated lock). Ecли в synchronized блок всегда заходит один поток (причем обязательно один и тот же), то будет работать первый (biased) тип блокировки, когда монитор привязывается (bias) к потоку с помощью единственной операции CAS и все последующие заходы будут проходить без каких либо существенных накладок. Если потом такой привязанный монитор попытается захватить другой поток, то либо произойдет пере привязка и останется текущий тип блокировки, либо блокировка смениться на lightweight, и любой вход в synchronized блок будет сопровождаться CAS. Если же потом обнаружится contention, то начинает работать fat блокировка, и для захода в synchronized блок начнет использоваться примитив синхронизации операционной системы (mutex и condition variable [1]).

Так же хочется сказать пару слов о переходах между описанными типами блокировок. Во-первых, и пере привязка монитора, и переход от biased к lightweight очень затратны, так как для этого требуется остановить все потоки [1]. Поэтому существует некий delay на старте JVM, когда привязка блокировок отключена, и все synchronized начинают свою работу с lightweight блокировки. Если во время этого периода contention не обнаружится, то произойдет привязывание блокировки, а если обнаружится, то блокировка скорее всего станет inflated и никогда больше не сможет работать как biased. Данный delay вы можете выставить в ноль (-XX:BiasedLockingStartupDelay=0), если уверены, что в большинство synchronized блоков будет заходить только один поток. Один из аргументов в пользу существования задержки на старте JVM может служить тот факт, что иногда на запуске приложения его классы создаются специальным потоком, служащим только для инициализации и проставления dependency. Все же остальное время с объектом класса может постоянно работать совершенно другой поток. Так вот, чтобы не делать лишнюю дорогую пере привязку, можно и использовать данный delay на старте приложения. Но если вы понимаете, что за большинство ваших мониторов будет состязание на протяжении всего времени работы приложения, либо в synchronnized будут заходить разные потоки, то можно вообще отключить оптимизацию привязок (-XX:-UseBiasedLocking).

Переход между lightweight и fat блокировками можно так же настраивать. С помощью -XX:+UseSpinning можно включить так называемый adaptive spinning, который позволяет потоку немного покрутиться CASом на мониторе, не переводя сразу lightweight блокировку в fat. Время, в течении которого поток будет спиниться, подстраивается автоматически JVM, на него влияет отношение количества успешных захватов к не успешным в предыдущие заходы в synchronized блок, длина критической секции, параллелизм и текущая нагрузка системы [2]. Еще один важный момент, заключается в том, что вероятность перехода монитора в сторону увеличения состязания намного больше, нежели в сторону уменьшения. Наверное, таким образом synchronozed защищается от переходов туда-обратно, которые как указано выше весьма и весьма затратны.
ReentrantLock
Рассмотрим теперь non fair ReentrantLock. Здесь все проще в том плане, что он всегда работает одинаково: сначала пытается сделать CAS, пользуясь стандартными средствами Java, и если удастся, то захватывает блокировку и выполняется дальше, если же CAS обламывается, то вызов идет в ОС и поток паркуется (дизейблиться для скедулинга OС). Т.е. получаем, что в отличие от synchronized при не конкурирующих потоках, ReentrantLock будет всегда делать CAS, чтобы зайти в критическую секцию и не важно заходит ли туда один поток или много. При наличие конкуренции, потоки, которым повезло с CAS избегут переключения контекста и сразу пойдут на выполнение, другим же придется парковаться. Еще раз замечу, что мы рассматриваем именно non fair имплементацию ReentrantLock.
Рецепты
Исходя из всего вышесказанного, можно вывести следующую очень приближенную инструкцию:

1. Если вы уверены, что с вашим кодом всегда работает только один поток, но есть очень маленькая вероятность, что туда придет другой поток и он должен увидеть все в консистентном состоянии. Либо же долгое время работает один поток, потом долгое время другой и так далее, то можно попробовать отдать предпочтение synchronized, так как в этом случае поток практически вообще не будет замечать, что происходит синхронизация.

2. Если вы знаете, что с вашим кодом работает много потоков, но конкуренции практически нет, то скорее всего оба подхода дадут практически одинаковый результат, так как и в том и другом случае потоку достаточно будет выполнить один CAS для входа в критическую секцию. Хотя стоит заметить, что для выхода из критической секции в случае synchronized потоку так же надо будет выполнить CAS, а в случае с ReentrantLock потоку надо будет выполнить volatile write. Так что кто из них выиграет по производительности может зависеть в том числе от железа, вернее в отношении стоимости операций CAS и мембаров.

3. Если же у вас заведомо сильный contention или же он бывает наплывами, тогда здесь скорее всего выиграет ReentrantLock, так как время от времени потоки смогут захватывать лок с помощью CAS, а в случае с synchronized при первом сильном состязании монитор надуется (inflated), и потоку всегда придется обращаться к ОС для доступа к критической секции, что скорее всего будет затратнее, ведь вероятность перейти обратно на lightweigh, в силу существующей, реализации довольно мала.

 Если еще проще, то когда состязания за блокировку нет либо оно очень мало, то, возможно, synchronized будет быстрее. Ежели есть заметное состязание, то скорее всего ReentrantLock даст некое преимущество.
Benchmark
Сам бенчмарки писать я не умею, поэтому приведу пример, показанный на уже упомянутом Java One [3]:


Красно-коричневый fair ReentrantLock за пределами рассмотрения нашей статьи, так что на него не обращайте сейчас внимание, а вот на зеленый synchronized и синий ReentrantLock давайте посмотрим повнимательнее. Светлый оттенок это как раз рассмотренный нами случай, когда в критическую секцию заходит один и тот же поток. А вот темный оттенок, это уже сильное состязание. Как и в наших рассуждениях в первом случае побеждает synchronized, а во втором выигрывает ReentrantLock.
Заключение
Наверное так же стоит упомянуть, что при работе synchronzed JVM вам может предложить еще несколько плюшек, таких как объединение блокировок (lock coarsening) и стирание блокировок (lock elision). Так же стоит заметить, что сейчас уже CAS стали весьма дешевым [4] (особенно локальный CAS на таком железе как Azul и Nehalem [5]), так что разница между ним и оверхедом от связанным блокировок (вернее от операций перепривязки) становиться все меньше и меньше. Т.е. на выбор так же может оказать влияние железо, которое вы собираетесь использовать в продакшене.

Таким образом, мы видим, что в борьбе synchronized vs ReentantLock нет однозначного победителя. Единственное, что мы можем делать, это различные предположения, используя знание того, что в synchronized присутствуют агрессивные оптимизации для случая одного потока, а ReentrantLock проектировался для usecase, когда у вас есть состязание за блокировку.
Acknowledgements
Огромное спасибо Алексею Шипилёву, который работает над performance тюнингом в Oracle, за техническое ревью статьи, замечания и уточнения.
Литература:
[1] Документ о связанных блокировках от Sun Microsystems
[2] Краткое сравнение synchronized и RL от человека, который этим занимается в Oracle
[3] Презентация с Java One (слайды 51-56)
[4] О связанных блокировках от создателя и сравнении с CAS
[5] Переписка о сравнение стоимости CAS и связанных блокировок