Объясните модель памяти в Java: что такое happens‑before, volatile, synchronized и как они воздействуют на видимость и упорядочение операций в многопоточном коде
Кратко: Java Memory Model (JMM) задаёт отношение happens‑before (hb), которое определяет гарантии видимости и допустимые перестановки операций между потоками. Если действие A happens‑before действие B (обозначим A→hbB \;A\rightarrow_{hb}B\;A→hbB), то все побочные эффекты A гарантированно видимы B, и реорганизатор/процессор не может вынести A за пределы этого отношения так, чтобы B видел устаревшее состояние. Ключевые правила happens‑before (важнейшие) - Program order rule: действия в одном потоке упорядочены по программному порядку: для любого A перед B в том же потоке A→hbB \;A\rightarrow_{hb}B\;A→hbB. - Monitor (synchronized) rule: unlock(m) в одном потоке →hb\rightarrow_{hb}→hb последующему lock(m) в другом потоке (для того же монитора). - Volatile rule: запись в volatile-переменную v →hb\rightarrow_{hb}→hb последующего чтения v (в другом потоке). - Thread start/join: вызов t.start() →hb\rightarrow_{hb}→hb первых действий в новом потоке; все действия в потоке →hb\rightarrow_{hb}→hb успешному возврату из t.join(). - Transitivity: если A→hbB\;A\rightarrow_{hb}BA→hbB и B→hbC\;B\rightarrow_{hb}CB→hbC то A→hbC\;A\rightarrow_{hb}CA→hbC. volatile - Что делает: чтение/запись volatile создают hb-ребро: запись v →hb\rightarrow_{hb}→hb последующее чтение v. - Семантика: запись volatile действует как release (не позволяет переместить предыдущие записи после неё), чтение как acquire (не позволяет переместить последующие чтения/записи перед ним). Это обеспечивает видимость изменений других переменных, выполненных до записи volatile. - Ограничения: volatile не даёт взаимного исключения — оно не делает несколько операций атомарными (если нужна атомарность сложной операции, нужен синхронизатор). Для примера: - T1: write(x=1); write(v=1);\text{write}(x=1);\; \text{write}(v=1);write(x=1);write(v=1); (v — volatile) - T2: read(v); read(x);\text{read}(v);\; \text{read}(x);read(v);read(x); По правилам: write(x)→hbwrite(v)\text{write}(x)\rightarrow_{hb}\text{write}(v)write(x)→hbwrite(v), write(v)→hbread(v)\text{write}(v)\rightarrow_{hb}\text{read}(v)write(v)→hbread(v), read(v)→hbread(x)\text{read}(v)\rightarrow_{hb}\text{read}(x)read(v)→hbread(x) ⇒ write(x)→hbread(x)\text{write}(x)\rightarrow_{hb}\text{read}(x)write(x)→hbread(x), значит T2 гарантированно увидит x=1x=1x=1. synchronized - Что делает: вход в synchronized (lock) — acquire; выход (unlock) — release. Unlock одного потока happens‑before последующего lock того же монитора в другом потоке. - Семантика: обеспечивает и видимость (все изменения до unlock видимы после lock), и взаимное исключение (в один момент только один поток держит монитор). - Пример: T1: lock(m); x=1; unlock(m); T2: lock(m); r=x; unlock(m); — по hb гарантировано, что T2 увидит x=1. Видимость, упорядочение и оптимизации - Если между операциями нет пути happens‑before — это data race; поведение чтения/записи в presence of data race не имеет гарантий JMM (может увидеть старые/неожиданные значения). - JMM позволяет компилятору и CPU переставлять/кэшировать операции, если такие перестановки не нарушают hb. Volatile и synchronized вводят границы, которые блокируют соответствующие перестановки. - Volatile дешевле, чем synchronized для простых сигналов/флагов, но не заменяет защиту сложных инвариантов. Короткие практические рекомендации - Используйте synchronized (или другие блокировки) для защиты инвариантов и атомарности составных действий. - Используйте volatile для флагов/одноэлементной видимости или для реализации простых publish/subscribe сигналов. - Для сложной атомарной логики используйте java.util.concurrent (Atomic*, Locks и др.), которые опираются на JMM и CAS. Это основные моменты: happens‑before даёт формальные гарантии видимости и запрет на перестановки; volatile обеспечивает acquire/release-видимость для одной переменной; synchronized обеспечивает acquire/release плюс взаимное исключение.
Ключевые правила happens‑before (важнейшие)
- Program order rule: действия в одном потоке упорядочены по программному порядку: для любого A перед B в том же потоке A→hbB \;A\rightarrow_{hb}B\;A→hb B.
- Monitor (synchronized) rule: unlock(m) в одном потоке →hb\rightarrow_{hb}→hb последующему lock(m) в другом потоке (для того же монитора).
- Volatile rule: запись в volatile-переменную v →hb\rightarrow_{hb}→hb последующего чтения v (в другом потоке).
- Thread start/join: вызов t.start() →hb\rightarrow_{hb}→hb первых действий в новом потоке; все действия в потоке →hb\rightarrow_{hb}→hb успешному возврату из t.join().
- Transitivity: если A→hbB\;A\rightarrow_{hb}BA→hb B и B→hbC\;B\rightarrow_{hb}CB→hb C то A→hbC\;A\rightarrow_{hb}CA→hb C.
volatile
- Что делает: чтение/запись volatile создают hb-ребро: запись v →hb\rightarrow_{hb}→hb последующее чтение v.
- Семантика: запись volatile действует как release (не позволяет переместить предыдущие записи после неё), чтение как acquire (не позволяет переместить последующие чтения/записи перед ним). Это обеспечивает видимость изменений других переменных, выполненных до записи volatile.
- Ограничения: volatile не даёт взаимного исключения — оно не делает несколько операций атомарными (если нужна атомарность сложной операции, нужен синхронизатор). Для примера:
- T1: write(x=1); write(v=1);\text{write}(x=1);\; \text{write}(v=1);write(x=1);write(v=1); (v — volatile)
- T2: read(v); read(x);\text{read}(v);\; \text{read}(x);read(v);read(x);
По правилам: write(x)→hbwrite(v)\text{write}(x)\rightarrow_{hb}\text{write}(v)write(x)→hb write(v), write(v)→hbread(v)\text{write}(v)\rightarrow_{hb}\text{read}(v)write(v)→hb read(v), read(v)→hbread(x)\text{read}(v)\rightarrow_{hb}\text{read}(x)read(v)→hb read(x) ⇒ write(x)→hbread(x)\text{write}(x)\rightarrow_{hb}\text{read}(x)write(x)→hb read(x), значит T2 гарантированно увидит x=1x=1x=1.
synchronized
- Что делает: вход в synchronized (lock) — acquire; выход (unlock) — release. Unlock одного потока happens‑before последующего lock того же монитора в другом потоке.
- Семантика: обеспечивает и видимость (все изменения до unlock видимы после lock), и взаимное исключение (в один момент только один поток держит монитор).
- Пример: T1: lock(m); x=1; unlock(m); T2: lock(m); r=x; unlock(m); — по hb гарантировано, что T2 увидит x=1.
Видимость, упорядочение и оптимизации
- Если между операциями нет пути happens‑before — это data race; поведение чтения/записи в presence of data race не имеет гарантий JMM (может увидеть старые/неожиданные значения).
- JMM позволяет компилятору и CPU переставлять/кэшировать операции, если такие перестановки не нарушают hb. Volatile и synchronized вводят границы, которые блокируют соответствующие перестановки.
- Volatile дешевле, чем synchronized для простых сигналов/флагов, но не заменяет защиту сложных инвариантов.
Короткие практические рекомендации
- Используйте synchronized (или другие блокировки) для защиты инвариантов и атомарности составных действий.
- Используйте volatile для флагов/одноэлементной видимости или для реализации простых publish/subscribe сигналов.
- Для сложной атомарной логики используйте java.util.concurrent (Atomic*, Locks и др.), которые опираются на JMM и CAS.
Это основные моменты: happens‑before даёт формальные гарантии видимости и запрет на перестановки; volatile обеспечивает acquire/release-видимость для одной переменной; synchronized обеспечивает acquire/release плюс взаимное исключение.