JSR 133 (Java Memory Model) FAQ (перевод)

в 22:25, , рубрики: Без рубрики

Добрый день.
В рамках набора на курс «Multicore programming in Java» я делаю серию переводов классических статей по многопоточности в Java. Всякое изучение многопоточности должно начинаться с введения в модель памяти Java (New JMM), основным источником от авторов модели является «The Java Memory Model» home page, где для старта предлагается ознакомится с JSR 133 (Java Memory Model) FAQ. Вот с перевода этой статьи я и решил начать серию.
Я позволил себе несколько вставок «от себя», которые, по моему мнению, проясняют ситуацию.
Я являюсь специалистом по Java и многопоточности, а не филологом или переводчиком, посему допускаю определенные вольности или иные формулировки при переводе. В случае, если Вы предложите лучший вариант — с удовольствием сделаю правку.


JSR 133 (Java Memory Model) FAQ

Jeremy Manson и Brian Goetz, февраль 2004

Содержание
Что такое модель памяти, в конце концов?
Другие языки, такие как C + +, имеют модель памяти?
Что такое JSR 133?
Что подразумевается под «переупорядочением»?
Что было не так со старой моделью памяти?
Что вы подразумеваете под «некорректно синхронизированы»?
Что делает синхронизация?
Как может случиться, что финальная поля меняют значения?
How do final fields work under the new JMM?
Что делает volatile?
Решила ли новая модель памяти «double-checked locking» проблему?
Что если я пишу виртуальную машину?
Почему я должен беспокоиться?

Что такое модель памяти, в конце концов?

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

На уровне процессора, модель памяти определяет необходимые и достаточные условия для гарантии того, что записи в память другими процессорами будут видны текущему процессору, и записи текущего процессора будут видимы другими процессорами. Некоторые процессоры демонстрируют сильную модель памяти, где все процессоры всегда видят точно одинаковые значения для любой заданной ячейки памяти. Другие процессоры демонстрируют более слабую модель памяти, где специальные инструкции, называемые барьерами памяти, требуются, чтобы ???flush или ???invalidate локальный кэш процессора, с целью увидеть записи, сделанные другими процессорами или сделать записи данного процессора видимыми для других. Эти барьеры памяти, как правило, выполняется при блокировке и ???unlock действиях; они невидимы для программистов в языках высокого уровня.

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

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

Кроме того, запись в памяти может быть перемещена раньше в программе; в этом случае, другие потоки могут увидеть запись, прежде чем он на самом деле «происходит». Вся эта гибкость является особенностью дизайна — давая компилятору, среде исполнения и аппаратному обеспечению гибкость выполнять операции в оптимальном порядке в рамках модели памяти, мы можем достичь более высокой производительности.

Простой пример этого можно увидеть в следующем фрагменте кода:

class Reordering {
    int x = 0, y = 0;
    public void writer() {
        x = 1;
        y = 2;
    }
    public void reader() {
        int r1 = y;
        int r2 = x;
    }
}

Давайте предположим, что этот код выполняется в двух потоках одновременно и чтение у возвращает значение 2. Поскольку эта запись расположена после записи х, программист может предположить, что чтение х должно вернуть значение 1. Тем не менее, записи, возможно, были переупорядочены. Если это имело место, то могла произойти запись в у, затем чтение обеих переменных, и только потом запись в х.Результатом будет то, что r1 имеет значение 2, а r2 имеет значение 0.

Комментарий переводчика

Предполагается, что у одного и того же объекта метод reader() и метод writer() вызываются из различных потоков.

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

Java включает в себя несколько языковых конструкций, в том числе volatile, final и synchronized, которые предназначены, для того, чтобы помочь программисту описать компилятору требования к параллелизму в программе. Модель памяти Java определяет поведение volatile и synchronized, и, что более важно, гарантирует, что корректно синхронизированная Java-программа работает правильно на всех процессорных архитектурах.

Другие языки, такие как C + +, имеют модель памяти?

