Подтвердить что ты не робот

Конструкторы и переупорядочение команд

Я просто наткнулся на статью, которая делает заявку, о которой я никогда не слышал, и не могу найти нигде. Утверждение состоит в том, что с точки зрения другого потока назначение значения, возвращаемого конструктором, может быть переупорядочено относительно инструкций внутри конструктора. Другими словами, утверждение состоит в том, что в приведенном ниже коде другой поток мог читать ненулевое значение a, в котором значение x не было установлено.

class MyInt {
   private int x;

   public MyInt(int value) {
      x = value;
   }

   public int getValue() {
      return x;
   }
}

MyInt a = new MyInt(42);

Это правда?

Edit:

Я думаю, что это гарантировало, что с точки зрения потока, выполняющего MyInt a = new MyInt(42), присваивание x имеет отношение до отношения с назначением a. Но оба эти значения могут быть кэшированы в регистрах, и они не могут быть сброшены в основную память в том же порядке, в котором они были изначально написаны. Без барьера памяти другой поток может поэтому прочитать значение a до того, как будет записано значение x. Правильно?

Итак, основываясь на axtavt и последующих комментариях, соответствуют ли эти оценки безопасности потоков?

// thread-safe
class Foo() {
   final int[] x;

   public Foo() {
      int[] tmp = new int[1];
      tmp[0] = 42;
      x = tmp; // memory barrier here
   }
}

// not thread-safe
class Bar() {
   final int[] x = new int[1]; // memory barrier here

   public Bar() {
      x[0] = 42; // assignment may not be seen by other threads
   }
}

Если это правильно... ничего себе, это действительно тонкое.

4b9b3361

Ответ 1

Процитированная вами статья является концептуально правильной. Это несколько неточно в его терминологии и использовании, как и ваш вопрос, и это приводит к потенциальной недопониманию и недоразумениям. Может показаться, что я занимаюсь терминологией здесь, но модель памяти Java очень тонкая, и если терминология не является точной, то одно понимание будет страдать.

Я вычитаю баллы из вашего вопроса (и из комментариев) и даю ответы на них.

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

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

Я думаю, что это гарантировало, что с точки зрения потока, выполняющего MyInt a = new MyInt(42), присваивание x имеет взаимосвязь между событиями с назначением a.

Опять же, почти. Верно, что в программном порядке назначение x происходит до назначения на a. Однако before-before является глобальным свойством, которое применяется ко всем потокам, поэтому не имеет смысла говорить о случившемся раньше, чем в отношении конкретного потока.

Но оба эти значения могут быть кэшированы в регистрах, и они не могут быть сброшены в основную память в том же порядке, в котором они были изначально написаны. Без барьера памяти другой поток может поэтому прочитать значение a до того, как будет записано значение x.

Опять же, почти. Значения можно кэшировать в регистрах, но части аппаратного обеспечения памяти, такие как кэш-память или буфер записи, также могут привести к переупорядочению. Аппаратное обеспечение может использовать различные механизмы для изменения порядка, например, для очистки кеша или барьеров памяти (которые обычно не вызывают промывки, а просто предотвращают определенные переупорядочивания). Трудность думать об этом с точки зрения аппаратного обеспечения заключается в том, что реальные системы довольно сложны и имеют разные формы поведения. Например, у большинства ЦП есть несколько различных вариантов барьеров памяти. Если вы хотите рассуждать о JMM, вам следует подумать с точки зрения элементов модели: операции с памятью и синхронизация, которые ограничивают переупорядочивание, устанавливая отношения "когда-либо".

Итак, чтобы пересмотреть этот пример с точки зрения JMM, мы увидим запись в поле x и запись в поле a в программном порядке. В этой программе нет ничего, что ограничивает переупорядочивание, т.е. Никакой синхронизации, никаких операций с летуацами, не пишет в конечные поля. Не происходит - до того, как отношения между ними будут записаны, и поэтому их можно будет переупорядочить.

Существует несколько способов предотвращения этих переупорядочений.

Один из способов - сделать x final. Это работает, потому что JMM говорит, что записывает в конечные поля до того, как возвращается конструктор, перед операциями, которые возникают после возвращения конструктора. Поскольку a записывается после возвращения конструктора, выполняется инициализация конечного поля x - до записи в a, и переупорядочение не допускается.

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

