Как решить декларацию "Двойная проверка заблокирована" в Java?

Я хочу реализовать ленивую инициализацию для многопоточности в Java.
У меня есть код такого рода:

class Foo {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            Helper h;
            synchronized(this) {
                h = helper;
                if (h == null) 
                    synchronized (this) {
                        h = new Helper();
                    } // release inner synchronization lock
                helper = h;
            } 
        }    
        return helper;
    }
    // other functions and members...
}

И я получаю декларацию "Двойная проверка заблокирована".
Как я могу решить это?

9 ответов

Решение

Вот идиома, рекомендованная в пункте 71: Используйте ленивую инициализацию разумно для эффективной Java:

Если вам нужно использовать ленивую инициализацию для производительности на поле экземпляра, используйте идиому двойной проверки. Эта идиома позволяет избежать затрат на блокировку при доступе к полю после его инициализации (пункт 67). Идея, лежащая в основе этой идиомы, состоит в том, чтобы дважды проверять значение поля (отсюда и двойную проверку имени): один раз без блокировки, а затем, если поле кажется неинициализированным, второй раз с блокировкой. Только если вторая проверка указывает, что поле не инициализировано, вызов инициализирует поле. Поскольку нет никакой блокировки, если поле уже инициализировано, очень важно, чтобы поле было объявлено volatile (Пункт 66). Вот идиома:

// Double-check idiom for lazy initialization of instance fields
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;
}

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

До выпуска 1.5 идиома двойной проверки не работала надежно, потому что семантика летучего модификатора была недостаточно сильной, чтобы поддерживать его [Pugh01]. Модель памяти, представленная в выпуске 1.5, исправила эту проблему [JLS, 17, Goetz06 16]. Сегодня идиома двойной проверки - это метод выбора для ленивой инициализации поля экземпляра. Хотя вы можете применить идиому двойной проверки и к статическим полям, нет никаких оснований для этого: идиома ленивого держателя инициализации - лучший выбор.

Ссылка

  • Эффективная Java, второе издание
    • Правило 71: Используйте ленивую инициализацию разумно

Вот шаблон для правильной двойной проверки блокировки.

class Foo {

  private volatile HeavyWeight lazy;

  HeavyWeight getLazy() {
    HeavyWeight tmp = lazy; /* Minimize slow accesses to `volatile` member. */
    if (tmp == null) {
      synchronized (this) {
        tmp = lazy;
        if (tmp == null) 
          lazy = tmp = createHeavyWeightObject();
      }
    }
    return tmp;
  }

}

Для синглтона существует более читаемая идиома для ленивой инициализации.

class Singleton {
  private static class Ref {
    static final Singleton instance = new Singleton();
  }
  public static Singleton get() {
    return Ref.instance;
  }
}

DCL с использованием ThreadLocal Брайан Гетц @ JavaWorld

что сломано в DCL?

DCL полагается на несинхронизированное использование поля ресурса. Это кажется безвредным, но это не так. Чтобы понять почему, представьте, что поток A находится внутри синхронизированного блока, выполняя инструкцию resource = new Resource(); в то время как поток B только вводит getResource(). Рассмотрим влияние на память этой инициализации. Память для нового объекта Resource будет выделена; будет вызван конструктор для Resource, инициализирующий поля-члены нового объекта; и полевому ресурсу SomeClass будет назначена ссылка на вновь созданный объект.

class SomeClass {
  private Resource resource = null;
  public Resource getResource() {
    if (resource == null) {
      synchronized {
        if (resource == null) 
          resource = new Resource();
      }
    }
    return resource;
  }
}

Однако, поскольку поток B не выполняется внутри синхронизированного блока, он может видеть эти операции с памятью в другом порядке, чем тот, который выполняет один поток. Это может быть случай, когда B видит эти события в следующем порядке (и компилятор также может свободно переупорядочивать такие инструкции): выделять память, назначать ссылку на ресурс, вызывать конструктор. Предположим, что поток B появляется после того, как память была выделена и поле ресурса установлено, но до вызова конструктора. Он видит, что ресурс не равен нулю, пропускает синхронизированный блок и возвращает ссылку на частично созданный ресурс! Излишне говорить, что результат не является ни ожидаемым, ни желаемым.

Может ли ThreadLocal помочь исправить DCL?

Мы можем использовать ThreadLocal для достижения явной цели идиомы DCL - ленивая инициализация без синхронизации по общему пути кода. Рассмотрим эту (поточно-ориентированную) версию DCL:

Листинг 2. DCL с использованием ThreadLocal

class ThreadLocalDCL {
  private static ThreadLocal initHolder = new ThreadLocal();
  private static Resource resource = null;
  public Resource getResource() {
    if (initHolder.get() == null) {
      synchronized {
        if (resource == null) 
          resource = new Resource();
        initHolder.set(Boolean.TRUE);
      }
    }
    return resource;
  }
}

Я думаю; здесь каждый поток однажды входит в блок SYNC для обновления значения threadLocal; тогда не будет. Таким образом, ThreadLocal DCL обеспечит вход потока только один раз в блок SYNC.

Что на самом деле означает синхронизированный?

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

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

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

Единственный способ сделать правильную двойную проверку блокировки в Java - это использовать "volatile" объявления для рассматриваемой переменной. Хотя это решение является правильным, обратите внимание, что "volatile" означает, что строки кэша сбрасываются при каждом доступе. Так как "синхронизированный" сбрасывает их в конце блока, он может на самом деле не быть более эффективным (или даже менее эффективным). Я бы порекомендовал просто не использовать двойную проверку блокировки, если вы не профилировали свой код и не обнаружили проблему с производительностью в этой области.

Определите переменную, которая должна быть перепроверена volatile midifier

Вам не нужно h переменная. Вот пример отсюда

class Foo {
    private volatile Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized(this) {
                if (helper == null)
                    helper = new Helper();
            }
        }
        return helper;
    }
}

Как отметили некоторые, вам определенно нужно volatile ключевое слово, чтобы оно работало правильно, если не объявлены все члены объекта final иначе не произойдет безопасной публикации перед просмотром, и вы сможете увидеть значения по умолчанию.

Мы устали от постоянных проблем с людьми, которые ошибаются, поэтому мы написали утилиту LazyReference, которая имеет окончательную семантику и была профилирована и настроена так, чтобы быть максимально быстрой.

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

Заявление, которое нуждается в объяснении:

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

Объяснение:

Поле будет прочитано первый раз в первом операторе if и во второй раз в операторе return. Поле объявляется как volatile, что означает, что оно должно обновляться из памяти при каждом обращении к нему (грубо говоря, для доступа к изменяемым переменным может потребоваться дополнительная обработка), и компилятор не может сохранить его в регистре. При копировании в локальную переменную и последующем использовании в обоих операторах (if и return) оптимизация регистра может быть выполнена JVM.

Что вы имеете в виду, от кого вы получаете декларацию?

Двойная проверка блокировки зафиксирована. проверьте википедию:

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;
   }

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

например, взяв предыдущий пример

    class Foo {
        private Helper helper = null;
        public Helper getHelper() {
            if (helper == null) {
                synchronized(this) {
                    if (helper == null)
                        Helper newHelper = new Helper();
                        helper = newHelper;
                }
            }
            return helper;
        }
     }

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

Другие вопросы по тегам