- PVSM.RU - https://www.pvsm.ru -

Я провел в изучении JMM много часов и теперь делюсь с вами знаниями в простой и понятной форме.
В этой статье мы подробно разберем Java Memory Model (JMM) и применим полученные знания на практике. Да, в интернете накопилось достаточно много информации про JMM/happens-before, и, кажется, что очередную статью про такую заезженную тему можно пропускать мимо. Однако я постараюсь дать вам намного большее и глубокое понимание JMM, чем большинство информации в интернете. После прочтения этой статьи вы будете уверенно рассуждать о таких вещах как memory ordering, data race и happens-before. JMM — сложная тема и не стоит верить мне на слово, поэтому большинство моих утверждений подтверждается цитатами из спеки, дизассемблером и jcstress тестами.
В современном мире код часто выполняется не в том порядке, в котором он был написан в программе. Он часто переупорядочивается на уровне:
Также в современных процессорах каждое ядро имеет собственный локальный кэш, который не видим другим ядрам. Более того, записи могут удерживаться в регистрах процессора, а не сбрасываться в память. Это ведет к тому, что поток может не видеть изменений, сделанных из других потоков.
Все эти оптимизации делаются с целью повысить производительность программ:
Хорошо, но как в таком хаосе мы вообще можем написать корректную программу?
Есть хорошие новости, и плохие. Начнем с хорошей:
Рассмотрим на примере — этот однопоточный код может быть переупорядочен как угодно под капотом, но в итоге мы гарантированно увидим результат обеих записей при чтении:
a = 5;
b = 7;
int r1 = a; /* always 5 */
int r2 = b; /* always 7 */
Какой порядок инструкций мог быть под капотом?
Например, такой:
b = 7;
a = 5;
int r2 = b; /* 7 */
int r1 = a; /* 5 */
Или такой:
b = 7;
int r2 = b; /* 7 */
a = 5;
int r1 = a; /* 5 */
Но здесь важно лишь то, что выполняемые под капотом действия в итоге приводят к ожидаемому результату. Такие переупорядочивания легальны потому, что эти 2 набора из записи/чтения никак не связаны друг с другом.
Теперь плохие новости:
Обо всем этом мы еще поговорим далее.
Теперь давайте перейдем к примеру из заголовка к статье:
public class MemoryReorderingExample {
private int x;
private int y;
public void T1() {
x = 1;
int r1 = y;
}
public void T2() {
y = 1;
int r2 = x;
}
}
Проанализируем программу: если в первом треде мы видим 0 при чтении y, то запись в x точно произошла, так как чтение идет после записи в порядке программы. Аналогично рассуждаем и о втором треде: если мы видим 0 при чтении x, то запись в y точно произошла. Таким образом, кажется, что мы никогда не можем получить такой результат, когда увидим 0 на обоих чтениях. Однако, хоть это и может показаться странным, но в данной программе мы вполне можем наблюдать результат чтения (r1, r2) = (0, 0). А причины следующие:
Совсем не нужно верить мне на слово, поэтому давайте напишем тест при помощи инструмента jcstress [7], который позволяет писать concurrency тесты для Java:
@JCStressTest
@Description("Classic test that demonstrates memory reordering")
@Outcome(id = "1, 1", expect = Expect.ACCEPTABLE, desc = "Have seen both writes")
@Outcome(id = {"0, 1", "1, 0"}, expect = Expect.ACCEPTABLE, desc = "Have seen one of the writes")
@Outcome(id = "0, 0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Have not seen any write")
public class JmmReorderingDekkerTest {
@Actor
public final void actor1(DataHolder dataHolder, II_Result r) {
r.r1 = dataHolder.actor1();
}
@Actor
public final void actor2(DataHolder dataHolder, II_Result r) {
r.r2 = dataHolder.actor2();
}
@State
public static class DataHolder {
private int x;
private int y;
public int actor1() {
x = 1;
return y;
}
public int actor2() {
y = 1;
return x;
}
}
}
Вот как нужно интерпретировать результат теста:
(1, 1): Expect.ACCEPTABLE — мы прочитали обе записи. Это корректное поведение(0, 1), (1, 0): Expect.ACCEPTABLE — мы прочитали одно из значений слишком рано. Это корректное поведение(0, 0): Expect.ACCEPTABLE_INTERESTING — мы не увидели ни одной записи. Это случай instructions reordering/visibilityЗапускаем тест на Intel Core i7-11700 (x86), Windows 10 x64, OpenJDK 17 (инструкцию по сборке и запуску тестов вы сможете найти в моем репозитории, который я приведу в конце статьи):
RESULT SAMPLES FREQ EXPECT DESCRIPTION
0, 0 2,188,517,311 18,91% Interesting Have not seen any write
0, 1 4,671,980,718 40,36% Acceptable Have seen one of the writes
1, 0 4,708,890,866 40,68% Acceptable Have seen one of the writes
1, 1 5,569,185 0,05% Acceptable Have seen both writes
Как видите, в 18,91% случаев от общего количества прогонов мы не увидели ни одной записи. Стало страшно? Читайте далее, чтобы не попасть в такую ситуацию.
Теперь, получив контекст и поняв проблемы, можно начать говорить о JMM.
Мы поняли, что as-if-serial семантики недостаточно для многопоточных программ. Почему же не распространить as-if-serial гарантию на всю программу и ядра процессора? Ответ простой — это сильно ударило бы по производительности программ или процессора.
Одно из решений описанных проблем — это начать полагаться на строгие гарантии определенной микро-архитектуры процессора или имплементации компилятора/JVM. Но это очень хрупкое решение, которое заставляет думать о среде запуска программы, что препятствует кросс-платформенности. Например, ARM архитектура обладает гораздо более слабыми гарантиями по сравнению с x86: мы можем обнаружить намного больше багов в программе, если однажды стабильно работавшую на x86 программу запустим на ARM. Более того, обычно компиляторы не дают никаких гарантий, а вольны делать любые оптимизации.
Более надежное решение — это создание так называемой модели памяти (memory model), которая строго описывает как потоки взаимодействуют между собой через память (memory ordering). Модель памяти делает легальными многие оптимизации компилятора, JVM и процессора, но в то же время закрепляет условия, при которых программа будет вести себя корректно в многопоточной среде. Таким образом, модель памяти:
Так вот, Java имеет свою модель памяти под названием Java Memory Model (JMM). По умолчанию JMM разрешает переупорядочивание действий и не гарантирует видимости изменений. Однако при выполнении определенных условий нам гарантируется порядок действий, консистентный с порядком в коде, а также видимость всех изменений. Таким образом, JMM позволяет нам писать программы, которые будут полностью корректно работать среди множества различных имплементаций JDK и микро-архитектур процессоров, в то же время сохраняя преимущества оптимизаций.
Для полного понимания модели памяти нам необходимо разобрать такое понятие как Memory Ordering.
Memory Ordering описывает наблюдаемый программой порядок, в котором происходят действия с памятью.
Смотрите: со стороны программы есть только действия записи и чтения из переменных и их порядок в коде. Также со стороны программы кажется, что мы имеем единую память, записи в которую становятся сразу видны другим тредам. Программа не подозревает ни о каких compiler reordering/instructions reordering/caching/register allocation и прочих оптимизациях под капотом. Если по какой-то причине мы наблюдаем результат, не консистентный с порядком в программе, то со стороны программы (высокоуровнево) это выглядит так, что действия c памятью просто были переупорядочены. Другими словами, порядок взаимодействия с памятью (memory order) может отличаться от порядка действий в коде (program order).
Для большего понимания давайте взглянем на уже знакомую нам программу с точки зрения Memory Ordering:
| Thread 0 | Thread 1 |
|---|---|
| x = 1 | y = 1 |
| r1 = y | r2 = x |
В случае (r1, r2) = (0, 0) мы можем просто сказать, что произошел StoreLoad memory reordering, то есть чтение произошло до записи. Не важно, по какой низкоуровневой причине это случилось, а важно лишь то, что в итоге со стороны программы действия с памятью были выполнены в неконсистентном порядке.
Таким образом, при при работе многопоточной программой нам лишь важно знать ответы на следующий вопрос:
Дать ответ на каждый из вопросов — это и есть задача модели памяти.
В свою очередь, Memory Reordering — это высокоуровневое понятие, которое абстрагирует и обобщает низкоуровневые проблемы, которые мы рассматривали выше. Всего существует 4 типа memory reordering:
r1, r2 могут выполниться в порядке r2, r1r, w могут выполниться в порядке w, rw1, w2 могут выполниться в порядке w2, w1w, r могут выполниться в порядке r, wВ дальнейшем, когда я буду говорить "переупорядочивание" или "reordering", я буду иметь в виду именно Memory Reordering, если не сказано обратное.
Memory Model описывает, какие переупорядочивания возможны. В зависимости от строгости модели памяти подразделяются на следующие виды:
Модель памяти существует как на уровне языка, так и на уровне процессора, но они не связаны напрямую. Модель языка может предоставлять как более слабые, так и более строгие гарантии, чем модель процессора.
В частности, Java Memory Model не дает никаких гарантий, пока не использованы необходимые примитивы синхронизации. И напротив, посмотрите на главу Memory Ordering из Intel Software Developer’s Manual [8]:
- Reads are not reordered with other reads [запрещает LoadLoad reordering]
- Writes are not reordered with older reads [запрещает LoadStore reordering]
- Writes to memory are not reordered with other writes [запрещает StoreStore reordering]
- Reads may be reordered with older writes to different locations but not with older writes to the same location [разрешает StoreLoad reordering]
Как видите, Intel разрешает только StoreLoad переупорядочивания, а все остальные запрещены. Да, модель памяти x86 достаточно строга, но есть и намного более слабые модели памяти процессоров — например, ARM разрешает все переупорядочивания.
Однако даже если вы пишите программу под x86, вам все равно необходимо считаться с более слабой Java Memory Model, так как последняя разрешает все переупорядочивания на уровне компилятора. Модель памяти языка — прежде всего.
Еще раз закрепим: Instructions Ordering и Memory Ordering — это не одно и то же. Инструкции могут переупорядочиваться под капотом как угодно, но их memory effect должен подчиняться некоторым Memory Ordering правилам, которые гарантируются (или не гарантируются) Memory Model. Наконец, memory ordering — это высокоуровневое понятие, созданное для простоты понимания работы с памятью.
Например, Intel запрещает LoadLoad переупорядочивания, но под капотом все равно делает спекулятивные чтения. Как это возможно? Дело в том, что процессор следит за тем, чтобы результат выполнения инструкций не нарушал memory ordering правил. Если какое-то правило нарушается, то процессор возвращается к более раннему состоянию: результат чтения отбрасывается, а записи не коммитятся в память. Например, из того же Intel Software Developer’s Manual [9]:
The processor-ordering model described in this section is virtually identical to that used by the Pentium and Intel486 processors. The only enhancements in the Pentium 4, Intel Xeon, and P6 family processors are:
- Added support for speculative reads, while still adhering to the ordering principles above.
Все случаи, где может произойти Memory Reordering, можно покрыть одним понятием — data race. Гонка возникает тогда, когда с shared данными работает одновременно два или больше тредов, где как минимум один из них пишет и их действия не синхронизированы.
Смотрите более конкретное определение в спеке:
Memory that can be shared between threads is called shared memory or heap memory.
All instance fields,
staticfields, and array elements are stored in heap memory. In this chapter, we use the term variable to refer to both fields and array elements.Two accesses to (reads of or writes to) the same variable are said to be conflicting if at least one of the accesses is a write.
When a program contains two conflicting accesses (§17.4.1) that are not ordered by a happens-before relationship, it is said to contain a data race.
Для действий в гонке не гарантируется никакого консистентного результата (порядка) — от запуска к запуску может быть совершенно разный результат операций.
Как же нам добиться полной корректности многопоточной программы? Давайте обратимся за ответом снова к спеке — JLS §17.4.3. Programs and Program Order [12]:
A set of actions is sequentially consistent if all actions occur in a total order (the execution order) that is consistent with program order, and furthermore, each read r of a variable v sees the value written by the write w to v such that:
- w comes before r in the execution order, and
- there is no other write w' such that w comes before w' and w' comes before r in the execution order.
Sequential consistency is a very strong guarantee that is made about visibility and ordering in an execution of a program. Within a sequentially consistent execution, there is a total order over all individual actions (such as reads and writes) which is consistent with the order of the program, and each individual action is atomic and is immediately visible to every thread.If a program has no data races, then all executions of the program will appear to be sequentially consistent.
Таак, sequential consistency, где-то мы это уже слышали… Ах да, SC запрещает все Memory Reordering! Это звучит как то, что нам нужно. Идем далее: чтобы добиться sequential consistency, необходимо избавиться от всех data race в программе. Звучит просто, но не так просто это сделать. Как вы уже заметили, JMM определяет понятие data race через так называемое happens-before. А это значит, что для написания корректных многопоточных программ нам придется изучить и понять, что такое happens-before.
Ну что ж, поехали!
Happens-Before — это концепция, которая гарантирует memory ordering, консистентный с порядком в коде. Из спеки (§17.4.5. Happens-before Order [11]):
Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second.
Happens-before определяется как отношение между двумя действиями:
T1 и поток T2 (необязательно отличающийся от потока T1), и действия x и y, выполняемые в потоках T1 и T2 соответственноx happens-before y, то во время выполнения y треду T2 должны быть видны все изменения, сделанные в x тредом T1Если мы свяжем доступ к shared переменной с помощью happens-before, то мы избавимся от data race, а значит избавимся и от memory reordering.
Давайте сразу проясним один момент: нет, happens-before не означает, что инструкции будут действительно выполняться в таком порядке. Если переупорядочивание инструкций все равно приводит к консистентному результату, то такое переупорядочивание инструкций не запрещено. JLS:
It should be noted that the presence of a happens-before relationship between two actions does not necessarily imply that they have to take place in that order in an implementation. If the reordering produces results consistent with a legal execution, it is not illegal.
Далее мы рассмотрим все действия, для которых JMM гарантирует отношение happens-before.
Если действие x идет перед y в коде программы и эти действия происходят в одном и том же треде, то x happens-before y:
If
xandyare actions of the same thread andxcomes beforeyin program order, thenhb(x, y).
Это формальное определение as-if-serial семантики, которую я уже упоминал в начале статьи: если действие A идет перед действием B в порядке программы, то B гарантированно увидит все изменения, которые должны быть сделаны в A.
Еще раз закрепим: happens-before не означает, что инструкции будут действительно выполняться в таком порядке под капотом. Посмотрите на первый тред из нашего примера:
| Thread 0 |
|---|
| x = 1 |
| r1 = y |
Для этого треда гарантируется, что x = 1 happens-before r1 = y. Однако эти действия никак не связаны: запись в x не влияет на чтение y. Другими словами, на чтении y нам не нужно видеть изменений, сделанных при записи в x. Поэтому даже если инструкции будут переупорядочены, то happens-before между этими действиями не будет нарушено.
Сравните:
| Thread 0' |
|---|
| x = 1 |
| y = x + 1 |
В такой программе действия связаны — на записи в y нам необходимо наблюдать запись в x. Именно в данном случае happens-before запрещает переупорядочивание инструкций, гарантируя, что при записи в y мы увидим результат записи в x.
Освобождение монитора happens-before каждым последующим захватом того же самого монитора.
An unlock action on monitor
mhappens-before all subsequent lock actions onm
Запись в volatile переменную happens-before каждым последующим чтением той же самой переменной.
A write to a volatile variable
vhappens-before all subsequent reads ofvby any thread
Финальное действие в треде T1 happens-before любым действием в треде T2, которое обнаруживает, что тред T1 завершен.
The final action in a thread
T1happens-before any action in another threadT2that detects thatT1has terminated.
Это приводит нас к таким happens-before:
T1 happens-before завершением вызова T1.join() в T2T1 happens-before завершением вызова T1.isAlive() в T2 (если вызов возвращает false)Действие запуска треда (Thread.start()) happens-before первым действием в этом треде.
An action that starts a thread happens-before the first action in the thread it starts.
Если тред T1 прерывает тред T2, то интеррапт happens-before обнаружением интеррапта. Обнаружить интеррапт можно или по исключению InterruptedException, или с помощью вызова Thread.interrupted/Thread.isInterrupted.
If thread
T1interrupts threadT2, the interrupt byT1happens-before any point where any other thread (includingT2) determines thatT2has been interrupted (by having anInterruptedExceptionthrown or by invokingThread.interruptedorThread.isInterrupted).
Дефолтная инициализация (0, false или null) при создании переменной happens-before любыми другими действиями в треде.
The write of the default value (
zero,false, ornull) to each variable happens-before the first action in every thread.Although it may seem a little strange to write a default value to a variable before the object containing the variable is allocated, conceptually every object is created at the start of the program with its default initialized values.
Важно отметить, что отношение happens-before является транзитивным. То есть, если hb(x,y) и hb(y,z), то hb(x,z).
Это приводит нас к одному очень важному и интересному наблюдению. Мы знаем, что два последовательных действия в одном и том же треде связаны с помощью happens-before (same thread actions). Тогда если действие A в одном треде связано отношением happens-before с действием B в другом треде, то благодаря транзитивности второму треду во время и после выполнения действия B будут видны все изменения, сделанные первым тредом до и во время выполнения действия A.
Еще раз: если есть последовательные действия [A1, A2] в первом треде, последовательные действия [B1, B2] во втором треде, и hb(A2, B1), то hb(A1, B1), hb(A1, B2) и hb(A2, B2), потому что:
hb(A1, A2), hb(B1, B2)hb(A1, A2) (same thread), hb(A2, B1) (hb), hb(B1, B2) (same thread), то hb(A1, B1), hb(A1, B2) и hb(A2, B2)Вот как мы можем применить это знание:
T1.join()Давайте с учетом этой информации запишем более полное определение happens-before:
T1 и поток T2 (необязательно отличающийся от потока T1), и действия x и y, выполняющиеся в потоках T1 и T2 соответственноx happens-before y, то во время и после выполнения y должны быть видны все изменения, сделанные до и во время выполнения xМы уже на полпути к написанию корректных многопоточных программ — теперь осталось только применить полученные значения на практике. За основу для дальнейших примеров возьмем следующую нерабочую программу:
public class MemoryReorderingExample {
private int x;
private boolean initialized = false;
public void writer() {
x = 5; /* W1 */
initialized = true; /* W2 */
}
public void reader() {
boolean r1 = initialized; /* R1 */
if (r1) {
int r2 = x; /* R2, may read default value (0) */
}
}
}
Можно подумать, что если мы прочитали значение true на R1, то прочитаем и значение 5 на R2, так как в порядке программы запись в x идет перед записью в initialized. Но на самом деле мы можем наблюдать значение по умолчанию (0) при чтении x по следующим причинам:
x не пропагирована другим ядрам на момент чтенияДругими словами, с точки зрения программы мы говорим, что произошел StoreStore или LoadLoad memory reordering.
Давайте лично убедимся в том, что такие переупорядочивания возможны, написав jcstress тест:
@JCStressTest
@Description("Triggers memory reordering")
@Outcome(id = "-1", expect = Expect.ACCEPTABLE, desc = "Not initialized yet")
@Outcome(id = "5", expect = Expect.ACCEPTABLE, desc = "Returned correct value")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Initialized but returned default value")
public class JmmReorderingPlainTest {
@Actor
public final void actor1(DataHolder dataHolder) {
dataHolder.writer();
}
@Actor
public final void actor2(DataHolder dataHolder, I_Result r) {
r.r1 = dataHolder.reader();
}
@State
public static class DataHolder {
private int x;
private boolean initialized = false;
public void writer() {
x = 5;
initialized = true;
}
public int reader() {
if (initialized) {
return x;
}
return -1; // return mock value if not initialized
}
}
}
Запускаем тест на Intel Core i7-11700 (x86), Windows 10 x64, OpenJDK 17 и получаем следующие результаты:
Results across all configurations:
RESULT SAMPLES FREQ EXPECT DESCRIPTION
-1 5,004,050,680 38,73% Acceptable Not initialized yet
0 168,651 <0,01% Interesting Initialized but returned default value
5 7,916,756,029 61,27% Acceptable Returned correct value
Как видите, в <0,01% случаев мы получили неконсистентный Memory Order.
Далее мы доведем эту программу до полной корректности, используя happens-before.
Monitor lock [13] (Intrinsic lock) не только предоставляет happens-before между освобождением и взятием лока, но также является и мьютексом [14], который позволяет обеспечить эксклюзивный доступ к критической секции (критическая секция — это секция, в которой ведется работа с shared данными). Каждый объект в Java содержит внутри себя такой лок (отсюда и название intrinsic), но его нельзя использовать напрямую — чтобы воспользоваться им, необходимо применить keyword synchronized.
Вот как мы можем исправить приведенную выше программу с помощью монитора:
public class SynchronizedHappensBefore {
private final Object lock = new Object();
private int x;
private boolean initialized = false;
public void writer() {
synchronized (lock) {
x = 5; /* W1 */
initialized = true; /* W2 */
} /* RELEASE */
}
public synchronized void reader() {
synchronized (lock) { /* ACQUIRE */
boolean r1 = initialized; /* R1 */
if (r1) {
int r2 = x; /* R2, guaranteed to see 5 */
}
}
}
}
В данном примере мы используем монитор объекта lock, свойство happens-before которого гарантирует, что после получения монитора reader увидит все изменения, которые сделал writer до освобождения монитора. Следите внимательно: если hb(W1, W2) (same thread), hb(W2, RELEASE) (same thread), hb(RELEASE, ACQUIRE) (monitor lock), hb(ACQUIRE, R1) (same thread), hb(R1, R2) (same thread), то hb(W2, R1) и hb(W1, R2) (transitivity).
Таким образом, если writer освободил монитор и мы захватили его после в reader, то благодаря happens-before нам гарантируется видимость всех действий, которые идут перед освобождением монитора в порядке программы.
Volatile предоставляет happens-before гарантию между записью и чтением из volatile переменной. Семантика volatile отличается от монитора только тем, что не устанавливает exclusive access.
Вот так с помощью volatile мы исправляем ту же самую программу:
public class VolatileHappensBefore {
private int x;
private volatile boolean initialized;
public void writer() {
x = 5; /* W1 */
initialized = true; /* W2 */
}
public void reader() {
boolean r1 = initialized; /* R1 */
if (r1) {
int r2 = x; /* R2, guaranteed to see 5 */
}
}
}
В данном примере мы синхронизируемся на volatile поле initialized, свойство happens-before которого гарантирует, что мы увидим все изменения, которые сделал writer до записи в volatile переменную. Следите внимательно: если hb(W1, W2) (same thread), hb(W2, R1) (volatile), hb(R1, R2) (same thread), то hb(W1, R2) (transitivity).
Таким образом, если мы прочитали true на R1, то нам гарантируется видимость всех действий, которые идут перед записью в volatile переменную в коде программы.
Как видите, пользоваться happens-before достаточно просто. Это все, что вам нужно, чтобы писать свободные от data race и корректные с точки зрения Memory Ordering программы.
В самом начале статьи я уже затрагивал тему Cache Coherence, а теперь разберемся в ней подробнее.
Перед тем как идти дальше, рассмотрим устройство кэша на базовом уровне:
Из-за того, что ядра имеют собственный локальный кэш, возникает потенциальная проблема чтения неактуальных значений. Например, пусть два ядра прочитали одно и то же значение из памяти и сохранили в свой локальный кэш. Затем первое ядро записывает новое значение в свой локальный кэш, но другое ядро не видит этого изменения и продолжает читать устаревшее значение. Как итог, данные среди локальных кэшей не консистентны. Если бы в процессоре существовал только общий кэш, то проблемы чтения неактуальных значений просто не существовало бы: так как все записи и чтения проходят через кэш, а не идут напрямую в память, то общий кэш по сути был бы master копией памяти, где всегда лежали бы актуальные значения. Но это сильно ударило бы по производительности процессора, так как кэш может обрабатывать только один цикл единовременно, а значит ядра простаивали бы в очереди. Более того, локальный кэш распаян физически ближе к ядру, поэтому доступ к нему стоит дешевле. Именно поэтому и необходим локальный кэш, чтобы каждое ядро могло эффективно работать с кэшем независимо от других ядер.

