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

Пример кода ниже взят из JLS 17.5 "final Field Semantics":

class FinalFieldExample { 
    final int x;
    int y; 
    static FinalFieldExample f;

    public FinalFieldExample() {
        x = 3; 
        y = 4; 
    } 

    static void writer() {
        f = new FinalFieldExample();
    } 

    static void reader() {
        if (f != null) {
            int i = f.x;  // guaranteed to see 3  
            int j = f.y;  // could see 0
        } 
    } 
}

Поскольку экземпляр FinalFieldExample публикуется через гонку данных, возможно ли, что f != null проверка оценивается успешно, но последующие f.x разыменование видит f как null?

Другими словами, можно ли получить NullPointerException в строке с комментарием "гарантированно увидим 3"?

2 ответа

Решение

Хорошо, вот мой собственный взгляд на это, основанный на довольно подробном разговоре (на русском языке) об окончательной семантике, данном Владимиром Ситниковым, и последующем пересмотре JLS 17.5.1.

Конечная семантика поля

В спецификации указано:

Даны запись w, замораживание f, действие a (которое не является чтением последнего поля), чтение r1 последнего поля, замороженного с помощью f, и чтение r2 такое, что hb(w, f), hb(f, a), mc(a, r1) и разыменования (r1, r2), то при определении того, какие значения могут видеть r2, мы рассматриваем hb(w, r2).

Другими словами, мы гарантированно увидим запись в финальное поле, если будет построена следующая цепочка отношений:

hb(w, f) -> hb(f, a) -> mc(a, r1) -> dereferences(r1, r2)


1. hb(ш, ж)

w - запись в последнее поле:x = 3

f - действие "замораживания" (выходFinalFieldExample конструктор):

Пусть o будет объектом, а c будет конструктором для o, в котором записано последнее поле f. Замораживание последнего поля f из o происходит, когда c выходит, как обычно, так и внезапно.

Поскольку запись поля происходит до завершения конструктора в порядке выполнения программы, мы можем предположить, что hb(w, f):

Если x и y являются действиями одного и того же потока, а x стоит перед y в программном порядке, тогда hb(x, y)

2. hb(е, а)

Определение дано в описании действительно расплывчато ("действие, которое не является чтение окончательного поля") Можно предположить, что публикует ссылку на объект (

f = new FinalFieldExample()), поскольку это предположение не противоречит спецификации (это действие, а не чтение последнего поля).

Поскольку завершающий конструктор выполняется до записи ссылки в программном порядке, эти две операции упорядочиваются отношением происходит до:hb(f, a)

3. mc(a, r1)

В нашем случае r1 - это "чтение последнего поля, замороженного f" (f.x)

И тут начинается самое интересное. mc (Цепочка памяти) - один из двух дополнительных частичных порядков, представленных в разделе "Семантика конечных полей":

Есть несколько ограничений на порядок цепочки памяти:

  • Если r - это чтение, которое видит запись w, тогда это должно быть так, что mc(w, r).
  • Если r и a - действия, такие, что разыменование (r, a), то должно быть так, что mc(r, a).
  • Если w - это запись адреса объекта o потоком t, который не инициализировал o, тогда должно существовать некоторое чтение r потоком t, которое видит адрес o, такое, что mc(r, w).

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

Ниже приведена часть, которая на самом деле объясняет, почему можно получить NPE:

  • обратите внимание на жирную часть в спецификации: mc(a, r1)отношение существует, только если чтение поля видит запись в общую ссылку
  • f != null а также f.x две различные операции чтения с точки зрения JMM
  • в спецификации нет ничего, что говорило бы, что mc отношения транзитивны по отношению к программному порядку или случаются до
  • поэтому если f != null видит запись, выполненную другим потоком, нет никаких гарантий, что f.x тоже видит это

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

Для нашего простого примера достаточно сказать, что JLS утверждает, что "порядок разыменования рефлексивен, и r1 может быть таким же, как r2" (что в точности соответствует нашему случаю).

Безопасный способ борьбы с небезопасной публикацией

Ниже приведена модифицированная версия кода, которая гарантированно не генерирует NPE:

class FinalFieldExample { 
    final int x;
    int y; 
    static FinalFieldExample f;

    public FinalFieldExample() {
        x = 3; 
        y = 4; 
    } 

    static void writer() {
        f = new FinalFieldExample();
    } 

    static void reader() {
        FinalFieldExample local = f;
        if (local != null) {
            int i = local.x;  // guaranteed to see 3  
            int j = local.y;  // could see 0
        } 
    } 
}

Важным отличием здесь является чтение общей ссылки в локальную переменную. Как заявляет JLS:

Локальные переменные... никогда не используются совместно между потоками и не зависят от модели памяти.

Следовательно, с точки зрения JMM из общего состояния выполняется только одно чтение.

Если при этом чтении происходит запись, выполненная другим потоком, это будет означать, что две операции связаны цепочкой памяти (mc) отношения. Более того,local = f а также i = local.x связаны отношениями цепочки разыменования, что дает нам всю цепочку, упомянутую в начале:

hb(w, f) -> hb(f, a) -> mc(a, r1) -> dereferences(r1, r2)

Ваш анализ прекрасен (1+), если бы я мог проголосовать дважды - я бы. Вот еще одна ссылка на одной и той же проблемы с "независимо гласит: " вот, к примеру.

Я также попытался подойти к этой проблеме в другом ответе.

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

static void reader() {

    FinalFieldExample instance1 = f;

    if (instance1 != null) {

        FinalFieldExample instance2 = f;
        int i = instance2.x;    

        FinalFieldExample instance3 = f;
        int j = instance3.y;  
    } 
}

И компилятор может теперь сделать некоторые хотят читает (переместить те читает перед темif statement):

static void reader() {

    FinalFieldExample instance1 = f;
    FinalFieldExample instance2 = f;
    FinalFieldExample instance3 = f;

    if (instance1 != null) {
        int i = instance2.x;    
        int j = instance3.y;  
    } 
}

Эти чтения могут быть дополнительно переупорядочены между ними:

static void reader() {

    FinalFieldExample instance2 = f;
    FinalFieldExample instance1 = f;
    FinalFieldExample instance3 = f;

    if (instance1 != null) {
        int i = instance2.x;    
        int j = instance3.y;  
    } 
}

Отсюда все должно быть тривиально: ThreadA читает FinalFieldExample instance2 = f; быть null, прежде чем он выполнит следующее чтение:FinalFieldExample instance1 = f; некоторые ThreadB звонки writer (как таковой f != null) и часть:

 FinalFieldExample instance1 = f;

решено non-null.

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