Безопасная публикация и инициализация Java-объектов, или #когдаужепочинятdoublecheckedlocking
Пост из серии "будни перформанс-инженеров" и "JavaOne круглый год".
К моему величайшему facepalm’у на прошедшем JavaOne была тьма вопросов про double-checked locking, и как правильно делать синглетоны. На большую часть этих вопросов уже ответил Сергей Куксенко, а здесь я хочу подытожить. Надеюсь этим постом раз и навсегда поставить точку в разговорах про double-checked locking и синглетоны. А то мне придётся сделать резиновую печать с URL этого поста и ставить её спрашивающим на лоб.
Самое главное в этом посте — увидеть за деревьями лес, и понять, что пост на самом деле не про синглетоны и даже не про DCL. Он про более важные и высокоуровневные концепции, которые удобно показать на этих уже мне осторчертевших примитивах.
I. Теоретическая подготовка: фабрики и безопасная публикация
Меня немножко возмущает, когда смешивают понятие собственно синглетона и фабрики синглетонов. Для целей нашего поста эти две сущности нам надо будет друг от друга отличать. Всё описаное, понятно, также распространяется на синглетон, в который фабрика уже внедрена (то есть существует метод static getInstance()).
Хорошая фабрика синглетонов обладает следующими свойствами:
Хорошая фабрика потокобезопасна. Вне зависимости от порядка обращения из разных потоков все они получат один и тот же синглетон. Более того, синглетон будет корректно проинициализирован.
Хорошая фабрика ленива (тут можно поспорить, но неленивая фабрика нам здесь неинтересна). Инициализация синглетона происходит при первом запросе на синглетон, а не при загрузке класса синглетона.
Хорошая фабрика эффективна, т.е. вносит минимум накладных расходов.
Понятно, что вот такое:
…удовлетворяет требованиям 1 и 2, но не удовлетворяет требованию 3.
На этом месте рождается идиома Double-Checked Locking. Она берёт своё начало из идеи, что нечего лишний раз синхронизироваться, если подавляющее количество вызовов уже обнаружит синглетон инициализированным. Поэтому разные люди берут и пишут:
(UPD 2014/02/10: Ниже по тексту в FinalWrapperFactory были две гонки на чтение поля instance, из-за чего первое чтение имеет право прочитать не null, а второе имеет полное право прочитать null и вернуть этот null из метода, и исправил. Год спустя мне <hh user="elizarov"/> подсказал посмотреть и на другие места: здесь такая же проблема исправлена аналогичным образом — мы поле читаем в локальную переменную (с одной гонкой), после чего уже проверяем и возвращаем результат из локальной переменной, а на локальной переменной гонок быть не может в принципе, и если мы проверили что она не null, то и результат обязан быть не null).
К сожалению, эта хрень не всегда работает корректно. Казалось бы, если проверка check 1 не выполнилась, то instance уже инициализирован и его можно возвращать. А вот и нет! Он инициализирован с точки зрения потока, который произвёл изначальное присвоение! Нет никаких гарантий, что вы обнаружите в полях синглетона то, что вы записали внутри его конструктора, если будете читать в другом потоке.
Здесь можно было бы <a href="http://habrahabr.ru/post/133981/">начать объяснять</a> про happens-before, но это довольно тяжёлый формализм. Вместо этого мы будем использовать феноменологическое объяснение, в виде понятия <i>безопасной публикации</i>. Безопасная публикация обеспечивает видимость всех значений, записанных до публикации, всем последующим читателям. Элементарных способов безопасной публикации несколько:
инициализация статическим инициализатором (<a href="http://docs.oracle.com/javase/specs/jls/se7/html/jls-12.html#jls-12.4">JLS 12.4</a>)
запись в поле, корректно защищённое локом (<a href="http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.4.5">JLS 17.4.5</a>)
запись в final-поле (<a href="http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.5">JLS 17.5</a>)
Обратим внимание, что в NonVolatileDCL поле $instance…
не инициализируется статикой
не защищено локом как минимум одно чтение
не записывается в volatile
не записывается в final
То есть, по определению, публикация $instance в NonVolatileDCL безопасной не является. Смотрите, кстати, сколько из этого следует забавных возможностей для безопасной фабрики синглетонов. Начиная с уже навязшего в зубах:
…продолжая не менее классическим holder idiom, который безопасно публикует, записывая объект статическим инициализатором:
…и заканчивая final-полем. Поскольку в final-поле вне конструктора писать уже поздно, нужно сделать:
(UPD: 2013/02/15: Спустя год я понял, что в прошлой версии был потенциальный NPE, проапдейтил)
Вариант с безопасной публикацией через корректно синхронизированное поле у нас уже есть, в самом начале.
Кроме того, в наш зачёт с криком "одна бабка мне сказала, что volatile это дорого!" врывается новый кандидат, кеширующий поле в локале:
II. Теоретическая подготовка: синглетоны и безопасная инициализация
Идём дальше. Объект можно сделать всегда безопасным для публикации. JMM гарантирует видимость всех final-полей после завершения конструктора. Вот пример полностью безопасной инициализации:
Замечу, что в некоторых случаях это распространяется не только на final поля, но и на volatile. Есть ещё более фимозные техники, типа synchronized в конструкторе, <a href="http://cheremin.blogspot.com/2012/05/unsafe-publication.html">можете почитать</a> у <hh user="cheremin"/><sub>(поделитесь с Русланом инвайтом ;))</sub>, он такое любит. В этом посте таких высоких материй мы касаться не будем.
Вот такой объект, понятно, будет небезопасным:
На самом деле, проблемы с небезопасно опубликованным небезопасным синглетоном скажутся в некоторых специальных граничных условиях, например, если конструктор синглетона заинлайнится в getInstance() фабрики Тогда ссылка на недоконструированный объект может быть присвоена в $instance до фактического завершения конструктора.
Вот, например, хвост NonVolatileDCLFactory.getInstance() для UnsafeSingleton (конструктор синглетона заинлайнился):
Обратите внимание на присвоение $instance до присвоения $obj4.
А вот тот же самый NonVolatileDCLFactory с SafeSingleton:
Видно, что $instance пишется после всех полей.
Для тех, кто не запарился до сюда дочитать, небольшой бонус. HotSpot следует консервативной рекомендации из <a href="http://g.oswego.edu/dl/jmm/cookbook.html">JSR-133 Cookbook</a>: "Issue a StoreStore barrier after all stores but before return from any constructor for any class with a final field."
Другими словами, есть специфичная для хотспота <a href="http://hg.openjdk.java.net/jdk7u/jdk7u/hotspot/file/tip/src/share/vm/opto/parse1.cpp">фишка</a>:
То есть, если hotspot обнаруживает в конструкторе запись хотя бы в одно final поле, то он тупо выставляет барьер в конец конструктора и таким образом обеспечивает запись <i>всех</i> полей в конструкторе до записи ссылки на сконструированный объект. Это имеет смысл, чтобы не делать несколько барьеров для нескольких финальных полей. То есть, только для хотспота можно сделать так:
…и это будет эффективно безопасной публикацией, но только на хотспоте. При этом нет особенной разницы, в каком порядке пишутся поля (но только пока devil_laugh).
Это несколько умозрительный случай, но внимательный читатель оценит симпатичные грабли: жил-был класс с кучей нефинальных полей и одним финальным. Тесты проходят, приложение работает, объект как будто безопасно публикуется. Потом приходит Вова и рефакторит класс, удаляя финальное поле — и всё, кранты безопасной публикации. Вова смотрит в свой коммит и не понимает, как такое возможно.
Итого, у нас есть шесть вариантов фабрик и три синглетона.
III. Ломаем DCL
Когда-то давно <hh user="gvsmirnov"/> меня спрашивал, можно ли действительно продемонстрировать такой реордеринг, который сломает DCL. Как видно из ассемблера вверху, гадкие реордеринги даже в присутствии Total Store Order’а нам может преподнести компилятор. Почему он это сделает, <a href="http://tinyurl.com/2g9mqh">тайна сия велика</a> есть, ему никто не запрещал.
Важно то, что это довольно тонкая гонка исключительно на первой инициализации, и поэтому приходится немножко поизвращаться, чтобы её осуществить:
Полный проект лежит <a href="http://shipilev.net/pub/articles/dcl-habr/singleton.tar.gz">вот тут</a>, можете поиграться. -DfactoryType, -DsingletonType выбирают фабрику и синглетон, -Dthreads регулирует количество потоков, а -Dtime — время на тест.
Синглетон проверяет свои поля методом:
…то есть по сути смотрит, были ли таки инициализированы поля у того инстанса, который отдала фабрика.
Ну что, посчитаем вероятности отказа. Гоняем тесты по 10 минут: за это время миллиарды новых синглетонов успевают создаваться, сталкиваться, разлетаться на фермионы, бозоны… чёрт, кажется, я не туда пишу. Никаким таким тестом доказать корректность многопоточного кода нельзя, тестом её можно только опровергнуть.
Что мы видим? <ul><li>Некорректно сконструированный синглетон (Unsafe) нормально работает с корректно публикующими фабриками</li> <li>Некорректно публикующая фабрика (NonVolatileDCL) нормально работает с корректно сконструированными синглетонами</li> <li>Когда эти двое встречаются, начинается треш и угар, причём с приличной вероятностью отказа: фейлом оканчивается 1 вызов из 3000</li> <li>Holder дисквалифицирован, т.к. сохраняет своё состояние в статике</li> </ul> Дабы меня не обвинили в великодержавном шовинизме, вот тот же тест на двухядерном NVidia Tegra2 (Cortex A9) и JDK 7u4 (ARM port), -Xmx512m -Xms512m -XX:+UseNUMA в двух потоках; метрика — вероятность отказа: <table> <tr> <td></td><td>Unsafe</td><td>Safe</td><td>Tricky</td> </tr> <tr><td>Synchronized</td><td>ε</td><td>ε</td><td>ε</td></tr> <tr><td>NonVolatileDCL</td><td><b>2*10<sup>-8</sup></b></td><td>ε</td><td>ε</td></tr> <tr><td>VolatileDCL</td><td>ε</td><td>ε</td><td>ε</td></tr> <tr><td>VolatileCacheDCL</td><td>ε</td><td>ε</td><td>ε</td></tr> <tr><td>Holder</td><td>N/A</td><td>N/A</td><td>N/A</td></tr> <tr><td>FinalWrapperDCL</td><td>ε</td><td>ε</td><td>ε</td></tr> </table>ε < 10<sup>-10</sup>, т.е. ни одного фейла не произошло, но это не значит, что они не появятся в будущем. ε существенно меньше, потому что ARM медленее, а тест выполняется те же 10 минут.
Что мы видим? Да тоже самое и видим. Несмотря на то, что x86 и ARM — очень разные платформы с точки зрения модели памяти, гарантированное поведение остаётся гарантированным. Вероятность отказа сильно упала ввиду специфики теста: глобальный эффект от безопасной публикации самой factory частично сглаживает эффекты от теста.
IV. Performance
Написать корректный параллельный код — дело не хитрое. Оберни всё глобальным локом, и вперёд. Проблема написать корректный и эффективный параллельный код. Ввиду того, что на J1 мне умудрялись говорить <i>"ой, volatile в DCL это так дорого, мы лучше синхронизуем getInstance()"</i>, придётся наглядно показать, что к чему. Не буду показывать много графиков, покажу только пару точек с тех же платформах, где гонялась корректность.
Очень простой микробенчмарк в нашем внутреннем тёплом ламповом харнессе выглядит так:
Поскольку наш харнесс ещё не открыт, вам придётся немножко поработать, чтобы написать полный микробенчмарк.
Брать синглетон у уже горячей фабрики — подавляющий use case в продакшене. Замечу, что микротест, который сильно амплифицирует стоимость даже элементарных операций, т.е. если что-то в этом тесте быстрее в два раза, то это не значит, что большой проект тоже разгонится в два раза с "правильной идиомой". Хотя бывает, особенно для локов.
Что мы здесь видим? * про Synchronized даже говорить нечего, она раздулась в настоящий лок и там всё очень-очень плохо * NonVolatile работает хорошо и непринуждённо * Volatile иногда работает похуже, сказывается необходимость читать $instance из памяти два раза, что делает этот вариант чуть медленнее NonVolatile * VolatileCache частично нивелирует этот эффект; показывая, что накладных расходов на само volatile-чтение нет * FinalWrapper работает так же как Volatile как раз по этой причине: нужно сделать один лишний дереференс, один лишний поход в память, один лишний потенциальный cache miss * Holder впереди планеты всей; казалось бы, ну как? Фокус в том, что к моменту компиляции методов этой фабрики HotSpot знает, что сам холдер уже загружен, и ему не нужно делать вообще никаких проверок, а сразу отдать статический $instance
Что мы здесь видим? * всё более-менее соотносится с x86, кроме того, что… * volatile-чтение на ARM’е требует барьера, поэтому VolatileDCL оттормаживает * можно сэкономить на стоимости volatile-чтения, скешировав значение в локале, VolatileCacheDCL это и делает; однако полностью избавиться от оверхеда нельзя, до NonVolatile так и не дотянуло<
V. Выводы, обобщения и оговорки
Главный вывод запечатлейте у себя: DCL работает! Оговорка 1: Если безопасно инициализирован, или безопасно опубликован, или и то и другое. Оговорка 2: Только в Java 5+.
Рецепты: 1. Не делайте ленивую инициализацию там, где сойдёт неленивая 2. Нужна статическая ленивая фабрика? Вам в Holder. Её особенно не выгрузишь, но зато спекулятивные оптимизации на вашей стороне 3. Нужна нестатическая ленивая фабрика? Можете использовать NonVolatileDCL, <b>но только если</b> объект безопасно конструируется (а также обратите внимание на гонки, которые заставляют нас читать ссылки сначала в локальные переменные). 4. Нужна нестатическая ленивая фабрика, и гарантировать безопасность конструирования нельзя? Используйте Volatile(Cached)DCL или FinalWrapperDCL (осторожнее с гонками!), в зависимости от того, чем вы хотите пожертвовать — потенциальной стоимостью volatile на ARM’е, или потенциальной стоимостью лишнего дереференса