На самом деле, процессоры умеют поддерживать консистентность данных среди локальных кэшей так, что любое из ядер всегда читает актуальное значение одного и того же адреса памяти.
Cache Coherence [16] (когерентность кэша) — это механизм процессора, гарантирующий, что любое ядро всегда читает самое актуальное значение из кэша. Данным механизмом обладают многие современные архитектуры процессоров в той или иной имплементации. Самый популярный из протоколов — это MESI [17] протокол и его производные. Например, Intel использует MESIF [18], а AMD — MOESI [19] протокол.
В MESI протоколе линия кэша может находиться в одном из следующих состояний:
Когда одно из ядер процессора хочет изменить линию кэша, то оно должно установить exclusive доступ к ней. Для этого ядро посылает всем остальным ядрам сообщение о том, что указанную линию кэша необходимо пометить как invalid в их локальном кэше. Только после того, как ядра обработают запрос, пометив свою копию как invalid, ядро сможет записать новое значение вместе с этим помечая линию кэша как modified. Таким образом, при записи только одно ядро может удерживать значение в локальном кэше, а значит неконсистентность данных просто невозможна.
Когда любое ядро хочет прочитать какой-нибудь адрес в памяти, то алгоритм действий выглядит так:
Это очень упрощенное описание работы кэша — я опустил многие детали, но надеюсь, что примерная картина вам понятна. Скажу сразу, что я не претендую на полную корректность вышенаписанного: где-то я мог и соврать, ибо не являюсь специалистом в такой низкоуровневой теме как процессоры. Более того, многие моменты могут отличаться в зависимости от микроархитектуры процессора и используемого Cache Coherence протокола. В конце статьи я приведу ссылки на другие полезные источники, где вы сможете узнать подробнее о работе кэша.
Таким образом, как только значение попадает в локальный кэш, оно сразу же становится видно другим ядрам.
Теперь наверняка у вас возник закономерный вопрос: так что же, значит visibility проблемы на уровне процессора не существует? На самом деле, не все так просто.
Когда ядро получает запрос на инвалидацию записи в кэше, он может быть обработан не сразу, а поставиться в очередь Invalidation Queue (IQ). Эта оптимизация необходима по следующим причинам: во-первых, ядро может быть занято другой работой, и во-вторых, мы хотим, чтобы при большом количестве запросов ядро не заблокировалось на долгое время в их обработке, а обработало все постепенно. Таким образом, можно сказать, что invalidate запросы являются асинхронными
Проблема в том, что мы рискуем не прочитать самое актуальное значение просто потому, что запрос в invalidation queue еще не был обработан, а в кэше лежало еще не инвалидированное, но уже устаревшее значение.
Например:
| CORE 0 | CORE 1 |
|---|---|
| Cached (shared): x(5) | Cached (shared): x(5) |
| send invalidate request | |
| accept invalidate request, put in IQ and respond with acknowledgement | |
| Cached (exclusive): x(5) | Cached (shared): x(5) |
| x = 10 | |
| r = x / 5 / | |
| handle invalidate request / too late! / |
Как видите, мы прочитали устаревшее значение, хотя запрос на invalidate уже пришел.
В некоторых микро-архитектурах (как x86) каждое ядро имеет локальный FIFO Store Buffer (SB, write buffer), который является прослойкой между CPU и кэшем. В этот буфер ядро кладет все записи, которые будут ожидать там сброса в локальный кэш до тех пор, пока все остальные ядра не инвалидируют эту запись в своем кэше и не пришлют acknowledgement. Эта оптимизация требуется для того, чтобы не задерживать работу пишущего ядра, пока остальные ядра обрабатывают запрос на инвалидацию. При чтении ядро сперва смотрит в свой SB перед тем, как идти в локальный кэш, чтобы избежать чтения неактуальных значений и таким образом поддержать as-if-serial гарантию внутри одного ядра
Проблема в том, что другие ядра не увидят новой записи, пока пишущее ядро не сбросит запись из SB в локальный кэш, так как SB — это часть ядра, но не кэша. Другими словами, Cache Coherence механизм не распространяется на Store Buffer. Соответственно, некоторый промежуток времени пишущее ядро будет оперировать актуальным значением, но все остальные — устаревшим.
Например:
| CORE 0 | CORE 1 |
|---|---|
| Cached (exclusive): x(0) | Cached: none |
| x = 5 / put in SB / | |
| r2 = x / 5, read from SB / | |
| r1 = x / 0 / | |
| flushed from sb to cache | |
| r3 = x / 5, read from local cache / |
Как видите, CORE 0 произвело запись в x, а затем CORE 1 пытается прочитать эту переменную. Однако CORE 1 не найдет актуального значения ни в памяти, ни в кэше CORE 0, так как эта запись все еще лежит в Store Buffer. Соответственно, CORE 1 увидит 0 на чтении r1, хотя CORE 0 оперирует актуальным значением на r2, чем нарушается консистентность данных.
Итак, ядра действительно всегда видят актуальное значение, но только кроме короткого временного окна после записи. Другими словами, нам гарантируется eventual visibility изменений.
В заключение приведу полное устройство кэша:

Можно наивно предположить, что благодаря Cache Coherence нам гарантируется eventual visibility и на уровне Java для обычных записей и чтений, то есть не связанных happens-before). Однако, это не правда, так как мы работаем на уровне языка, а не процессора. Компилятор может оптимизировать код так, что запись никогда не станет видна другому треду. Яркий пример — это такой busy wait, где в бесконечном цикле проверяется значение shared переменной.
JCStress уже имеет готовый тест для этого случая — BasicJMM_04_Progress#PlainSpin [20]:
@JCStressTest(Mode.Termination)
@Outcome(id = "TERMINATED", expect = ACCEPTABLE, desc = "Gracefully finished")
@Outcome(id = "STALE", expect = ACCEPTABLE_INTERESTING, desc = "Test is stuck")
@State
public static class PlainSpin {
boolean ready;
@Actor
public void actor1() {
while (!ready); // spin
}
@Signal
public void signal() {
ready = true;
}
}
Смотрим на результаты запуска теста:
RESULT SAMPLES FREQ EXPECT DESCRIPTION
STALE 4 50.00% Interesting Test is stuck
TERMINATED 4 50.00% Acceptable Gracefully finished
Как видите, в половине случаев тред завис навсегда. Это произошло по той причине, что компилятор оптимизировал цикл while (!ready) в while(true). Компилятор свободен это делать, так как переменная не изменяется ни до, ни внутри цикла, а также не связана отношением happens-before с действиями в других тредах.
Исправить этот пример можно пометив переменную как volatile — только в этом случае нам гарантируется eventual visibility изменений.
Таким образом, пока мы работаем с обычными записями и чтениями, не связанными отношением happens-before, нам не гарантируется видимость изменений, сделанных из других тредов.
Процессор может переупорядочивать выполняемые им инструкции, даже если на уровне компилятора мы обеспечили необходимый порядок. Хотя процессор делает только такие переупорядочивания, которые не меняют итогового результата, но это гарантируется только для единственного ядра в изоляции, поэтому переупорядочивание может повлиять на другие ядра. Более того, все еще существует проблема видимости изменений, которую мы обсудили выше. Именно поэтому JMM ответственна и за синхронизацию на уровне процессора, ведь необходимо согласовать и исполняемые процессором инструкции, чтобы обеспечить happens-before.
Для решения этих проблем Java использует готовые низкоуровневые механизмы синхронизации под названием "memory barrier", предоставляемые самим процессором. Задача барьеров памяти — запретить (memory) переупорядочивания, которые обычно разрешены моделью памяти процессора. Таким образом, точно так же как мы используем примитивы синхронизации volatile/synchronized в высокоуровневом коде, сама Java под капотом тоже использует похожие низкоуровневые примитивы синхронизации.
Memory barrier [21] (memory fence, барьер памяти) — это тип процессорной инструкции, которая заставляет процессор гарантировать memory ordering для инструкций, работающих с памятью.
Всего существует 4 типа барьеров памяти — они напрямую матчатся в возможные memory reordering и запрещают каждый из них:
То, как имплементированы барьеры — это дело процессора. К примеру, они могут запрещать переупорядочивание инструкций и ожидать полной обработки Store Buffer/Invalidation Queue, но мы не знаем точной имплементации. На самом деле, знание таких деталей и не нужно — мы просто мыслим в терминах Memory Ordering и тех гарантий порядка, которые дают нам барьеры.
Соответствующие процессорные инструкции или отображаются 1-в-1 в эти типы, или же объединяют в себе сразу несколько типов барьеров. Все процессоры имеют как минимум одну full memory barrier инструкцию, которая объединяет в себя сразу все типы барьеров, запрещая Memory Reordering как load, так и store инструкций вокруг барьера. Например, на x86 мы имеем mfence [22] и lock prefix [23], которые являются full memory barrier. Однако процессоры могут предоставлять и более дешевые, гранулярные барьеры памяти.
Обычно Load- и Store- барьеры используются в паре: Store барьер гарантирует, что записи будут видны другому ядру, а Load барьер гарантирует, что чтения будут выполнены в необходимом порядке.
Например, вот как мы можем исправить уже знакомый нам по вступлению пример с помощью барьера:
| Thread 0 | Thread 1 |
|---|---|
| x = 1 | y = 1 |
| [StoreLoad] | [StoreLoad] |
| r1 = y | r2 = x |
Если мы поставим StoreLoad барьер после записи, то процессору запрещается переупорядочивать store инструкции до барьера с load инструкциями после барьера. В такой программе мы можем быть точно уверены, что не получим результата (r1, r2) = (0, 0). Если рассматривать этот пример со стороны Java, то нам достаточно было бы пометить обе переменные как volatile.
Давайте лично убедимся в наличии барьеров под капотом Java на примере volatile. В JSR-133 Cookbook [24], неофициальном гайдлайне по имплементации JMM за авторством Doug Lea, сказано:
- Issue a
StoreStorebarrier before each volatile store.- Issue a
StoreLoadbarrier after each volatile store.- Issue
LoadLoadandLoadStorebarriers after each volatile load.
Пусть есть такая простая программа с использованием volatile:
public class VolatileMemoryBarrierJIT {
private static int field1;
private volatile static int field2;
private static void write(int i) {
field1 = i << 1;
/* StoreStore */
field2 = i << 2;
/* StoreLoad */
}
private static void read() {
int r1 = field2;
/* LoadLoad + LoadStore */
int r2 = field1;
}
public static void main(String[] args) throws Exception {
// invoke JIT
for (int i = 0; i < 10000; i++) {
write(i);
read();
}
Thread.sleep(1000);
}
}
Теперь возьмем дизассемблер hsdis [25] и посмотрим на сгенерированный JIT-компилятором нативный код (инструкция по самостоятельному запуску будет в моем репозитории, который я приведу в конце статьи). Запускаем дизассемблер на Intel Core i7-11700 (x86), Windows 10 x64, OpenJDK 17. Вот сгенерированный ASM код для write():
[Verified Entry Point]
# {method} {0x00000175a1400310} 'write' '(I)V' in 'jit_disassembly/VolatileMemoryBarrierJIT'
# parm0: rdx = int
# [sp+0x40] (sp of caller)
0x000001758817dae3: mov DWORD PTR [rsi+0x70],edi ;*putstatic field1 {reexecute=0 rethrow=0 return_oop=0}
; - jit_disassembly.VolatileMemoryBarrierJIT::write@3 (line 9)
0x000001758817dae6: shl edx,0x2
0x000001758817dae9: mov DWORD PTR [rsi+0x74],edx
0x000001758817daec: lock add DWORD PTR [rsp-0x40],0x0 ;*putstatic field2 {reexecute=0 rethrow=0 return_oop=0}
; - jit_disassembly.VolatileMemoryBarrierJIT::write@9 (line 10)
В mov инструкциях мы записываем значения полей field1/field2. Теперь обратите внимание на инструкцию lock add DWORD PTR [rsp-0x40],0x0. Это может показаться странным, что мы добавляем 0 к значению на стеке (rsp), но эта инструкция выступает лишь в качестве дешевой по стоимости "заглушки". Все дело в наличии lock префикса, который является full memory barrier на x86, что и дает нам StoreLoad барьер после записи в volatile. JVM могла бы использовать mfence барьер, но на современных процессорах lock add с добавлением 0 на стек является эффективнее [26].
Наверняка у вас возник вопрос: где же StoreStore барьер? Как мы уже видели во вступлении, x86 дает достаточно сильные гарантии порядка. Из Intel Software Developer's Manual [8]:
- Reads are not reordered with other reads [запрещает LoadLoad reordering]
- Writes are not reordered with older reads [запрещает LoadStore reordering]
- Writes to memory are not reordered with other writes [запрещает StoreStore reordering]
- Reads may be reordered with older writes to different locations but not with older writes to the same location [разрешает StoreLoad reordering]
Из этого следует, что нет необходимости использовать LoadLoad, LoadStore, и StoreStore барьеры на x86 микроархитектуре, а нужен только StoreLoad барьер. JVM достаточно умна, чтобы не использовать дорогие барьеры памяти там, где процессор уже дает необходимые гарантии, поэтому мы и не видим применения барьера в сгенерированном нативном коде.
Теперь посмотрим на ASM код для read():
[Verified Entry Point]
# {method} {0x00000175a14003a8} 'read' '()V' in 'jit_disassembly/VolatileMemoryBarrierJIT'
# [sp+0x40] (sp of caller)
0x000001758817de5e: mov edi,DWORD PTR [rsi+0x74] ;*getstatic field2 {reexecute=0 rethrow=0 return_oop=0}
; - jit_disassembly.VolatileMemoryBarrierJIT::read@0 (line 14)
0x000001758817de61: mov esi,DWORD PTR [rsi+0x70] ;*getstatic field1 {reexecute=0 rethrow=0 return_oop=0}
; - jit_disassembly.VolatileMemoryBarrierJIT::read@4 (line 15)
И снова заметим, что барьеры LoadLoad и LoadStore отсутствуют при чтении volatile переменной благодаря строгим гарантиям x86 микроархитектуры. Однако на более слабой микроархитектуре как ARM мы будем наблюдать барьеры в этих местах (смотрите volatile_jit_asm_arm64.txt [27]).
Итак, давайте просуммируем то, что делает happens-before на каждом из уровней:
javac)
HotSpot JIT Compiler C1/C2)
Первые два уровня зависят полностью от самой Java — именна она имплементирует гарантию порядка. Уровень процессора же зависит не только от Java, но и от самого процессора, который предоставляет и имплементирует барьеры памяти.
Важная часть JMM, которую я не упоминал ранее, это атомарность некоторых базовых действий. А именно:
volatile, являются атомарнымиЧто же нам дают эти свойства в многопоточной среде? Нам гарантируется, что при shared чтении переменной мы увидим или значение по умолчанию (0, false, null), или полное консистентное значение, но не половинное значение. Даже если в переменную пишут одновременно несколько тредов, то мы увидим результат записи одного из них, но не будет такой ситуации, что чтение увидит первую половину битов из одной записи, а вторую половину из другой записи.
Таким образом, свойство atomicity дополняет happens-before: happens-before гарантирует нам чтение актуальных изменений, а atomicity гарантирует, что прочитанные данные будут консистентными.
Но почему мы вообще могли бы прочитать половинное значение? Дело в том, что некоторые типы в языке имеют размер (в битах) больший, чем длина машинного слова [28] процессора. Например, 32-х битный процессор оперирует словами по 32 бита, но тип long/double содержит 64 бита. Соответственно, языку требуется совершить 2 записи по 32 бит, чтобы полностью записать значение. Из JLS §17.7. Non-Atomic Treatment of double and long [29]:
For the purposes of the Java programming language memory model, a single write to a non-volatile
longordoublevalue is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.Writes and reads of volatile
longanddoublevalues are always atomic.Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.
JCStress имеет готовый тест для этого случая — BasicJMM_02_AccessAtomicity.java [30]:
@JCStressTest
@Outcome(id = "0", expect = ACCEPTABLE, desc = "Seeing the default value: writer had not acted yet.")
@Outcome(id = "-1", expect = ACCEPTABLE, desc = "Seeing the full value.")
@Outcome( expect = ACCEPTABLE_INTERESTING, desc = "Other cases are violating access atomicity, but allowed under JLS.")
@Ref("https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.7")
@State
public static class Longs {
long v;
@Actor
public void writer() {
v = 0xFFFFFFFF_FFFFFFFFL;
}
@Actor
public void reader(J_Result r) {
r.r1 = v;
}
}
Результаты запуска теста оттуда же:
This test would yield interesting results on some 32-bit VMs, for example x86_32:
RESULT SAMPLES FREQ EXPECT DESCRIPTION
-1 8,818,463,884 70.12% Acceptable Seeing the full value.
-4294967296 9,586,556 0.08% Interesting Other cases are violating access atomicity, but allowed u...
0 3,747,652,022 29.80% Acceptable Seeing the default value: writer had not acted yet.
4294967295 86,082 <0.01% Interesting Other cases are violating access atomicity, but allowed u...
Как видите, в некоторых случаях мы увидели неконсистентное состояние переменной. То есть мы наблюдали переменную прямо посередине записи — writer записал первую половину битов, но еще не успел записать вторую.
Один из способов обеспечить атомарность записи и чтения для long/double — это пометить переменную как volatile. Другой способ — это работать с переменной под монитором, который обеспечивает атомарность всех действий, выполняемых внутри synchronized блока. Замечу, что эти манипуляции необходимы только в том случае, если переменная шарится между тредами — для локальных переменных это не имеет смысла.
JMM дает очень полезную гарантию порядка и видимости записей для final полей: если ссылка на создаваемый объект не утекла во время работы конструктора (так, что ее мог увидеть другой тред), то все остальные треды, которые увидели non-null ссылку на этот объект, гарантированно прочитают актуальные значения всех внутренних final полей объекта вне зависимости от того, была гонка при чтении ссылки или нет.
Из спеки JLS §17.5. final Field Semantics [31]:
An object is considered to be completely initialized when its constructor finishes. A thread that can only see a reference to an object after that object has been completely initialized is guaranteed to see the correctly initialized values for that object's
finalfields.The usage model for
finalfields is a simple one: Set thefinalfields for an object in that object's constructor; and do not write a reference to the object being constructed in a place where another thread can see it before the object's constructor is finished. If this is followed, then when the object is seen by another thread, that thread will always see the correctly constructed version of that object'sfinalfields.
Это очень сильная гарантия, которая полностью избавляет нас от проблем memory reordering при чтении состояния объекта.
Обычно под капотом эта гарантия имплементируется с помощью StoreStore + LoadLoad барьера памяти. Именно это и сказано в JSR-133 Cookbook [24]:
- Issue a
StoreStorebarrier after all stores but before return from any constructor for any class with a final field.- If on a processor that does not intrinsically provide ordering on indirect loads, issue a
LoadLoadbarrier before each load of a final field.
Таким образом, вот так JVM создает объект с final полями:
Object _obj = <new> // memory allocation
_obj.f = 5; // write final field in constructor
[StoreStore]
obj = _obj; // publish
Благодаря тому, что StoreStore барьер запрещает переупорядочивание store операций вокруг барьера, нам гарантируется, что после записи ссылки мы также записали и все поля из конструктора. Более того, StoreStore гарантирует видимость всех изменений до барьера, если читатель увидел запись, сделанную после барьера.
Читаем объект мы следующим образом:
Object _obj = obj;
[LoadLoad]
r1 = _obj.f; // read final field
Здесь LoadLoad барьер требуется для того, чтобы процессор не переупорядочил чтение ссылки с чтениями полей объекта.
Однако как и в случае с volatile, эти барьеры не требуются там, где процессор уже дает необходимые гарантии. Например, таких барьеров точно не будет на x86.
Интересно, что благодаря такой имплементации, которая, например, используется в HotSpot JVM (см. http://hg.openjdk.java.net/jdk/jdk/file/ee1d592a9f53/src/hotspot/share/opto/parse1.cpp#l1001 [32]), нам неявно гарантируется видимость и всех остальных non-final полей. Однако это деталь имплементации, а не гарантия спеки, поэтому на это лучше не полагаться.
Семантика final полей напрямую касается иммутабельных объектов. Известно, что такие объекты можно безопасно шарить между тредами. Но без данной гарантии JMM это было бы не правдой, ведь проблема переупорядочивания все еще никуда не делась. Именно благодаря тому, что JMM автоматически берет на себя задачу по синхронизации final полей, мы имеем возможность корректно шарить иммутабельные объекты без использования примитивов синхронизации.
Давайте рассмотрим использование final полей на примере. Пусть мы имеем такой объект:
public class Foo {
private final int a; /* always visible */
public Foo() {
this.a = 5;
}
}
public class Bar {
private final int b; /* always visible */
public Foo() {
this.b = 7;
}
}
public class DataHolder {
private final Foo foo; /* always visible */
private final int c; /* always visible */
private Bar bar; /* may not be visible */
private int d; /* may not be visible */
public DataHolder() {
this.foo = new Foo();
this.bar = new Bar();
this.c = 9;
this.d = 10;
/* StoreStore */
}
}
Тогда мы имеем следующие гарантии — смотрите комментарии в коде:
public class FinalFieldExample {
private DataHolder instance;
public void writer() {
instance = new DataHolder();
}
public void reader() {
DataHolder instance = this.instance; /* data race */
/* LoadLoad */
if (instance != null) {
Foo foo = instance.foo; /* guaranteed to see non-null reference */
int a = foo.a; /* guaranteed to see 5 */
int c = instance.c; /* guaranteed to see 9 */
Bar bar = instance.bar; /* no guarantee - may be null */
if (bar != null) {
int b = bar.b; /* guaranteed to see 7 */
}
int d = instance.d; /* no guarantee - may be 0 (default value) */
}
}
}
Интересные наблюдения:
instance и читается в гонке, но если мы увидели non-null ссылку, то нам гарантируется видимость всех внутренних final полей вне зависимости от наличия гонкиfinal полей, включая ссылки (reference variable), то по определению гарантируется и видимость final полей этих вложенных объектов. Это видно, например, по объекту Foo, который вложен в DataHolderno guarantee, я вам немного соврал. Как минимум на HotSpot JVM мы все равно прочитаем актуальные значения всех полей, так как все записи происходят до StoreStore барьера. Однако это деталь имплементации, а не гарантия языкаИнтересно, что наличие data race не всегда плохо, если это не влияет на корректность программы, а в некоторых случаях гонка даже является намеренной. Такие гонки называются benign data race.
Не будем далеко ходить за примером — взгляните на имплементацию String#hashCode() [33] из OpenJDK:
public final class String {
/** Cache the hash code for the string */
private int hash; // Default to 0
/**
* Cache if the hash has been calculated as actually being zero, enabling
* us to avoid recalculating this.
*/
private boolean hashIsZero; // Default to false;
public int hashCode() {
// The hash or hashIsZero fields are subject to a benign data race,
// making it crucial to ensure that any observable result of the
// calculation in this method stays correct under any possible read of
// these fields. Necessary restrictions to allow this to be correct
// without explicit memory fences or similar concurrency primitives is
// that we can ever only write to one of these two fields for a given
// String instance, and that the computation is idempotent and derived
// from immutable state
int h = hash;
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h;
}
}
return h;
}
}
Как видите, поля hash и hashIsZero не помечены как volatile, а соответственно и нет happens-before между записью и чтением. Это означает, что даже если один тред уже записал значение hash или hashIsZero, то другой тред может не увидеть изменений. Однако это не опасный data race, так как мы восстанавливаемся из этой ситуации повторно вычисляя и записывая значения полей. Это валидно, так как результат вычисления hashCode остается неизменным для иммутабельного объекта (а все строки в Java являются иммутабельными), то есть запись идемпотентна.
Надеюсь, данная статья дала вам некоторое понимание JMM, а полученные знания помогут вам писать безопасные и корректные многопоточные программы.
Хотя я и привёл здесь много низкоуровневой информации, но на самом деле запоминать такие детали совершенно не обязательно — я лишь хотел дать вам более глубокое понимание того, что происходит под капотом JMM. Просто пользуйтесь предоставленными примитивами синхронизации, а JMM сделает все за вас, ведь она создана как раз с той целью, чтобы скрыть, абстрагировать нижние уровни и предоставить гарантии, избавляющие вас от проблем memory reordering.
Пользуйтесь JMM, и да пребудет с вами thread safety.
P.S: и запомните: data races are evil.
Обратите внимание на репозиторий в поддержку данной статьи — https://github.com/blinky-z/JmmArticleHabr [34]. Там вы сможете найти еще больше, не включенных в статью jcstress тестов и дизассемблированных программ, а также инструкции и результаты запуска тестов и дизассемблера на x86/arm64.
Основы:
Блог Алексея Шипилева — это целая кладезь знаний про JMM и не только. Крайне советую прочитать следующие его статьи:
Compiler Memory Ordering:
CPU Memory ordering/Memory Barrier:
CPU Cache:
Volatile:
Книги:
Автор: Дмитрий
Источник [67]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/378443
Ссылки в тексте:
[1] Instruction scheduling: https://en.wikipedia.org/wiki/Instruction_scheduling
[2] Out-of-order execution: https://en.wikipedia.org/wiki/Out-of-order_execution
[3] Branch Prediction: https://en.wikipedia.org/wiki/Branch_predictor
[4] Speculation: https://en.wikipedia.org/wiki/Speculative_execution
[5] Prefetching: https://en.wikipedia.org/wiki/Cache_prefetching
[6] as-if-serial: https://en.wikipedia.org/wiki/As-if_rule
[7] jcstress: https://github.com/openjdk/jcstress
[8] Intel Software Developer’s Manual: https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-system-programming-manual-325384.pdf#G13.14501
[9] Intel Software Developer’s Manual: https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-system-programming-manual-325384.pdf#G13.31870
[10] JLS §17.4.1. Shared Variables: https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html#jls-17.4.1
[11] JLS §17.4.5. Happens-before Order: https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html#jls-17.4.5
[12] JLS §17.4.3. Programs and Program Order: https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html#jls-17.4.3
[13] Monitor lock: https://docs.oracle.com/javase/tutorial/essential/concurrency/locksync.html
[14] мьютексом: https://ru.wikipedia.org/wiki/%D0%9C%D1%8C%D1%8E%D1%82%D0%B5%D0%BA%D1%81
[15] несколько уровней кэша: https://en.wikipedia.org/wiki/Cache_hierarchy
[16] Cache Coherence: https://en.wikipedia.org/wiki/Cache_coherence
[17] MESI: https://en.wikipedia.org/wiki/MESI_protocol
[18] MESIF: https://en.wikipedia.org/wiki/MESIF_protocol
[19] MOESI: https://en.wikipedia.org/wiki/MOESI_protocol
[20] BasicJMM_04_Progress#PlainSpin: https://github.com/openjdk/jcstress/blob/83365e01e6606f8368a74b3e5503361f51074cde/jcstress-samples/src/main/java/org/openjdk/jcstress/samples/jmm/basic/BasicJMM_04_Progress.java#L41
[21] Memory barrier: https://en.wikipedia.org/wiki/Memory_barrier
[22] mfence: https://www.felixcloutier.com/x86/mfence
[23] lock prefix: https://www.felixcloutier.com/x86/lock
[24] JSR-133 Cookbook: https://gee.cs.oswego.edu/dl/jmm/cookbook.html
[25] hsdis: https://blogs.oracle.com/javamagazine/post/java-hotspot-hsdis-disassembler
[26] является эффективнее: https://web.archive.org/web/20110620202202/https://blogs.oracle.com/dave/entry/instruction_selection_for_volatile_fences
[27] volatile_jit_asm_arm64.txt: https://github.com/blinky-z/JmmArticleHabr/blob/main/disassembly/arm64/volatile_jit_asm_arm64.txt
[28] машинного слова: https://en.wikipedia.org/wiki/Word_(computer_architecture)
[29] §17.7. Non-Atomic Treatment of double and long: https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html#jls-17.7
[30] BasicJMM_02_AccessAtomicity.java: https://github.com/openjdk/jcstress/blob/fa0af79c39b010128f61cb7945149681721ce320/jcstress-samples/src/main/java/org/openjdk/jcstress/samples/jmm/basic/BasicJMM_02_AccessAtomicity.java#L77
[31] §17.5. final Field Semantics: https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html#jls-17.5
[32] http://hg.openjdk.java.net/jdk/jdk/file/ee1d592a9f53/src/hotspot/share/opto/parse1.cpp#l1001: http://hg.openjdk.java.net/jdk/jdk/file/ee1d592a9f53/src/hotspot/share/opto/parse1.cpp#l1001
[33] String#hashCode(): http://hg.openjdk.java.net/jdk/jdk/file/ee1d592a9f53/src/java.base/share/classes/java/lang/String.java#l1531
[34] https://github.com/blinky-z/JmmArticleHabr: https://github.com/blinky-z/JmmArticleHabr
[35] Memory ordering: https://en.wikipedia.org/wiki/Memory_ordering
[36] Memory model: https://en.wikipedia.org/wiki/Memory_model_(programming)
[37] JLS §17.4. Memory Model: https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html#jls-17.4
[38] The Java Memory Model: http://www.cs.umd.edu/~pugh/java/memoryModel/index.html
[39] Memory Barriers — a Hardware View for Software Hackers: https://raw.githubusercontent.com/tpn/pdfs/master/Memory%20Barriers%20-%20a%20Hardware%20View%20for%20Software%20Hackers%20(July%2023%2C%202010).pdf
[40] How does memory reordering help processors and compilers?: https://stackoverflow.com/questions/37725497/how-does-memory-reordering-help-processors-and-compilers/37739933#37739933
[41] Java Memory Model Pragmatics: https://shipilev.net/blog/2014/jmm-pragmatics/
[42] Safe Publication and Safe Initialization in Java: https://shipilev.net/blog/2014/safe-public-construction/
[43] Close Encounters of The Java Memory Model Kind: https://shipilev.net/blog/2016/close-encounters-of-jmm-kind/
[44] All Fields Are Final: https://shipilev.net/blog/2014/all-fields-are-final/
[45] Memory Ordering at Compile Time: https://preshing.com/20120625/memory-ordering-at-compile-time/
[46] Memory Consistency Models: A Tutorial: https://www.cs.utexas.edu/~bornholt/post/memory-models.html
[47] Memory Barriers Are Like Source Control: https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/
[48] Weak vs. Strong Memory Models: https://preshing.com/20120930/weak-vs-strong-memory-models/
[49] Sequential Consistency & Total Store Order: https://www.cis.upenn.edu/~devietti/classes/cis601-spring2016/sc_tso.pdf
[50] Memory Barriers/Fences: https://mechanical-sympathy.blogspot.com/2011/07/memory-barriersfences.html?m=1
[51] Linux kernel memory barriers: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/Documentation/memory-barriers.txt
[52] Сводная таблица возможных переупорядочиваний среди различных микроархитектур: https://en.wikipedia.org/wiki/Memory_ordering#Runtime_memory_ordering
[53] Does an x86 CPU reorder instructions?: https://stackoverflow.com/questions/50307693/does-an-x86-cpu-reorder-instructions
[54] Making sense of Memory Barriers: https://stackoverflow.com/questions/37798053/making-sense-of-memory-barriers
[55] what is a store buffer?: https://stackoverflow.com/questions/11105827/what-is-a-store-buffer
[56] Size of store buffers on Intel hardware? What exactly is a store buffer?: https://stackoverflow.com/questions/54876208/size-of-store-buffers-on-intel-hardware-what-exactly-is-a-store-buffer
[57] ARM Memory Ordering: https://developer.arm.com/documentation/den0024/a/Memory-Ordering?lang=en
[58] CPU cache: https://en.wikipedia.org/wiki/CPU_cache
[59] Mechanical Sympathy: CPU Cache Flushing Fallacy: https://mechanical-sympathy.blogspot.com/2013/02/cpu-cache-flushing-fallacy.html
[60] Cache coherency primer: https://fgiesen.wordpress.com/2014/07/07/cache-coherency/
[61] Memory barriers force cache coherency?: https://stackoverflow.com/questions/30958375/memory-barriers-force-cache-coherency
[62] Volatile: https://jpbempel.github.io/2012/10/09/volatile.html
[63] Volatile and memory barriers: https://jpbempel.github.io/2015/05/26/volatile-and-memory-barriers.html
[64] Java theory and practice: Managing volatility: https://www.pvsm.ruhttp://web.archive.org/web/20210221170926/https://www.ibm.com/developerworks/java/library/j-jtp06197/
[65] A Primer on Memory Consistency and Cache Coherence: https://www.morganclaypool.com/doi/10.2200/S00962ED2V01Y201910CAC049
[66] Java Concurrency in Practice: https://www.amazon.com/Java-Concurrency-Practice-Brian-Goetz/dp/0321349601
[67] Источник: https://habr.com/ru/post/685518/?utm_source=habrahabr&utm_medium=rss&utm_campaign=685518
Нажмите здесь для печати.