Большинство других языков программирования, таких как C и C++, не были разработаны с прямой поддержкой многопоточности. Защитные меры, которые эти языки предлагают против различных видов переупорядочивания, происходящих в компиляторах и архитектурах во многом зависят от гарантий, предлагаемых используемыми библиотеками распараллеливания (например, pthreads), используемым компилятором и платформой, на которой запускается код.

Комментарий переводчика

Java задала тренд введения модели памяти в спецификации языка и в последнем стандарте C++11 уже есть модель памяти (глава 1.7 «The C++ memory model»). Кажется она есть уже и у C11.

Что такое JSR 133?

С 1997 года были обнаружены несколько серьезных недостатков в модели памяти Java, которая определена в главе 17 спецификации языка. Эти недостатки допускали шокирующее поведение (например, позволялось изменение значения final-поля) и препятствовали компилятору в использовании типичных оптимизаций.

Комментарий переводчика

Java 8 Language Specification, «Chapter 17. Threads and Locks»
Java 7 Language Specification, «Chapter 17. Threads and Locks»
Java 6 Language Specification, «Chapter 17. Threads and Locks»
Java 5 Language Specification, «Chapter 17. Threads and Locks»
Java 4 Language Specification, «Chapter 17. Threads and Locks»

Модель памяти Java была амбициозным проектом; впервые спецификация языка программирования попыталась включить модель памяти, которая может обеспечить согласованную семантику для параллелизма среди различных архитектур. К сожалению, определить модель памяти, которая является и согласованной и интуитивной оказалось гораздо труднее, чем ожидалось. JSR 133 определяет новую модель памяти для Java, которая исправляет недостатки предыдущей модели. Для того чтобы сделать это, семантика final и volatile была изменена.

Полное описание семантики доступно по ссылке http://www.cs.umd.edu/users/pugh/java/memoryModel, но формальное описание не для робких. Удивляет и отрезвляет когда узнаешь, насколько сложным является такое простое понятие, как синхронизации на самом деле. К счастью, вам не нужно понимать все детали формальной семантики — целью JSR 133 было создать набор правил, который обеспечивает интуитивное понимание того, как работают volatile, synchronized и final.

Цели JSR 133 включают в себя:

  • Сохранение существующих гарантий безопасности, таких как безопасность типов (type safety???), а также ??? усиление других. Например, значения переменных не могут появиться «из воздуха»: каждое значение для переменной наблюдаемое каким-либо одним потоком должно быть значением, которое было записано каким-то другим потоком.
  • Семантика корректно синхронизированных программ должна быть настолько простой и интуитивно понятной, насколько это возможно.
  • Семантика не полно или некорректно синхронизированных программ должна быть определена таким образом, что бы потенциальные угрозы безопасности были минимизированы
  • Программисты должны иметь возможность уверенно рассуждать о том, как многопоточные программы взаимодействуют с памятью.
  • Должно быть возможна разработка корректных и высокопроизводительных JVM поверх широкого диапазона популярных аппаратных архитектур.
  • Должны быть обеспечена новая гарантия безопасности инициализации. Если объект правильно построен (не было «утечек» ссылок на него во время конструирования), то все потоки, которые видят ссылку на этот объект также без необходимости синхронизации будут видеть значения final-полей, которые были установлены в конструкторе.
  • Должно быть обеспечено минимальное влияние на существующий код.

Что подразумевается под «переупорядочением»?

Есть ряд случаев, в которых доступ к переменным (полям объектов,  статическим полям и элементам массива) может выполниться в порядке, отличном от указанного в программе. Компилятор свободен в расположении инструкций с целью оптимизации. Процессоры могут выполнять инструкции в ином порядке в ряде случаев. Данные могут перемещаться между регистрами, кэшами процессора и оперативной памяти в другом порядке, чем указано в программе.

Например, если поток пишет в поле 'а', а затем в поле 'b' и значение 'b' не зависит от значения 'a', то компилятор волен изменить порядок этих операций, и кэш имеет право сбросить (flush) 'b' в оперативную память раньше чем 'a'. Есть несколько потенциальных источников переупорядочивания, таких как компилятор, JIT и кэш-память.

