Ленивая инициализация/запоминание без volatile

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

Это меня очень смущает в сочетании с тем фактом, что раздел о Модель памяти Java в JLS написана не так, чтобы ее было легко понять.

Поэтому не могли бы вы сказать мне, верны ли предположения, которые я сделал в следующем коде, и гарантируется ли его корректная работа?

Он частично основан на коде, представленном в статье Википедии о блокировке с двойной проверкой, однако там автор использовал класс-оболочку (FinalWrapper), но причина этого мне не совсем очевидна. Может быть, для поддержки значений null?

public class Memoized<T> {
    private T value;
    private volatile boolean _volatile;
    private final Supplier<T> supplier;

    public Memoized(Supplier<T> supplier) {
        this.supplier = supplier;
    }

    public T get() {
        /* Apparently have to use local variable here, otherwise return might use older value
         * see https://jeremymanson.blogspot.com/2008/12/benign-data-races-in-java.html
         */
        T tempValue = value;

        if (tempValue == null) {
            // Refresh
            if (_volatile);
            tempValue = value;

            if (tempValue == null) {
                // Entering refreshes, or have to use `if (_volatile)` again?
                synchronized (this) {
                    tempValue = value;

                    if (tempValue == null) {
                        value = tempValue = supplier.get();
                    }

                    /* 
                     * Exit should flush changes
                     * "Flushing" does not actually exists, maybe have to use  
                     * `_volatile = true` instead to establish happens-before?
                     */
                }
            }
        }

        return tempValue;
    }
}

Также я читал, что вызов конструктора можно встроить и переупорядочить, что приведет к ссылке на неинициализированный объект (см. этот комментарий в блоге). Безопасно ли тогда напрямую присваивать результат поставщику или это нужно делать в два этапа?

value = tempValue = supplier.get();

Два шага:

tempValue = supplier.get();
// Reorder barrier, maybe not needed?
if (_volatile);
value = tempValue;

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


person Marcono1234    schedule 08.01.2019    source источник
comment
Ваш volatile бесполезен, так как вы не присваиваете ему значение. Сброс происходит в конце блока synchronized, потому что, согласно JMM, снятие блокировки происходит до последующего получения той же блокировки.   -  person Ivan    schedule 09.01.2019
comment
Я предлагаю прочитать это полностью: shipilev.net/blog/2016/ близкие контакты типа jmm   -  person Aleksey Shipilev    schedule 09.01.2019
comment
Пример блокировки с двойной проверкой с помощью final кратко объясняется здесь, в Stack Overflow (также Алексеем Шипилевым).   -  person Radiodef    schedule 09.01.2019
comment
Ваш volatile бесполезен, так как вы не присваиваете ему значение. @ Иван, я использую его для создания отношений «происходит до», а не для хранения в нем какой-либо ценности.   -  person Marcono1234    schedule 09.01.2019
comment
Происходит до того, как я установил между записью в volatile и последующим чтением. Вы ничего не пишете в эту переменную.   -  person Ivan    schedule 09.01.2019


Ответы (2)


Ваш код не является потокобезопасным, что можно легко показать, удалив все ненужные части:

public class Memoized<T> {
    private T value;
    // irrelevant parts omitted

    public T get() {
        T tempValue = value;

        if (tempValue == null) {
            // irrelevant parts omitted
        }

        return tempValue;
    }
}

Таким образом, value не имеет модификатора volatile, и вы читаете его в методе get() без синхронизации, а если не null, продолжаете использовать его без синхронизации.

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

Тот факт, что вы используете эзотерические конструкции, такие как if (_volatile);, становится неактуальным, поскольку код уже взломан.

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

Обратите внимание, что, поскольку лямбда-выражения попадают в ту же категорию, вы можете использовать их, чтобы упростить пример для вашего варианта использования:

public class Memoized<T> {
    private boolean initialized;
    private Supplier<T> supplier;

    public Memoized(Supplier<T> supplier) {
        this.supplier = () -> {
            synchronized(this) {
                if(!initialized) {
                    T value = supplier.get();
                    this.supplier = () -> value;
                    initialized = true;
                }
            }
            return this.supplier.get();
        };
    }

