Статьи

Многопоточность и модель памяти Java

На Симпозиуме по программному обеспечению в Новой Англии я присутствовал на сессии Брайана Гетца под названием «Модель памяти Java». Когда я увидел фразу «модель памяти» в названии, я подумал, что речь идет о сборке мусора, распределении памяти и типах памяти. Вместо этого речь идет о многопоточности. Разница в том, что эта презентация фокусируется на видимости , а не на блокировке или атомарности. Это моя попытка подвести итоги своего выступления.

Важность видимости

Под видимостью здесь понимается память, которую может увидеть исполняющий поток после его записи. Большая проблема заключается в том, что когда поток A пишет что-то до того, как поток B его читает, это не означает, что поток B будет читать правильное значение. Вы можете убедиться, что потоки A и B упорядочены с блокировкой, но вы все равно можете находиться в состоянии глубокого открытия, поскольку память не записывается и не читается по порядку или читается в частично записанном состоянии.

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

while (!asleep) ++sheep;

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

if (!asleep) while (true) ++sleep; 

Результатом является бесконечный цикл. Исправление в этом случае заключается в использовании изменчивой переменной.

Модель памяти Java

Модель памяти описывает, когда действия одного потока гарантированно будут  видны другому. Модель  памяти Java (JMM) — настоящее достижение: ранее модели памяти были специфичны для каждой архитектуры процессора. Модель кроссплатформенной памяти требует гораздо большей переносимости, чем возможность компилировать один и тот же исходный код: вы действительно можете запустить его где угодно. Потребовалось до Java 5 (JSR 133), чтобы получить JMM правильно.

JMM определяет частичный порядок действий программы (чтение / запись, блокировка / разблокировка, запуск / объединение потоков), которые называются  случайными . По сути, если действие X происходит до Y, то результаты X видны Y.  Внутри потока порядок в основном соответствует порядку программы. Это просто. Но  между потоками, если вы не используете синхронизированный или изменчивый , нет никаких гарантий видимости. Что касается видимых результатов, нет гарантии, что поток A увидит их в том порядке, в котором поток B их выполнит. Брайан даже использовал специальную теорию относительности для описания дезориентирующих эффектов относительных взглядов на реальность. Вам нужно синхронизация, чтобы получить гарантии видимости между потоками.

Основные инструменты синхронизации потоков:

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

В дополнение к вышесказанному JMM предлагает гарантию безопасности инициализации для неизменяемых объектов.

Правила

Вот моменты, которые Брайан подчеркнул:

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

Пример: двойная проверка блокировки

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

private Thing instance = null;
public Thing getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null) instance = new Thing();
}
}
return instance;
}


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

Основной мотивацией было избежать синхронизации в общем случае. Если раньше это было дорого, то безусловная синхронизация теперь намного дешевле. Есть еще много советов, чтобы избежать якобы дорогостоящих операций Java, но JVM значительно улучшилась, и многие старые советы по производительности (такие как пул объектов) просто больше не имеют смысла. Остерегайтесь читать давние советы, когда вы Google для Java советы. Помните приведенный выше совет Брайана против преждевременной оптимизации. Тем не менее, он также показал несколько лучших альтернатив для ленивой инициализации.

Некоторые мысли

Этот доклад напомнил мне, что многопоточность на низком уровне — сложная задача. Достаточно сложно, что потребовались годы, чтобы правильно понять JMM. Достаточно сложно, чтобы университетский профессор сказал « не делай этого ». И если вы точно будете следовать правилам Брайана и везде будете использовать примитивы синхронизации, вы можете оказаться уязвимыми для взаимоблокировок потоков (хммм … почему в JConsole есть функция обнаружения взаимоблокировок?).

Основная опасность в многопоточности заключается в разделяемом, изменчивом состоянии. Без общих изменяемых данных потоки могут быть отдельными процессами, и опасность испаряется. Поэтому, хотя замечательно то, что JMM сделала для межплатформенных гарантий видимости, я думаю, что мы бы сделали себе одолжение, если бы постарались минимизировать совместимые изменяемые данные. Часто есть альтернативы более высокого уровня. Например, конструкция Actor Scala полагается на передачу неизменяемых сообщений вместо совместного использования памяти.

С http://chriswongdevblog.blogspot.com/