class OtherObj {
    MyInt a;
    synchronized void set() {
        a = new MyInt(42);
    }
    synchronized int get() {
        return (a != null) ? a.getValue() : -1;
    }
}

Разблокировка в конце вызова set() происходит после записи в поля x и a. Если другой поток вызывает get(), он принимает блокировку в начале вызова. Это устанавливает связь между релизом блокировки в конце set() и фиксацией блокировки в начале get(). Это означает, что записи в x и a не могут быть переупорядочены после начала вызова get(). Таким образом, поток читателя будет видеть допустимые значения для a и x и никогда не сможет найти ненулевой a и неинициализированный x.

Конечно, если поток читателя называет get() раньше, он может видеть a как null, но здесь нет проблемы с моделью памяти.

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

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

И да, это тонко.:-) Но проблемы на самом деле возникают, если вы пытаетесь быть умными, например, избегать использования синхронизации или изменчивых переменных. Большую часть времени это не стоит. Если вы придерживаетесь практики "безопасной публикации", в том числе не утечка this во время вызова конструктора и сохранение ссылок на построенные объекты с использованием синхронизации (например, мой пример OtherObj выше), все будет работать точно так, как вы ожидаете от них.

Литература:

  • Goetz, Java Concurrency На практике, глава 3, Совместное использование объектов. Это включает в себя обсуждение памяти и безопасной публикации.
  • Manson/Goetz, Часто задаваемые вопросы о модели памяти Java. http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html. Несколько старый, но есть хорошие примеры.
  • Shipilev, Java Memory Model Pragmatics. http://shipilev.net/blog/2014/jmm-pragmatics/. Слайд-презентация и расшифровка беседы, предоставленной одним из гуру производительности Oracle. Больше, чем вы хотели узнать о JMM, с некоторыми указателями на возможные изменения JMM в будущих версиях Java.
  • Исходный код OpenJDK 8 String.java. http://hg.openjdk.java.net/jdk8/jdk8/jdk/file/tip/src/share/classes/java/lang/String.java

Ответ 2

В смысле модели памяти Java - да. In не означает, что вы будете наблюдать это на практике, однако.

Посмотрите на это со следующего угла: оптимизация, которая может привести к видимому переупорядочению, может произойти не только в компиляторе, но и в CPU. Но ЦП ничего не знает об объектах и ​​их конструкторах, поскольку для процессора это всего лишь пара назначений, которые могут быть переупорядочены, если модель памяти ЦП позволяет это.

Конечно, компилятор и JVM могут дать указание процессору не переупорядочивать эти назначения, помещая барьеры памяти в сгенерированный код, но для этого все объекты разрушат производительность процессоров, которые могут в значительной степени полагаться на такую ​​агрессивную оптимизацию. Поэтому Java Memory Model не предоставляет никаких особых гарантий для этого случая.

Это приводит, например, к известному недостатку в Двойной проверке реализации однопользовательской блокировки в модели памяти Java.

Ответ 3

Иными словами, утверждение состоит в том, что в приведенном ниже коде другой поток мог читать ненулевое значение a, в котором значение x не было установлено.

Короткий ответ - да.

Длинный ответ: точка, которая подкрепляет другой поток, читающий ненулевой a со значением x, который не был установлен, - это не строгое перенаправление команд, а процессор, кэширующий значения в его регистры (и кеши L1), а не чтение этих значений из основной памяти. Это может косвенно подразумевать переупорядочение, но это необязательно.

В то время как кэширование значений в регистрах CPU помогает ускорить обработку, в нем возникает проблема видимости значений между различными потоками, запущенными на разных ЦП. Если значения всегда читаются из основной области программы, все потоки будут последовательно видеть одно и то же значение (потому что - это одна копия этого значения). В вашем примере кода, если значение поля члена x кэшируется в регистр CPU1, к которому обращается поток-1, а другой поток Thread-2, запущенный на CPU-2, теперь считывает это значение из основной памяти и обновлений его значение в кэше в CPU-1 (обработано Thread-1) теперь недействительно с точки зрения программы, но сама спецификация Java позволяет виртуальным машинам рассматривать это как допустимый сценарий.