Компилятор, среда исполнения и аппаратное обеспечение допускают изменение порядка иснтрукций при сохранении иллюзии, как-если-поcлдовательной (as-if-serial) семантики, что означает, что однопоточные программы не должны быть в состоянии наблюдать эффекты переупорядочивания. Тем не менее, переупорядочивания может вступить в игру в некорректно синхронизированных многопоточных программах, где один поток может наблюдать эффекты производимые другими потоками, и такие программы могут быть в состоянии обнаружить, что переменные становятся видимыми для других потоков в порядке, отличном от указанного в исходном коде.

Большую часть времени, потоки не учитывают, что делают другие потоки. Но когда им это требуется, то в игру вступает синхронизация.

Что было не так со старой моделью памяти?

Было несколько серьезных проблем со старой моделью памяти. Она была трудна для понимания и поэтому часто нарушалась. Например, старая модель во многих случаях не позволяла многие виды переупорядочивания, которые были реализованы в каждой JVM. Эта путаница со смыслом старой модели привела к тому, что были вынуждены создать JSR-133.

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

Комментарий переводчика

??? — пример со строками

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

Комментарий переводчика

??? — пример с volatile

Наконец, предположения программистов о том, что может произойти, если их программы неправильно синхронизированы часто ошибочны. Одна из целей JSR-133 — обратить внимание на этот факт.

Как final-поля работают при новой модели памяти?

Значения для final-полей объекта задаются в конструкторе (??? или инициализаторе). Если предположить, что объект построен «правильно», то как только объект построен, значения, присвоенные final-полям в конструкторе будут видны всем другим потокам без синхронизации. Кроме того, видимые значения для любого другого объекта или массива, на который ссылается эти final-поля будут по крайней мере, так же «свежи», как и значения final-полей.
(??? — пример несвежести)

Что это значит для объект чтобы быть должным образом построенным? Это просто означает, что ссылка на объект «не утечет» до окончания процесса построения экземпляра. (См. Safe Construction Techniques для примеров. ???) Другими словами, не помещайте ссылку на объект строится в любом месте, где другой поток может быть в состоянии видеть его; не назначить его на статическом поле, не зарегистрировать его в качестве слушателя с любой другой объект, и так далее. Эти задачи должно быть сделано после того, как конструктор завершает, не в конструкторе.

Что вы подразумеваете под «некорректно синхронизированы»?

Под некорректно синхронизированным кодом разные люди подразумевают разные вещи. Когда мы говорим о некорректно синхронизированном коде в контексте модели памяти Java, мы имеем в виду любой код, в котором:

  1. есть запись переменной одним потоком,
  2. есть чтение той же самой переменной другим потоком и
  3. чтение и запись не упорядочены по синхронизации (are not ordered by synchronization)

Когда это происходит, мы говорим, что происходит гонка потоков (data race) на этой переменной. Программы с гонками потоков — некорректно синхронизированные программы.

Комментарий переводчика

??? — некорректно синхронизированные программы тоже могут быть полезны — String

Что делает синхронизация?

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

Но синхронизация — это больше чем просто взаимное исключение. Синхронизация гарантирует, что данные записанные в память до или в синхронизированном блоке становятся предсказуемо видимыми для других потоков, которые синхронизируются на том же мониторе. После того как мы выходим из синхронизированного блока, мы отпускаем (release) монитор, что имеет эффект сбрасывания (flush) кэша в оперативную память, так что запись сделанные нашим потоком могут быть видимыми для других потоков. Прежде чем мы сможем войти в синхронизированный блок, мы захватываем (asquire) монитор, что имеет эффект объявления недействительными данных локального процессорного кэша (invalidating the local processor cache), так что переменные будут загружены из основной памяти. Тогда мы сможем увидеть все записи, сделанные видимым предыдущим освобождением (release) монитора.

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

Семантика новой модели памяти создает частичный порядок на операциях с памятью (чтение поля, запись поля, захват блокировки (lock), освобождение блокировки (unlock)) и другие операции с потоками (start(), join()). Некоторые действия, как говорят, происходят «прежде» (happen before) других. Когда одно действие происходит «прежде» (happen before) другого, первое будет гарантированно расположено до и видно второму. Правила этого упорядочения таковы:

