"Эффективная Java" головоломка: почему в этом параллельном коде требуется волатильность?

Я работаю по пункту 71, "Использовать ленивую инициализацию разумно", "Эффективная Java" (второе издание). Это предполагает использование идиомы двойной проверки для ленивой инициализации полей экземпляра с помощью этого кода (стр. 283):

private volatile FieldType field;
FieldType getField() {
    FieldType result = field;
    if (result == null) {  //First check (no locking)
        synchronized(this) {
            result = field;
            if (result == null)  //Second check (with locking)
                 field = result = computeFieldValue();
        }
     }
     return result;
}

Итак, у меня есть несколько вопросов:

  • Зачем нужен изменчивый модификатор на field, если инициализация происходит в синхронизированном блоке? Книга предлагает этот вспомогательный текст: "Поскольку нет блокировки, если поле уже инициализировано, важно, чтобы поле было объявлено изменчивым". Следовательно, так ли это, что после инициализации поля volatile является единственной гарантией множественных согласованных потоков на field, учитывая отсутствие другой синхронизации? Если да, почему бы не синхронизировать getField(), или это так, что приведенный выше код обеспечивает лучшую производительность?

  • В тексте предлагается, чтобы не требуемая локальная переменная result использовалась для "обеспечения того, чтобы field читается только один раз в обычном случае, когда он уже инициализирован", тем самым повышая производительность. Если result был удален, как бы field было прочитано несколько раз в обычном случае, когда он уже был инициализирован?

Ответы

Ответ 1

Почему необходим изменчивый модификатор в поле, если инициализация происходит в синхронизированном блоке?

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

Это означает, что thread-1 может инициализировать field внутри synchronized, но этот поток-2 может видеть, что объект не полностью инициализирован. Любые незавершенные поля не должны быть инициализированы до того, как объект был назначен field. Ключевое слово volatile гарантирует, что field будет полностью инициализирован до его доступа.

Это пример знаменитой ошибка двойной проверки блокировки.

Если результат был удален, как поле будет читаться несколько раз в обычном случае, когда он уже был инициализирован?

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

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

Ссылка на объект перед конструктором объекта завершена

Ответ 2

Это довольно сложно, но теперь это связано с тем, что компилятор может изменить ситуацию.
В принципе шаблон Double Checked Locking не работает в Java, если только переменная volatile.

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

Посмотрите this другой вопрос SO по этой теме.

Ответ 3

Хорошие вопросы.

Почему необходим изменчивый модификатор в поле, если инициализация происходит в синхронизированном блоке?

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

public class FieldType{
   Object obj = new Object();
   Object obj2 = new Object();
   public Object getObject(){return obj;}
   public Object getObject2(){return obj2;}
}

Возможно, что getField() возвращает непустой экземпляр, но методы экземпляра getObj() и getObj2() могут возвращать нулевые значения. Это связано с тем, что без синхронизации записи в эти поля могут участвовать в гонке с целью создания объекта.

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

Если результат был удален, как поле будет читаться несколько раз в обычном случае, когда он уже был инициализирован?

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