Почему volatile используется в этом примере блокировки с двойной проверкой


Из книги Head First design patterns, синглетный шаблон с двойной проверкой блокировки был реализован следующим образом:

public class Singleton {
    private volatile static Singleton instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Я не понимаю, почему volatile используется. Разве использование volatile не нарушает цель использования двойной проверенной блокировки, т. е. производительности?

6 54

6 ответов:

Хороший источник для понимания того, почему volatile необходим, взят из книги JCIP. Википедия также имеетдостойное объяснение этого материала.

Реальная проблема заключается в том, что Thread A может назначить пространство памяти для instance до того, как он закончит построение instance. Thread B увидит это назначение и попытается его использовать. Это приводит к сбою Thread B, потому что он использует частично сконструированную версию instance.

Как цитирует @irreputable, volatile не стоит дорого. Даже если это дорого, последовательность должна иметь приоритет над производительностью.

Есть еще один чистый элегантный способ для ленивых Синглетов.
public final class Singleton {
    private Singleton() {}
    public static Singleton getInstance() {
        return LazyHolder.INSTANCE;
    }
    private static class LazyHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
}

Исходная статья: Initialization-on-demand_holder_idiom из Википедии

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

Поскольку класс не имеет переменных static для инициализации, инициализация завершается тривиально.

Статическое определение класса LazyHolder в нем не инициализируется до тех пор, пока JVM не определит, что LazyHolder должен быть выполнен.

Статический класс LazyHolder выполняется только тогда, когда статический метод getInstance вызывается на синглтоне класса, и в первый раз, когда это происходит, JVM загружается и инициализируется класс LazyHolder.

Это решение потокобезопасно, не требуя специальных языковых конструкций (например, volatile или synchronized).

Ну, нет никакой перепроверенной блокировки для производительности. Это сломанный шаблон.

Оставляя эмоции в стороне, volatile здесь потому, что без него к моменту прохождения второго потока instance == null первый поток может еще не построить new Singleton(): никто не обещает, что создание объектапроизойдет-до назначения instance для любого потока, кроме того, который фактически создает объект.

volatile в свою очередь устанавливает бывает-до связь между чтениями и записями, и исправляет нарушенный шаблон.

Если вы ищете производительность, используйте внутренний статический класс holder.

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

Первый - не для корректности (если бы Вы были правы, то это было бы саморазрушительно), а для оптимизации.

Волатильное чтение само по себе не очень дорого.

Вы можете сконструировать тест для вызова getInstance() в узком цикле, чтобы наблюдать влияние изменчивого чтения; однако этот тест не реалистичен; в такой ситуации программист обычно вызывает getInstance() один раз и кэширует экземпляр на время использования. Другой impl-это использование поля final (см. Википедию). Это требует дополнительного чтения, которое может стать дороже, чем версия volatile. Версия final может быть быстрее в узком круге, однако, этот тест спорен, как утверждалось ранее.

Объявление переменной как volatile гарантирует, что все обращения к ней фактически считывают ее текущее значение из памяти.

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