Комментарий переводчика

??? — частичный порядок — математическое определение

  1. Каждое действие в потоке происходит «прежде» (happens before) любого другого действия в этом потоке, которое идет «ниже» в коде этого потока.
  2. Освобождение монитора происходит «прежде» (happens before) каждого последующего захвата того же самого монитора.
  3. Запись в volatile-поле происходит происходит «прежде» (happens before) каждого последующего чтения того же самого volatile-поля.
  4. Вызов метода start() потока происходит «прежде» (happens before) любых действий в запущенном потоке.
  5. Все действия в потоке происходят «прежде» (happens before) любых действий любого другого потока, который успешно завершил ожидание на join() по первому потоку.

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

Комментарий переводчика

??? — много примеров с останов-не останов потока

Другим следствием является то, что следующий шаблон, который некоторые люди используют, чтобы установить барьер памяти, не работает:

synchronized (new Object()) {}

Это конструкция является на самом деле «пустышкой» (no-op), и ваш компилятор может удалить ее полностью, потому что компилятор знает, что никакой другой поток не будет синхронизироваться на том же мониторе. Вы должны установить отношение «происходит-прежде» отношения для одного потока, чтобы увидеть результаты другого.

Важное примечание: Обратите внимание, важно для обоих потоков синхронизироваться на одном и том же мониторе, чтобы установить отношение «происходит-прежде» (happens-before relationship) должным образом. Это не тот случай, когда все видимое потоку A, когда он синхронизируется на объекте X становится видно потоку B после того, как тот синхронизирует на объекте Y. Освобождение и захват должны «соответствовать» (то есть, быть выполнены с одним и тем же монитором), чтобы была обеспечена правильная семантика. В противном случае код содержит гонку данных (data race).

Комментарий переводчика

??? — пример с синхронизируемся по разным

Как может случиться, что финальная поля меняют значения?

Один из лучших примеров того, как значения final-полей могут измениться, включает одну конкретную реализацию класса String.
Строка может быть реализована как объект с тремя полями — массив символов, смещение в этом массиве, и длины. Основанием для реализации String Это путь, вместо того, только массив символов, в том, что он позволяет несколько строку и объекты StringBuffer одни и те же массив символов и избежать дополнительное выделение объекта и копирование. Так, например, метод String.substring () может быть реализован путем создания нового строку, которая разделяет тот же массив символов с исходной строки и просто отличается в длину и смещение поля. Для String, эти поля все окончательные поля.

Комментарий переводчика

До update 6 для JRE 7 от Oracle java.lang.String реализована вот так

public final class String implements Serializable, Comparable<String>, CharSequence {
    private final char value[];
    private final int offset;
    private final int count;
    ....
}

В начиная с update 6 для JRE 7 от Oracle java.lang.String реализована уже по другому (без полей offset и count)
О причинах Вы можете прочитать тут. Это не меняет сути примера авторов, так как «содержимое» финального массива value[] тоже может «пока не долететь». Даже возможны несколько стадий:
1. ссылка на строку уже не null, а поле value — пока null
2. ссылка на строку уже не null, поле value[] уже не null, но в некоторых ячейках value[] не корректные char-ы, а первоначальные 0-и.

A String can be implemented as an object with three fields — a character array, an offset into that array, and a length. The rationale for implementing String this way, instead of having only the character array, is that it lets multiple String and StringBuffer objects share the same character array and avoid additional object allocation and copying. So, for example, the method String.substring() can be implemented by creating a new string which shares the same character array with the original String and merely differs in the length and offset fields. For a String, these fields are all final fields.

String s1 = "/usr/tmp";
String s2 = s1.substring(4); 

Что делает volatile?

Volatile поля являются специальными полями, которые используются для передачи состояние между потоками. Каждое чтение из volatile возвратит результат последней записи любым другим потоком; по сути, они указываются программистом как поля, для которых не приемлемо увидеть «несвежий» (stale) значение в результате кэширования или переупорядочивания. Компилятору и runtime-среде запрещено распределять их в регистрах. Они также должны убедиться, что после того как они написаны, они «проталкиваются» (flushed) из кэша в основную память, поэтому они сразу же становятся видны другим потокам. Аналогично, перед чтением volatile поля кэш должен быть освобожден, так что будет видимо значение в оперативной памяти, а не в кэше Существуют также дополнительные ограничения на изменение порядка обращения к volatile переменных.

