Почему эта блокировка с двойной проверкой реализована с отдельным классом-оболочкой?

Когда я читал статью Wikipedias о Double Checked Locking, я смущен этой реализацией:

public class FinalWrapper<T> {
    public final T value;
    public FinalWrapper(T value) { 
        this.value = value; 
    }
} 
public class Foo {
    private FinalWrapper<Helper> helperWrapper = null;

    public Helper getHelper() {
        FinalWrapper<Helper> wrapper = helperWrapper;

    if (wrapper == null) {
         synchronized(this) {
           if (helperWrapper == null) {
              helperWrapper = new FinalWrapper<Helper>(new Helper());
           }
           wrapper = helperWrapper;
         }
    }
    return wrapper.value;
   }
}

Я просто не понимаю, зачем нам создавать оболочку. Разве этого недостаточно?

if (helperWrapper == null) {
     synchronized(this) {
       if (helperWrapper == null) {
          helperWrapper = new FinalWrapper<Helper>(new Helper());
       }
     }
}    

Это потому, что использование обертки может ускорить инициализацию, потому что оболочка хранится в стеке, а helperWrapper хранится в куче?

Ответ 1

Разве этого недостаточно?

if (helperWrapper == null) {
     synchronized(this) {
       if (helperWrapper == null) {
          helperWrapper = new FinalWrapper<Helper>(new Helper());
       }
     }
}

Нет, этого недостаточно.

Выше, первая проверка для helperWrapper == null не является потокобезопасной. Он может возвращать false (видя ненулевой экземпляр) для некоторого потока "слишком рано", указывая на не полностью сконструированный объект helperWrapper.

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

Например, рассмотрим следующую последовательность событий:

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

Примечание семантика некоторых языков программирования, упомянутых выше, является в точности семантикой Java с версии 1.5 и выше. Модель памяти Java (JSR-133) явно допускает такое поведение - поиск в Интернете для получения более подробной информации об этом, если вы заинтересованы.

Это потому, что использование обертки может ускорить инициализацию, потому что оболочка хранится в стеке, а helperWrapper хранится в куче?

Нет, это не причина.

Причина - безопасность потоков. Опять же, семантика Java 1.5 и выше (как определено в Java Memory Model) гарантирует, что любой поток сможет получить доступ только к правильно инициализированному экземпляру Helper из оболочки из-за того, что это конечное поле, инициализированное в конструкторе - см. JLS 17.5 Семантика конечного поля.

Ответ 2

Как я понимаю, локальный wrapper код действительно не нужен.

Мое рассуждение: причина, по которой трюк Wrapper работает в первую очередь, состоит в том, что JLS гарантирует, что компилятор не может публиковать ссылку на объект до того, как все его окончательные поля будут правильно инициализированы. Следовательно, мы гарантируем, что либо helperWrapper/wrapper имеют значение null OR helperWrapper/wrapper.value указывают на правильно инициализированный объект. Предполагая, что в финале не было гарантии, дополнительный локальный ничего не сделает: компилятор будет полностью вправе назначить частично созданную переменную как wrapper, так и helperWrapper

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

Ответ 3

Просто используя helperWrapper для нулевых проверок, и оператор return может выйти из строя из-за переупорядочения чтения, разрешенного в модели памяти Java.

Вот пример сценария:

  • Первый тест helperWrapper == null (racy read) оценивается как false, т.е. helperWrapper не является нулевым.
  • Последняя строка, return helperWrapper.value (чтение расизма) приводит к исключению NullPointerException, то есть helperWrapper имеет значение null

Как это случилось? Модель памяти Java позволяет переупорядочивать эти два считываемых чтения, поскольку перед чтением не было никакого барьера, т.е. Отношения "произойдет раньше". (См. Пример String.hashCode)

Обратите внимание, что перед тем, как читать helperWrapper.value, вы должны неявно прочитать ссылку helperWrapper. Таким образом, гарантии, предоставляемые семантикой final, что helperWrapper полностью инстанцируются, не применяются, поскольку они только применяются, когда helperWrapper не равен null.

Ответ 4

Я понимаю, что причина wrapper заключается в том, что чтение неизменяемого объекта (все поля являются окончательными) является атомной операцией.

Если wrapper равно null, то он еще не является неизменным объектом, и мы должны попасть в блок synchronized.

Если wrapper не-null, то мы гарантированно имеем полностью построенный объект value, поэтому мы можем его вернуть.

В вашем коде проверка null может быть выполнена без фактического чтения ссылочного объекта и, таким образом, не запускает атомарность операции. То есть операция могла инициализироваться helperWrapper при подготовке к передаче результата new Helper() конструктору, но конструктор еще не был вызван.

Теперь я бы предположил, что следующий код в вашем примере будет читать return helperWrapper.value;, который должен запускать чтение атомарного ссылки, гарантируя завершение конструктора, но вполне возможно ( "семантика некоторых языков программирования" ), что компилятор позволил оптимизировать это, чтобы не выполнять атомное чтение, и, таким образом, он возвратил бы неполностью инициализированный объект value при правильных обстоятельствах.

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

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