Java Language
Модель памяти Java
Поиск…
замечания
Модель памяти Java - это раздел JLS, который определяет условия, при которых одному потоку гарантированно видят эффекты записи в памяти, сделанные другим потоком. Соответствующий раздел в последних выпусках - «Модель памяти JLS 17.4» (в Java 8 , Java 7 , Java 6 )
Был капитальный ремонт Java Memory Model в Java 5, который (среди прочего) изменил способ работы volatile
. С тех пор модель памяти практически не изменилась.
Мотивация для модели памяти
Рассмотрим следующий пример:
public class Example {
public int a, b, c, d;
public void doIt() {
a = b + 1;
c = d + 1;
}
}
Если этот класс используется, это однопоточное приложение, то наблюдаемое поведение будет таким, каким вы ожидали. Например:
public class SingleThreaded {
public static void main(String[] args) {
Example eg = new Example();
System.out.println(eg.a + ", " + eg.c);
eg.doIt();
System.out.println(eg.a + ", " + eg.c);
}
}
выведет:
0, 0
1, 1
Что касается «основного» потока , то инструкции в методе main()
метод doIt()
будут выполняться в том порядке, в котором они записаны в исходном коде. Это явное требование спецификации языка Java (JLS).
Теперь рассмотрим тот же класс, который используется в многопоточном приложении.
public class MultiThreaded {
public static void main(String[] args) {
final Example eg = new Example();
new Thread(new Runnable() {
public void run() {
while (true) {
eg.doIt();
}
}
}).start();
while (true) {
System.out.println(eg.a + ", " + eg.c);
}
}
}
Что будет печатать?
На самом деле, согласно JLS, невозможно предсказать, что это будет печатать:
- Вероятно, вы увидите несколько строк
0, 0
для начала. - Тогда вы, вероятно, увидите строки, такие как
N, N
илиN, N + 1
. - Вы можете видеть строки типа
N + 1, N
- В теории вы даже можете увидеть, что линии
0, 0
продолжаются навсегда 1 .
1 - На практике наличие операторов println
может привести к некорректной синхронизации и кэш памяти. Вероятно, это скроет некоторые из эффектов, которые приведут к вышеуказанному поведению.
Итак, как мы можем объяснить это?
Переопределение заданий
Одним из возможных объяснений неожиданных результатов является то, что компилятор JIT изменил порядок присвоений в методе doIt()
. JLS требует , чтобы заявления , как представляется для исполнения в порядке с точки зрения текущего потока. В этом случае ничто в коде метода doIt()
может наблюдать эффект (гипотетического) переупорядочения этих двух утверждений. Это означает, что JIT-компилятор будет разрешен для этого.
Зачем это делать?
На типичном современном оборудовании машинные инструкции выполняются с использованием конвейера команд, который позволяет последовательности инструкций находиться на разных этапах. Некоторые этапы выполнения команд занимают больше времени, чем другие, и операции с памятью имеют тенденцию занимать больше времени. Интеллектуальный компилятор может оптимизировать пропускную способность инструкции по конвейеру, заказывая инструкции, чтобы максимизировать количество перекрытий. Это может привести к тому, что выполняемые части инструкций будут неработоспособны. JLS позволяет это обеспечить, что не влияет на результат вычисления с точки зрения текущей нити .
Эффекты кэшей памяти
Вторым возможным объяснением является эффект кэширования памяти. В классической компьютерной архитектуре каждый процессор имеет небольшой набор регистров и больший объем памяти. Доступ к регистрам намного быстрее, чем доступ к основной памяти. В современных архитектурах есть кэши памяти, которые медленнее регистра, но быстрее, чем основная память.
Компилятор будет использовать это, пытаясь сохранить копии переменных в регистрах или в кэшах памяти. Если переменную не нужно очищать в основной памяти, или ее не нужно читать из памяти, это может привести к значительным преимуществам в производительности, если вы этого не сделаете. В случаях, когда JLS не требует, чтобы операции с памятью были видимыми для другого потока, компилятор Java JIT скорее всего не добавит инструкции «барьер чтения» и «барьер записи», которые заставят читать и записывать основную память. Еще раз, преимущества производительности при этом важны.
Правильная синхронизация
До сих пор мы видели, что JLS позволяет компилятору JIT генерировать код, который делает однопоточный код быстрее, переупорядочивая или избегая операций с памятью. Но что происходит, когда другие потоки могут наблюдать состояние (общих) переменных в основной памяти?
Ответ заключается в том, что другие потоки могут наблюдать переменные состояния, которые выглядят невозможными ... на основе кодового порядка операторов Java. Решением этого является использование соответствующей синхронизации. Три основных подхода:
- Использование примитивных мьютексов и
synchronized
конструкций. - Использование
volatile
переменных. - Использование поддержки параллелизма на более высоком уровне; например, классы в пакетах
java.util.concurrent
.
Но даже при этом важно понять, где необходима синхронизация, и на какие последствия вы можете положиться. Здесь находится модель памяти Java.
Модель памяти
Модель памяти Java - это раздел JLS, который определяет условия, при которых одному потоку гарантированно видят эффекты записи в памяти, сделанные другим потоком. Модель памяти задается с достаточной степенью формальной строгости , и (в результате) требуется подробное и тщательное чтение для понимания. Но основным принципом является то, что некоторые конструкции создают связь «между событиями» перед записью переменной одним потоком и последующее чтение одной и той же переменной другим потоком. Если существует отношение «произойдет до», компилятор JIT обязан сгенерировать код, который гарантирует, что операция чтения увидит значение, записанное записью.
Вооружившись этим, можно рассуждать о когерентности памяти в программе Java и решить, будет ли это предсказуемым и последовательным для всех платформ исполнения.
Происходит до отношений
(Ниже приведена упрощенная версия того, что говорит спецификация языка Java. Для более глубокого понимания вам необходимо прочитать спецификацию.)
Случаи-до отношений являются частью модели памяти, которые позволяют нам понять и понять видимость памяти. Как говорит JLS ( JLS 17.4.5 ):
«Два действия могут быть упорядочены с помощью отношения« произойдет раньше » . Если произойдет одно действие - перед другим, то первое будет видимым и упорядоченным до второго».
Что это значит?
действия
Действия, указанные выше в цитируемой цитате, указаны в JLS 17.4.2 . Существует 5 видов действий, указанных в спецификации:
Чтение: чтение неизменяемой переменной.
Запись: запись нелетучей переменной.
Действия синхронизации:
Volatile read: Чтение изменчивой переменной.
Volatile write: запись изменчивой переменной.
Замок. Блокировка монитора
Разблокировка. Разблокировка монитора.
(Синтетическое) первое и последнее действие потока.
Действия, которые запускают поток или обнаруживают, что поток завершен.
Внешние действия. Действие, которое имеет результат, зависящий от среды, в которой программа.
Действия дивергенции потока. Они моделируют поведение определенных видов бесконечного цикла.
Заказ программы и синхронизация
Эти два порядка ( JLS 17.4.3 и JLS 17.4.4 ) управляют выполнением операторов в Java
Заказ программы описывает порядок выполнения инструкции в одном потоке.
Порядок синхронизации описывает порядок выполнения оператора для двух операторов, связанных синхронизацией:
Действие разблокировки на мониторе синхронизируется со всеми последующими действиями блокировки на этом мониторе.
Синхронизация записи в изменчивую переменную - со всеми последующими чтениями одной и той же переменной любым потоком.
Действие, которое запускает поток (т.
Thread.start()
ВызовThread.start()
), синхронизируется с первым действием вThread.start()
потоке (т.Thread.start()
методаrun()
потока).По умолчанию инициализация полей синхронизируется с первым действием в каждом потоке. (См. JLS для объяснения этого.)
Заключительное действие в потоке синхронизируется с любым действием в другом потоке, который обнаруживает завершение; например, возврат вызова
join()
илиisTerminated()
который возвращаетtrue
.Если один поток прерывает другой поток, вызов прерывания в первом потоке синхронизируется - с точкой, в которой другой поток обнаруживает, что поток был прерван.
Бывает-до заказа
Это упорядочение ( JLS 17.4.5 ) определяет, гарантируется ли запись в памяти для последующего чтения в памяти.
Более конкретно, чтение переменной v
гарантируется наблюдением записи в v
тогда и только тогда, когда write(v)
происходит - перед read(v)
И нет промежуточной записи в v
. Если есть промежуточные записи, то read(v)
может видеть результаты из них, а не предыдущие.
Правила, определяющие порядок до- заказа, следующие:
Happens-Before Rule # 1 - Если x и y - действия одного и того же потока, а x - до y в программном порядке , то x происходит до y.
Happens-Before Rule # 2 - Происходит до конца от конца конструктора объекта до начала финализатора для этого объекта.
Happens-Before Rule # 3 - Если действие x синхронизируется с последующим действием y, то x происходит до y.
Happens-Before Rule # 4 - Если x происходит - до того, как y и y произойдет - до z, то x произойдет - до z.
Кроме того, различные классы в стандартных библиотеках Java указываются как определяющие как -либо отношения. Вы можете интерпретировать это как означающее, что это происходит так или иначе , без необходимости точно знать, как гарантия будет выполнена.
Бывает - прежде чем рассуждения применимы к некоторым примерам
Мы представим несколько примеров, чтобы показать, как применять , прежде чем рассуждать, чтобы проверить, что записи видны для последующих чтений.
Однопоточный код
Как и следовало ожидать, записи всегда видны для последующих чтений в однопоточной программе.
public class SingleThreadExample {
public int a, b;
public int add() {
a = 1; // write(a)
b = 2; // write(b)
return a + b; // read(a) followed by read(b)
}
}
By Happens-Before Правило № 1:
- Действие
write(a)
происходит - перед действиемwrite(b)
. - Действие
write(b)
происходит - перед действиемread(a)
. -
read(a)
происходит - перед действиемread(a)
.
By Happens-Before Правило № 4:
-
write(a)
происходит - передwrite(b)
Иwrite(b)
происходит - передread(a)
ПРОБЛЕМЫwrite(a)
происходит - передread(a)
. -
write(b)
происходит - передread(a)
Иread(a)
происходит - передread(b)
ПРОБЛЕМЫwrite(b)
происходит - передread(b)
.
Подведение итогов:
- Отношение
write(a)
-beforeread(a)
означает, чтоa + b
гарантированно видит правильное значениеa
. - Отношение
write(b)
-beforeread(b)
означает, чтоa + b
гарантированно видит правильное значениеb
.
Поведение «volatile» в примере с 2 потоками
Мы будем использовать следующий примерный код, чтобы изучить некоторые последствия модели памяти для `volatile.
public class VolatileExample {
private volatile int a;
private int b; // NOT volatile
public void update(int first, int second) {
b = first; // write(b)
a = second; // write-volatile(a)
}
public int observe() {
return a + b; // read-volatile(a) followed by read(b)
}
}
Во-первых, рассмотрим следующую последовательность операторов с участием 2 потоков:
- Создается один экземпляр
VolatileExample
; назовите этоve
, -
ve.update(1, 2)
вызывается в одном потоке и -
ve.observe()
вызывается в другом потоке.
By Happens-Before Правило № 1:
- Действие
write(a)
происходит - перед действиемvolatile-write(a)
. -
volatile-read(a)
- перед действиемread(b)
.
«Бывает» - до правила № 2:
- Действие
volatile-write(a)
в первом потоке происходит до выполненияvolatile-read(a)
действия во втором потоке.
By Happens-Before Правило № 4:
- Действие
write(b)
в первом потоке происходит - перед действиемread(b)
во втором потоке.
Другими словами, для этой конкретной последовательности мы гарантируем, что второй поток увидит обновление для нелетучей переменной b
сделанной первым потоком. Тем не менее, также должно быть ясно, что если назначения в методе update
были наоборот, или метод observe()
прочитал переменную b
перед a
, то цепочка, которая произошла раньше , будет нарушена. Цепь также будет разбита, если во втором потоке volatile-read(a)
будет следовать volatile-write(a)
в первом потоке.
Когда цепь сломана, нет гарантии, что observe()
увидит правильное значение b
.
Летучие с тремя нитями
Предположим, что мы добавим третий поток в предыдущий пример:
- Создается один экземпляр
VolatileExample
; назовите этоve
, - Два потока требуют
update
:-
ve.update(1, 2)
вызывается в одном потоке, -
ve.update(3, 4)
вызывается во втором потоке,
-
-
ve.observe()
впоследствии вызывается в третьем потоке.
Чтобы полностью проанализировать это, нам нужно рассмотреть все возможные перемежения операторов в первом и втором потоках. Вместо этого мы рассмотрим только два из них.
Сценарий №1 - предположим, что update(1, 2)
предшествует update(3,4)
мы получаем следующую последовательность:
write(b, 1), write-volatile(a, 2) // first thread
write(b, 3), write-volatile(a, 4) // second thread
read-volatile(a), read(b) // third thread
В этом случае, легко видеть , что существует непрерывная происходит до-цепь от write(b, 3)
, чтобы read(b)
. Кроме того, нет промежуточной записи в b
. Таким образом, для этого сценария третий поток гарантированно видит, что b
имеет значение 3
.
Сценарий № 2 - предположим, что update(1, 2)
и update(3,4)
перекрываются, а элементы чередуются следующим образом:
write(b, 3) // second thread
write(b, 1) // first thread
write-volatile(a, 2) // first thread
write-volatile(a, 4) // second thread
read-volatile(a), read(b) // third thread
Теперь, в то время как есть происходит прежде , чем-цепь от write(b, 3)
, чтобы read(b)
, существует промежуточные write(b, 1)
действие , выполняемое другой нить. Это означает, что мы не можем быть уверены, какое значение read(b)
.
(Помимо этого: это демонстрирует, что мы не можем полагаться на volatile
для обеспечения видимости энергонезависимых переменных, за исключением очень ограниченных ситуаций.)
Как избежать необходимости понимать модель памяти
Модель памяти трудно понять и ее трудно применить. Это полезно, если вам нужно рассуждать о правильности многопоточного кода, но вы не хотите, чтобы это объяснение для каждого многопоточного приложения, которое вы пишете.
Если вы принимаете следующие принципы при написании параллельного кода на Java, во многом вы можете избежать необходимости прибегать к случаям - до рассуждений.
По возможности используйте неизменяемые структуры данных. Правильно внедренный неизменяемый класс будет потокобезопасным и не будет вводить проблемы безопасности потоков при использовании его с другими классами.
Понимать и избегать «небезопасной публикации».
Используйте примитивные мьютексы или объекты
Lock
для синхронизации доступа к состоянию в изменяемых объектах, которые должны быть потокобезопасными 1 .Используйте
Executor
/ExecutorService
или платформу fork join, а не пытайтесь напрямую создавать потоки управления.Используйте классы `java.util.concurrent, которые предоставляют расширенные блокировки, семафоры, защелки и барьеры, вместо прямого использования wait / notify / notifyAll.
Используйте версии
java.util.concurrent
карт, наборов, списков, очередей и требований, а не внешнюю синхронизацию неконкурентных коллекций.
Общий принцип - попытаться использовать встроенные библиотеки параллельного использования Java, а не «сворачивать свой собственный» параллелизм. Вы можете полагаться на их работу, если вы используете их правильно.
1 - Не все объекты должны быть потокобезопасными. Например, если объект или объекты ограничены потоком (т. Е. Доступен только для одного потока), то его безопасность потока не имеет отношения к делу.