При старой модели памяти, доступ к volatile переменным не могли быть переупорядочены друг с другом, но они могут быть переупорядочены с не-volatile переменными. Это сводило на нет полезность volatile полей как средства передачи сигнала от одного потока к другому.

В соответствии с новой моделью памяти, по-прежнему верно, что volatile переменные не могут быть переупорядочены друг с другом. Разница в том, что теперь уже не так легко изменить порядок между обычными полями расположенными рядом volatile. Запись в volatile поле тот же эффект для памяти как и освобождение монитора (monitor release), а чтение из volatile поля имеет тот же эффект для памяти как и захват монитора (monitor acquire). В сущности, так как новая модель накладывает более строгие ограничения на изменение порядка между доступом к volatile полям и другими полями (volatile или обычным), все, что было видимо для потока A когда он писал в volatile поле f становится видимым для потока B, когда он прочтет f.

Комментарий переводчика

И в старой и в новой моделях памяти программа гарантированно остановится и напечатает 1 (data — volatile, run — volatile)

public class App {
    static volatile int data = 0;
    static volatile boolean run = true;
    public static void main(String[] args) {
        new Thread(new Runnable() {
            public void run() {
                data = 1;
                run = false;
            }
        }).start();

        while (run) {/*NOP*/};
        System.out.println(data);
    }
}

И в старой и в новой моделях памяти программа гарантированно остановится. В новой модели гарантированно напечатает 1, в старой может 0 или 1 (data — НЕ volatile, run — volatile), так в новой нельзя переносить запись в не-volatile ниже чем запись в volatile

public class App {
    static int data = 0;
    static volatile boolean run = true;
    public static void main(String[] args) {
        new Thread(new Runnable() {
            public void run() {
                data = 1;
                run = false;
            }
        }).start();

        while (run) {/*NOP*/};
        System.out.println(data);
    }
}

И в старой и в новой моделях памяти программа может НЕ остановится. В обеих моделях если остановится, то может напечатать как 1, так и 0 (data — НЕ volatile, run — НЕ volatile), так как можно мменять независимые записи в не-volatile поля

public class App {
    static int data = 0;
    static boolean run = true;
    public static void main(String[] args) {
        new Thread(new Runnable() {
            public void run() {
                data = 1;
                run = false;
            }
        }).start();

        while (run) {/*NOP*/};
        System.out.println(data);
    }
}

И в старой и в новой моделях памяти программа может НЕ остановится. В обеих моделях если остановится, то может напечатать как 1, так и 0 (data — volatile, run — НЕ volatile), так как можно переносить запись в не-volatile выше чем запись в volatile

public class App {
    static volatile int data = 0;
    static boolean run = true;
    public static void main(String[] args) {
        new Thread(new Runnable() {
            public void run() {
                data = 1;
                run = false;
            }
        }).start();

        while (run) {/*NOP*/};
        System.out.println(data);
    }
}

Вот простой пример того, как volatile поля могут быть использованы (??? — Effective Java):

class VolatileExample {
    int x = 0;
    volatile boolean v = false;
    public void writer() {
        x = 42;
        v = true;
    }
    public void reader() {
        if (v == true) {
            //uses x - guaranteed to see 42.
        }
    }
}

Предположим, что один поток называется Писателем, а другой называется читателем. Запись в v в Писателе releases the write to x to memory(???), а считывание v приобретает??? это значение из памяти. Таким образом, если читатель увидит значение true поля у, то также гарантированно увидит запись 42 в x. Это не было верно, для старой моделью памяти. Если бы v не было volatile, то компилятор мог бы изменить порядок записи в писателе, и для чтение читателем х могло бы видеть 0.