    public T get() {
        return supplier.get();
    }
}

Здесь supplier.get() внутри Memoized.get() может считать обновленное значение supplier без действия синхронизации, и в этом случае будет считано правильное value, поскольку оно неявно равно final. Если метод считывает устаревшее значение для ссылки supplier, он окажется в блоке synchronized(this), который использует флаг initialized для определения необходимости оценки исходного поставщика.

Поскольку доступ к полю initialized будет осуществляться только внутри блока synchronized(this), оно всегда будет оцениваться как правильное значение. Этот блок будет выполняться не более одного раза для каждого потока, тогда как только первый из них будет оценивать get() для исходного поставщика. После этого каждый поток будет использовать поставщика () -> value, возвращая значение без каких-либо действий по синхронизации.

person Holger    schedule 29.05.2019
comment
И если устаревший supplier читается, а synchronized(this) вводится и выходит, новый supplier будет считываться из-за того, что происходит до гарантий синхронизированного блока, верно? У вас есть источник безопасно публикуемых лямбда-выражений (если возможно, JLS)? - person Marcono1234; 02.06.2019
comment
Знаете ли вы, насколько хорошо этот подход работает по сравнению с другими решениями? Алексей Шипилёв сделал тесты производительности, но это без учета лямбда-выражений. - person Marcono1234; 02.06.2019
comment
Также я собираюсь отметить этот ответ как принятый, потому что он указывает, что не так с моим решением, хотя Питер Лоури предоставил отличные альтернативные решения. - person Marcono1234; 02.06.2019
comment
Да, synchronized(this) обеспечивает необходимые отношения «происходит до». Безопасность захваченных значений для лямбда-выражений не упоминается явно, но может быть получена из требования, чтобы локальные переменные были final или фактически окончательными и определенно назначенными перед лямбда-выражением (согласно JLS§15.27.2.). На практике эти значения копируются в final полей сгенерированного класса (и любой гипотетический будущий механизм, не нуждающийся в классах, должен оставаться совместимым с этим). Ожидается, что производительность будет между Final Wrapper и подходом Holder. - person Holger; 03.06.2019

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

enum LazyX {
   ;
   static volatile Supplier<X> xSupplier; // set somewhere before use

   static class Holder {
       static final X x = xSupplier.get();
   }

   public static X get() {
       return Holder.x;
   }
}

Если вы знаете Поставщика, это становится проще

enum LazyXpensive {
   ;

   // called only once in a thread safe manner
   static final Xpensive x = new Xpensive();

   // after class initialisation, this is a non volatile read
   public static Xpensive get() {
       return x;
   }
}

Вы можете избежать изменения поля, используя Unsafe

import sun.misc.Unsafe;

import java.lang.reflect.Field;
import java.util.function.Supplier;

public class LazyHolder<T> {
    static final Unsafe unsafe = getUnsafe();
    static final long valueOffset = getValueOffset();

    Supplier<T> supplier;
    T value;

    public T get() {
        T value = this.value;
        if (value != null) return value;

        return getOrCreate();
    }

    private T getOrCreate() {
        T value;
        value = (T) unsafe.getObjectVolatile(this, valueOffset);
        if (value != null) return value;

        synchronized (this) {
            value = this.value;
            if (value != null) return value;
            this.value = supplier.get();
            supplier = null;
            return this.value;
        }
    }


    public static Unsafe getUnsafe() {
        try {
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            return (Unsafe) theUnsafe.get(null);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new AssertionError(e);
        }
    }

    private static long getValueOffset() {
        try {
            return unsafe.objectFieldOffset(LazyHolder.class.getDeclaredField("value"));
        } catch (NoSuchFieldException e) {
            throw new AssertionError(e);
        }
    }
}

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

person Peter Lawrey    schedule 08.01.2019
comment
Спасибо за ваш ответ, но я не хочу создавать синглтоны, я хочу лениво инициализировать произвольные значения. - person Marcono1234; 09.01.2019
comment
Я также не хочу использовать Unsafe, но спасибо за предоставленное решение, я не знал об этом варианте. - person Marcono1234; 05.02.2019