Поведение чтения изменчивой переменной

#java #concurrency #lazy-loading #volatile #vavr

Вопрос:

Я столкнулся со следующим кодом при чтении Lazy исходного кода Vavr:

 private transient volatile Supplier<? extends T> supplier;
private T value; // will behave as a volatile in reality, because a supplier volatile read will update all fields (see https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#volatile)
 
 public T get() {
    return (supplier == null) ? value : computeValue();
}

private synchronized T computeValue() {
    final Supplier<? extends T> s = supplier;
    if (s != null) {
        value = s.get();
        supplier = null;
    }
    return value;
}
 

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

 private synchronized T computeValue() {
    final Supplier<? extends T> s = supplier;
    if (s != null) {
        // value = s.get(); suppose supplier = () -> new AnyClass(x, y , z)
        temp = calloc(sizeof(SomeClass));
        temp.<init>(x, y, z);
        value = temp; //this can be reordered with line above
        supplier = null;
    }
    return value;
}
 

К сожалению, рядом с value полем есть комментарий:

 private transient volatile Supplier<? extends T> supplier;
private T value; // will behave as a volatile in reality, because a supplier volatile read will update all fields (see https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#volatile)
 

Насколько я знаю, изменчивое чтение «обновит» значения переменных, которые считываются после изменчивого чтения. Другими словами — это истощает очередь аннулирования кэша (если мы говорим о протоколе когерентности MESI). Кроме того, это предотвращает загрузку/чтение, которые происходят после того, как изменчивое чтение будет переупорядочено перед ним. Тем не менее, это не дает никакой гарантии, что инструкции по созданию объекта (вызов get() метода поставщика) не будут переупорядочены.

Мой вопрос в том, почему этот код считается потокобезопасным?

Очевидно, что я не нашел ничего интересного в источнике, размещенном в комментарии.

Ответ №1:

Не говорите о кэшах, когда речь заходит о модели памяти Java. Что имеет значение, так это то, что формальные отношения случаются до отношений.

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

Действительно интересный метод заключается в

 public T get() {
    return (supplier == null) ? value : computeValue();
}
 

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

Затем, когда supplier считывается как null , это может быть только результатом записи, выполненной первым выполняемым потоком computeValue() , что устанавливает связь «происходит до» между записями, выполненными потоком до назначения null supplier , и чтениями, выполняемыми этим потоком после чтения null supplier . Таким образом, он будет воспринимать полностью инициализированное состояние объекта, на который ссылается value .

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