Семантика volatile была существенно усиленна, почти до уровня synchronized. Каждое чтение или запись volatile действует как «половина» synchronized с точки зрения видимости.
Важное примечание: Обратите внимание, важно что бы оба потока сделали чтение-запись по одной и той же volatile переменной, что бы добиться установления happens-before отношения. Это не тот случай, что все что видимо для потока А, когда он пишет летучих поле f становится видно для потока B после того, как он считает volatile поле g. Чтение и запись должны относиться к одной и той же volatile переменной, чтобы иметь должную семантику.

Решила ли новая модель памяти «double-checked locking» проблему?

(Печально известная) double-checked locking идиома (также называемая multithreaded singleton pattern) — это трюк, предназначенный для поддержки отложенной инициализации при отсутствии накладных расходов на синхронизацию. В самых ранних JVM синхронизация была медленной и разработчики стремились удалить ее, возможно, слишком ретиво. Double-checked locking идиома выглядит следующим образом:

// double-checked-locking - don't do this!

private static Something instance = null;

public Something getInstance() {
    if (instance == null) {
        synchronized (this) {
            if (instance == null)
                instance = new Something();
        }
    }
    return instance;
}

Комментарий переводчика

Мне кажется вот так корректнее (авторы не предоставили законченный класс, но в «каноническом варианте» метод getInstance() — статический, как следствие в нем невозможна синхронизация по this)

// double-checked-locking - don't do this!
public class Something {
    private static Something instance = null;
    public static Something getInstance() {
        if (instance == null) {
            synchronized (Something.class) {
                if (instance == null)
                    instance = new Something();
            }
        }
        return instance;
    }
}

или с использованием идиомы Private Mutex

// double-checked-locking - don't do this!
public class Something {
    private static final Object lock = new Object();
    private static Something instance = null;
    public static Something getInstance() {
        if (instance == null) {
            synchronized (lock) {
                if (instance == null)
                    instance = new Something();
            }
        }
        return instance;
    }
}

Это выглядит ужасно умно — мы избегаем синхронизации на наиболее частом пути выполнения (???). Есть только одна проблема с этим — идиома не работает. Почему? Наиболее очевидной причиной является то, что запись инициализирующая экземпляр и запись в экземпляра в статическое поле могут быть переупорядочены компилятором или кэшом, что будет иметь эффект возвращения чего-то частично «построенного». Результатом будет то, что мы читаем неинициализированный объект. Есть много других причин, почему некорректна как эта идиома, так и алгоритмические поправки к ней. Нет никакого способа, чтобы исправить это в старой модели памяти Java. Более подробную информацию можно найти «Double-checked locking: Clever, but broken» и тут «The 'Double Checked Locking is broken' declaration».

Многие полагали, что использование ключевого слова volatile позволит устранить проблемы, которые возникают при попытке использовать шаблон double-checked-locking. В виртуальных машинах до 1.5, volatile не будет гарантировать, что он работал. В соответствии с новой моделью памяти, объявление поле как volarile «исправит» проблемы с double-checked-locking, так как будет установлено отношение happens-before между инициализацией Something конструирующим потоком и возвратом читающему потоку.

Тем не менее, для любителей double-checked locking (и мы действительно надеемся, что их не осталось), новости по-прежнему не очень хороши. Весь смысл double-checked locking был избежать накладных расходов синхронизации. Мало того, что кратковременная синхронизации получилась НАМНОГО дешевле, чем в Java 1.0, но в рамках новой модели памяти, падение производительности при использовании volatile достигло почти уровня стоимости синхронизации. Так что до сих пор нет хорошей причины для использования с double-checked locking. Отредактировано: volatile обходится дешево на большинстве платформ.

Взамен используйте Initialization On Demand Holder идиому, которая потокобезопасна и намного проще для понимания:

private static class LazySomethingHolder {
    public static Something something = new Something();
}

public static Something getInstance() {
    return LazySomethingHolder.something;
}

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

Комментарий переводчика

??? — ссылка на спеку

Что если я пишу виртуальную машину?

Вам стоит посмотреть на gee.cs.oswego.edu/dl/jmm/cookbook.html.

Почему я должен беспокоиться?

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

Автор: IvanGolovach

Источник

Поделиться

* - обязательные к заполнению поля