Спецификация Java 11: 17.4. Модель памяти

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

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

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

Пример. Неправильно синхронизированные программы могут демонстрировать неожиданное поведение

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

Рассмотрим, например, трассировку примера программы, показанную в таблице ниже. Эта программа использует локальные переменные r1 и r2 и общие переменные A и B. Первоначально A == B == 0.

Таблица - неожиданные результаты, вызванные переупорядочением операторов - исходный код

Поток 1 Поток 2
1: r2 = A; 3: r1 = B;
2: B = 1; 4: A = 2;

Может показаться, что результат r2 == 2 и r1 == 1 невозможен. Интуитивно понятно, что сначала при выполнении должна идти либо инструкция 1, либо инструкция 3. Если команда 1 идет первой, она не должна видеть запись в команде 4. Если команда 3 идет первой, она не должна видеть запись в команде 2.

Если бы какое-то выполнение демонстрировало такое поведение, то мы бы знали, что инструкция 4 предшествовала инструкции 1, которая предшествовала инструкции 2, которая предшествовала инструкции 3, которая предшествовала инструкции 4. Это, на первый взгляд, абсурд.

Однако компиляторам разрешено переупорядочивать инструкции в любом потоке, если это не влияет на выполнение этого потока изолированно. Если команда 1 переупорядочена с помощью инструкции 2, как показано в следующей таблице, тогда легко увидеть, как может произойти результат r2 == 2 и r1 == 1.

Таблица - неожиданные результаты, вызванные переупорядочением операторов - допустимое преобразование компилятора

Поток 1 Поток 2
B = 1; r1 = B;
r2 = A; A = 2;

Некоторым программистам такое поведение может показаться "сломанным". Однако следует отметить, что этот код неправильно синхронизирован:

  • есть запись в одном потоке,
  • чтение той же переменной другим потоком,
  • а запись и чтение не упорядочиваются синхронизацией.

Эта ситуация является примером гонки данных (§17.4.5). Когда код содержит гонку данных, часто возможны противоречивые результаты.

Несколько механизмов могут производить переупорядочивание, указанное в предыдущей таблице. Компилятор Just-In-Time (JIT) в реализации виртуальной машины Java может переупорядочить код или это может выполнять процессор. Вдобавок иерархия памяти в архитектуре, на которой выполняется реализация виртуальной машины Java, может создавать впечатление, будто код переупорядочивается. В этой главе спецификации будем ссылаться на все, что может изменить порядок кода, как на компилятор.

Другой пример неожиданных результатов можно увидеть в следующей таблице. Изначально p == q и p.x == 0. Эта программа тоже неправильно синхронизируется; она записывает в разделяемую память без какого-либо упорядочивания между этими записями.

Таблица - неожиданные результаты, вызванные форвардной заменой

Поток 1 Поток 2
r1 = p; r6 = p;
r2 = r1.x; r6.x = 3;
r3 = q;
r4 = r3.x;
r5 = r1.x;

Одна из распространенных оптимизаций компилятора заключается в том, чтобы значение, прочитанное для r2, повторно использовалось для r5: они оба считывают r1.x без промежуточной записи. Эта ситуация показана в следующей таблице.

Таблица - неожиданные результаты, вызванные форвардной заменой

Поток 1 Поток 2
r1 = p; r6 = p;
r2 = r1.x; r6.x = 3;
r3 = q;
r4 = r3.x;
r5 = r2;

Теперь рассмотрим случай, когда присвоение r6.x в потоке 2 происходит между первым чтением r1.x и чтением r3.x в потоке 1. Если компилятор решает повторно использовать значение r2 для r5, тогда r2 и r5 будет иметь значение 0, а r4 будет иметь значение 3. С точки зрения программиста, значение, сохраненное в p.x, изменилось с 0 на 3, а затем изменилось обратно.

Модель памяти определяет, какие значения могут быть прочитаны в каждой точке программы. Действия каждого изолированного потока должны вести себя в соответствии с семантикой этого потока, за исключением того, что значения, видимые при каждом чтении, определяются моделью памяти. Когда мы говорим об этом, мы говорим, что программа подчиняется семантике внутри потока. Внутрипоточная семантика - это семантика однопоточных программ, которая позволяет полностью прогнозировать поведение потока на основе значений, видимых при чтении в потоке. Чтобы определить, допустимы ли действия потока t при выполнении, мы просто оцениваем реализацию потока t, как это было бы выполнено в однопоточном контексте.

Каждый раз, когда оценка потока t генерирует межпотоковое действие, оно должно соответствовать межпотоковому действию a для t, которое следует далее в порядке выполнения программы. Если a - это чтение, то дальнейшая оценка t использует значение, которое видит a, как определено моделью памяти.

В этом разделе представлена ​​спецификация модели памяти языка программирования Java, за исключением вопросов, касающихся полей final, которые описаны в §17.5.

Описанная здесь модель памяти принципиально не основана на объектно-ориентированной природе языка программирования Java. Для краткости и простоты в наших примерах мы часто показываем фрагменты кода без определений классов или методов или явного разыменования. Большинство примеров состоят из двух или более потоков, содержащих операторы с доступом к локальным переменным, общим глобальным переменным или полям экземпляра объекта. Обычно мы используем имена переменных, такие как r1 или r2, для обозначения переменных, локальных для метода или потока. Такие переменные недоступны для других потоков.


Читайте также:


Комментарии

Популярные сообщения из этого блога

Методы класса Object в Java

Как получить текущий timestamp в Java

Основные опции JVM для повышения производительности и отладки