- PVSM.RU - https://www.pvsm.ru -
Пост из серии «будни перформанс-инженеров» и «JavaOne круглый год».
К моему величайшему facepalm'у на прошедшем JavaOne была тьма вопросов про double-checked locking, и как правильно делать синглетоны. На большую часть этих вопросов уже ответил Walrus [1], а здесь я хочу подытожить. Надеюсь этим постом раз и навсегда поставить точку в разговорах про double-checked locking и синглетоны. А то мне придётся сделать резиновую печать с URL этого поста и ставить её спрашивающим на лоб.
Меня немножко возмущает, когда смешивают понятие собственно синглетона и фабрики синглетонов. Для целей нашего поста эти две сущности нам надо будет друг от друга отличать. Всё описаное, понятно, также распространяется на синглетон, в который фабрика уже внедрена (то есть существует метод static getInstance()).
Хорошая фабрика синглетонов обладает следующими свойствами:
Понятно, что вот такое:
public class SynchronizedFactory {
private Singleton instance;
public Singleton get() {
synchronized(this) {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
}
… удовлетворяет требованиям 1 и 2, но не удовлетворяет требованию 3.
На этом месте рождается идиома Double-Checked Locking. Она берёт своё начало из идеи, что нечего лишний раз синхронизироваться, если подавляющее количество вызовов уже обнаружит синглетон инициализированным. Поэтому разные люди берут и пишут:
public class NonVolatileDCLFactory {
private Singleton instance;
public Singleton get() {
if (instance == null) { // check 1
synchronized(this) {
if (instance == null) { // check 2
instance = new Singleton();
}
}
}
return instance;
}
}
К сожалению, эта хрень не всегда работает корректно. Казалось бы, если проверка check 1 не выполнилась, то instance уже инициализирован и его можно возвращать. А вот и нет! Он инициализирован с точки зрения потока, который произвёл изначальное присвоение! Никаких гарантий, что вы не обнаружите в полях синглетона то, что вы записали внутри его конструктора, нет.
Здесь можно было бы начать объяснять [2] про happens-before, но это довольно тяжёлый формализм. Вместо этого мы будем использовать феноменологическое объяснение, в виде понятия безопасной публикации. Безопасная публикация обеспечивает видимость всех значений, записанных до публикации, всем последующим читателям. Элементарных способов безопасной публикации несколько:
Обратим внимание, что в NonVolatileDCL поле $instance…
То есть, по определению, публикация $instance в NonVolatileDCL безопасной не является. Смотрите, кстати, сколько из этого следует забавных возможностей для безопасной фабрики синглетонов. Начиная с уже навязшего в зубах:
public class VolatileDCLFactory {
private volatile Singleton instance;
public Singleton get() {
if (instance == null) { // check 1
synchronized(this) {
if (instance == null) { // check 2
instance = new Singleton();
}
}
}
return instance;
}
}
… продолжая не менее классическим holder idiom, который безопасно публикует, записывая объект статическим инициализатором:
public class HolderFactory {
public static Singleton get() {
return Holder.instance;
}
private static class Holder {
public static final Singleton instance = new Singleton();
}
}
… и заканчивая final-полем. Поскольку в final-поле вне конструктора писать уже поздно, нужно сделать:
public class FinalWrapperFactory {
private FinalWrapper wrapper;
public Singleton get() {
if (wrapper == null) { // check 1
synchronized(this) {
if (wrapper == null) { // check 2
wrapper = new FinalWrapper(new Singleton());
}
}
}
return wrapper.instance;
}
private static class FinalWrapper {
public final Singleton instance;
public FinalWrapper(Singleton instance) {
this.instance = instance;
}
}
Вариант с безопасной публикацией через корректно синхронизированное поле у нас уже есть, в самом начале.
Кроме того, в наш зачёт с криком «volatile это дорого!» врывается новый кандидат, кеширующий поле в локале:
public class VolatileCacheDCLFactory implements Factory {
private volatile Singleton instance;
@Override
public Singleton getInstance() {
Singleton res = instance;
if (res == null) {
synchronized (this) {
if (instance == null) {
instance = new Singleton();
}
}
return instance;
}
return res;
}
}
Идём дальше. Объект можно сделать всегда безопасным для публикации. JMM гарантирует видимость всех final-полей после завершения конструктора. Вот пример полностью безопасной инициализации:
public class SafeSingleton implements Singleton {
private final Object obj1;
private final Object obj2;
private final Object obj3;
private final Object obj4;
public SafeSingleton() {
obj1 = new Object();
obj2 = new Object();
obj3 = new Object();
obj4 = new Object();
}
...
}
Замечу, что в некоторых случаях это распространяется не только на final поля, но и на volatile. Есть ещё более фимозные техники, типа synchronized в конструкторе, можете почитать [7] у cheremin [8], он такое любит. В этом посте таких высоких материй мы касаться не будем.
Вот такой объект, понятно, будет небезопасным:
public final class UnsafeSingleton implements Singleton {
private Object obj1;
private Object obj2;
private Object obj3;
private Object obj4;
public UnsafeSingleton() {
obj1 = new Object();
obj2 = new Object();
obj3 = new Object();
obj4 = new Object();
}
...
}
На самом деле, проблемы с небезопасно опубликованным небезопасным синглетоном скажутся в некоторых специальных граничных условиях, например, если конструктор синглетона заинлайнится в getInstance() фабрики Тогда ссылка на недоконструированный объект может быть присвоена в $instance до фактического завершения конструктора.
Вот, например, хвост NonVolatileDCLFactory.getInstance() для UnsafeSingleton (конструктор синглетона заинлайнился):
178 MEMBAR-storestore (empty encoding)
178 #checkcastPP of EAX
178 MOV [ESI + #8],EBX ! Field net/shipilev/singleton/NonVolatileDCLFactory.instance
17b MOV [EDI + #20],EAX ! Field net/shipilev/singleton/UnsafeSingleton.obj4
17e MOV ECX, ESI # CastP2X
180 MOV EBP, EDI # CastP2X
182 SHR ECX,#9
185 SHR EBP,#9
188 MOV8 [EBP + 0x6eb16a80],#0
18f MOV8 [ECX + 0x6eb16a80],#0
18f
196 B16: # B32 B17 <- B15 B4 Freq: 0.263953
196 MEMBAR-release (a FastUnlock follows so empty encoding)
196 MOV ECX,#7
19b AND ECX,[ESI]
19d CMP ECX,#5
1a0 Jne B32 P=0.000001 C=-1.000000
1a0
1a6 B17: # B18 <- B33 B32 B16 Freq: 0.263953
1a6 MOV EAX,[ESI + #8] ! Field net/shipilev/singleton/NonVolatileDCLFactory.instance
1a6
1a9 B18: # N523 <- B17 B1 Freq: 1
1a9 ADD ESP,24 # Destroy frame
POPL EBP
TEST PollPage,EAX ! Poll Safepoint
1b3 RET
Обратите внимание на присвоение $instance до присвоения $obj4.
А вот тот же самый NonVolatileDCLFactory с SafeSingleton:
178 MEMBAR-storestore (empty encoding)
178 #checkcastPP of EAX
178 MOV [EDI + #20],EAX ! Field net/shipilev/singleton/SafeSingleton.obj4
17b MOV ECX, EDI # CastP2X
17d SHR ECX,#9
180 MOV8 [ECX + 0x6eb66800],#0
187 MEMBAR-release ! (empty encoding)
187 MOV [ESI + #8],EBX ! Field net/shipilev/singleton/NonVolatileDCLFactory.instance
18a MOV ECX, ESI # CastP2X
18c SHR ECX,#9
18f MOV8 [ECX + 0x6eb66800],#0
18f
196 B16: # B32 B17 <- B15 B4 Freq: 0.24361
196 MEMBAR-release (a FastUnlock follows so empty encoding)
196 MOV ECX,#7
19b AND ECX,[ESI]
19d CMP ECX,#5
1a0 Jne B32 P=0.000001 C=-1.000000
1a0
1a6 B17: # B18 <- B33 B32 B16 Freq: 0.24361
1a6 MOV EAX,[ESI + #8] ! Field net/shipilev/singleton/NonVolatileDCLFactory.instance
1a6
1a9 B18: # N524 <- B17 B1 Freq: 1
1a9 ADD ESP,24 # Destroy frame
POPL EBP
TEST PollPage,EAX ! Poll Safepoint
1b3 RET
Видно, что $instance пишется после всех полей.
Для тех, кто не запарился до сюда дочитать, небольшой бонус. HotSpot следует консервативной рекомендации из JSR-133 Cookbook [9]: «Issue a StoreStore barrier after all stores but before return from any constructor for any class with a final field.»
Другими словами, есть специфичная для хотспота фишка [10]:
...
// This method (which must be a constructor by the rules of Java)
// wrote a final. The effects of all initializations must be
// committed to memory before any code after the constructor
// publishes the reference to the newly constructor object.
// Rather than wait for the publication, we simply block the
// writes here. Rather than put a barrier on only those writes
// which are required to complete, we force all writes to complete.
...
То есть, если hotspot обнаруживает в конструкторе запись хотя бы в одно final поле, то он тупо выставляет барьер в конец конструктора и таким образом обеспечивает запись всех полей в конструкторе до записи ссылки на сконструированный объект. Это имеет смысл, чтобы не делать несколько барьеров для нескольких финальных полей. То есть, только для хотспота можно сделать так:
public class TrickySingleton implements Singleton {
private final Object barrier;
private Object obj1;
private Object obj2;
private Object obj3;
private Object obj4;
public TrickySingleton() {
barrier = new Object();
obj1 = new Object();
obj2 = new Object();
obj3 = new Object();
obj4 = new Object();
}
...
}
… и это будет эффективно безопасной публикацией, но только на хотспоте. При этом нет особенной разницы, в каком порядке пишутся поля (но только пока *devil_laugh*).
Это несколько умозрительный случай, но внимательный читатель оценит симпатичные грабли: жил-был класс с кучей нефинальных полей и одним финальным. Тесты проходят, приложение работает, объект как будто безопасно публикуется. Потом приходит Вова и рефакторит класс, удаляя финальное поле — и всё, кранты безопасной публикации. Вова смотрит в свой коммит и не понимает, как такое возможно.
Итого, у нас есть шесть вариантов фабрик и три синглетона.
Когда-то давно gvsmirnov [11] меня спрашивал, можно ли действительно продемонстрировать такой реордеринг, который сломает DCL. Как видно из ассемблера вверху, гадкие реордеринги даже в присутствии Total Store Order'а нам может преподнести компилятор. Почему он это сделает, тайна сия велика [12] есть, ему никто не запрещал.
Важно то, что это довольно тонкая гонка исключительно на первой инициализации, и поэтому приходится немножко поизвращаться, чтобы её осуществить:
private volatile Factory factory;
private volatile boolean stopped;
public class Runner implements Runnable {
public void run() {
long iterations = 0;
long selfCheckFailed = 0;
while (!stopped) {
Singleton singleton = factory.getInstance();
if (singleton == null || !singleton.selfCheck()) {
selfCheckFailed++;
}
iterations++;
// switch to new factory
factory = FactorySelector.newFactory();
}
pw.printf("%d of %d had failed (%e)n", selfCheckFailed, iterations, 1.0D * selfCheckFailed / iterations);
}
}
Полный проект лежит вот тут [13], можете поиграться. -DfactoryType, -DsingletonType выбирают фабрику и синглетон, -Dthreads регулирует количество потоков, а -Dtime — время на тест.
Синглетон проверяет свои поля методом:
public boolean selfCheck() {
return (obj1 != null) &&
(obj2 != null) &&
(obj3 != null) &&
(obj4 != null);
}
… то есть по сути смотрит, были ли таки инициализированы поля у того инстанса, который отдала фабрика.
Ну что, посчитаем вероятности отказа. Гоняем тесты по 10 минут: за это время миллиарды новых синглетонов успевают создаваться, сталкиваться, разлетаться на фермионы, бозоны… чёрт, кажется, я не туда пишу. Никаким таким тестом доказать корректность многопоточного кода нельзя, тестом её можно только опровергнуть.
На приличных размеров Nehalem'е (2 sockets, 6 cores per socket, 2 strands per core = 24 hw threads), JDK 7u4 x86_64, RHEL 5.5, -Xmx8g -Xms8g -XX:+UseNUMA -XX:+UseCompressedOops, в 24 потоках; метрика — вероятность отказа:
Unsafe | Safe | Tricky | |
Synchronized | ε | ε | ε |
NonVolatileDCL | 3*10-4 | ε | ε |
VolatileDCL | ε | ε | ε |
VolatileCacheDCL | ε | ε | ε |
Holder | ε | ε | ε |
FinalWrapperDCL | ε | ε | ε |
ε < 10-11, т.е. ни одного фейла не произошло, но это не значит, что их никогда не будет :)
Что мы видим?
Дабы меня не обвинили в великодержавном шовинизме, вот тот же тест на двухядерном NVidia Tegra2 (Cortex A9) и JDK 7u4 (ARM port), -Xmx512m -Xms512m -XX:+UseNUMA в двух потоках; метрика — вероятность отказа:
Unsafe | Safe | Tricky | |
Synchronized | ε | ε | ε |
NonVolatileDCL | 2*10-8 | ε | ε |
VolatileDCL | ε | ε | ε |
VolatileCacheDCL | ε | ε | ε |
Holder | ε | ε | ε |
FinalWrapperDCL | ε | ε | ε |
ε < 10-10, т.е. ни одного фейла не произошло, но это не значит, что они не появятся в будущем. ε существенно меньше, потому что ARM медленее, а тест выполняется те же 10 минут.
Что мы видим? Да тоже самое и видим. Несмотря на то, что x86 и ARM — очень разные платформы с точки зрения модели памяти, гарантированное поведение остаётся гарантированным. Вероятность отказа сильно упала ввиду специфики теста: глобальный эффект от безопасной публикации самой factory частично сглаживает эффекты от теста.
Написать корректный параллельный код — дело не хитрое. Оберни всё глобальным локом, и вперёд. Проблема написать корректный и эффективный параллельный код. Ввиду того, что на J1 мне умудрялись говорить «ой, volatile в DCL это так дорого, мы лучше синхронизуем getInstance()», придётся наглядно показать, что к чему. Не буду показывать много графиков, покажу только пару точек с тех же платформах, где гонялась корректность.
Очень простой микробенчмарк в нашем внутреннем тёплом ламповом харнессе выглядит так:
public class SteadyBench { // все инстансы SteadyBench шарятся между потоками
private Factory factory;
@Setup
public void setUp() {
factory = FactorySelector.newFactory();
}
@TearDown
public void teardown() {
factory = null;
}
@GenerateMicroBenchmark(share = Options.Tristate.TRUE)
public Object test() { // этот метод зовётся в цикле много-много раз
return factory.getInstance();
}
}
Поскольку наш харнесс ещё не открыт, вам придётся немножко поработать, чтобы написать полный микробенчмарк.
Брать синглетон у уже горячей фабрики — подавляющий use case в продакшене. Замечу, что микротест, который сильно амплифицирует стоимость даже элементарных операций, т.е. если что-то в этом тесте быстрее в два раза, то это не значит, что большой проект тоже разгонится в два раза с «правильной идиомой». Хотя бывает, особенно для локов.
x86, Nehalem, 24 hardware threads; метрика: миллионы операций в секунду, чем больше, тем лучше:
1 thread | 24 threads | |||||
Unsafe | Safe | Tricky | Unsafe | Safe | Tricky | |
Synchronized | 46 ± 1 | 47 ± 1 | 43 ± 1 | 9 ± 1 | 25 ± 1 | 22 ± 2 |
NonVolatileDCL | 386 ± 12 | 473 ± 1 | 463 ± 2 | 5103 ± 27 | 4955 ± 84 | 4981 ± 45 |
VolatileDCL | 394 ± 8 | 405 ± 2 | 402 ± 8 | 3977 ± 33 | 4576 ± 26 | 4620 ± 19 |
VolatileCachedDCL | 454 ± 8 | 465 ± 3 | 460 ± 6 | 4778 ± 180 | 4946 ± 70 | 5071 ± 87 |
Holder | 554 ± 0 | 520 ± 7 | 540 ± 5 | 6125 ± 30 | 6131 ± 35 | 6114 ± 22 |
FinalWrapperDCL | 415 ± 0 | 390 ± 10 | 359 ± 6 | 4566 ± 24 | 4585 ± 23 | 4231 ± 30 |
Что мы здесь видим?
ARMv7, Cortex A9, 2 hardware threads; метрика: миллионы операций в секунду, чем больше, тем лучше:
1 thread | 2 threads | |||||
Unsafe | Safe | Tricky | Unsafe | Safe | Tricky | |
Synchronized | 7.1 ± 0.1 | 7.1 ± 0.1 | 7.1 ± 0.1 | 1.9 ± 0.1 | 1.9 ± 0.1 | 1.9 ± 0.1 |
NonVolatileDCL | 23.6 ± 0.1 | 23.6 ± 0.1 | 23.6 ± 0.1 | 45.5 ± 1.3 | 47.0 ± 0.1 | 47.0 ± 0.1 |
VolatileDCL | 13.4 ± 0.1 | 13.4 ± 0.1 | 13.4 ± 0.1 | 26.6 ± 0.1 | 26.6 ± 0.1 | 26.6 ± 0.1 |
VolatileCachedDCL | 17.4 ± 0.1 | 17.4 ± 0.1 | 17.4 ± 0.1 | 34.6 ± 0.1 | 34.6 ± 0.1 | 34.6 ± 0.1 |
Holder | 24.2 ± 0.1 | 24.2 ± 0.1 | 24.2 ± 0.1 | 47.8 ± 0.4 | 47.9 ± 0.4 | 48.0 ± 0.1 |
FinalWrapperDCL | 24.2 ± 0.1 | 24.2 ± 0.1 | 24.2 ± 0.1 | 48.1 ± 0.1 | 46.8 ± 1.5 | 46.8 ± 1.5 |
Что мы здесь видим?
Главный вывод запечатлейте у себя: DCL работает! (кроме случаев, когда и объект небезопасный, и фабрика не безопасная).
Рецепты:
Автор: TheShade
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/6993
Ссылки в тексте:
[1] Walrus: http://habrahabr.ru/users/walrus/
[2] начать объяснять: http://habrahabr.ru/post/133981/
[3] JLS 12.4: http://docs.oracle.com/javase/specs/jls/se7/html/jls-12.html#jls-12.4
[4] JLS 17.4.5: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.4.5
[5] как следствие: http://docs.oracle.com/javase/6/docs/api/java/util/concurrent/atomic/package-summary.html
[6] JLS 17.5: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.5
[7] можете почитать: http://cheremin.blogspot.com/2012/05/unsafe-publication.html
[8] cheremin: http://habrahabr.ru/users/cheremin/
[9] JSR-133 Cookbook: http://g.oswego.edu/dl/jmm/cookbook.html
[10] фишка: http://hg.openjdk.java.net/jdk7u/jdk7u/hotspot/file/tip/src/share/vm/opto/parse1.cpp
[11] gvsmirnov: http://habrahabr.ru/users/gvsmirnov/
[12] тайна сия велика: http://tinyurl.com/2g9mqh
[13] вот тут: http://shipilev.net/pub/articles/dcl-habr/singleton.tar.gz
Нажмите здесь для печати.