Глубокое погружение в Java Memory Model |
||
МЕНЮ Главная страница Поиск Регистрация на сайте Помощь проекту Архив новостей ТЕМЫ Новости ИИ Голосовой помощник Разработка ИИГородские сумасшедшие ИИ в медицине ИИ проекты Искусственные нейросети Искусственный интеллект Слежка за людьми Угроза ИИ ИИ теория Внедрение ИИКомпьютерные науки Машинное обуч. (Ошибки) Машинное обучение Машинный перевод Нейронные сети начинающим Психология ИИ Реализация ИИ Реализация нейросетей Создание беспилотных авто Трезво про ИИ Философия ИИ Big data Работа разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика
Генетические алгоритмы Капсульные нейросети Основы нейронных сетей Распознавание лиц Распознавание образов Распознавание речи Творчество ИИ Техническое зрение Чат-боты Авторизация |
2022-09-25 05:47 Я провел в изучении 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 набора из записи/чтения никак не связаны друг с другом. Теперь плохие новости:
Обо всем этом мы еще поговорим далее. А теперь давайте перейдем к примеру из заголовка к статье (кстати, эта программа отражает идиому Dekker lock): 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; } } Проанализируем программу:
Таким образом, кажется, что мы никогда не можем получить такой результат выполнения программы, когда увидим
Совсем не нужно верить мне на слово, поэтому давайте напишем тест при помощи инструмента jcstress, который позволяет писать concurrency тесты для Java: ("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; } } } Вот как нужно интерпретировать результат теста:
Запускаем тест на 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% случаев от общего количества прогонов мы не увидели ни одной записи. Все еще не убедительно? А давайте заглянем на самый нижний уровень — уровень JIT-сгенированного кода. Напишем такую тестовую программу: public class JmmDekkerInstructionsReorderingJIT { private static int x; private static int y; private static int T1(int i) { x = i; return y; } private static int T2(int i) { y = i; return x; } public static void main(String[] args) throws Exception { // invoke JIT for (int i = 0; i < 10000; i++) { T1(i); T2(i); } Thread.sleep(1000); } } Теперь возьмем дизассемблер hsdis и посмотрим на сгенерированный JIT-компилятором нативный код (подробная инструкция по самостоятельному запуску также будет в моем репозитории). Запускаем дизассемблер на Intel Core i7-11700 (x86), Windows 10 x64, OpenJDK 17 с помощью команды [Verified Entry Point] # {method} {0x0000015b92003208} 'T1' '(I)I' in 'jit_disassembly/JmmDekkerInstructionsReorderingJIT' # parm0: rdx = int # [sp+0x20] (sp of caller) 0x0000015bf9561c00: sub rsp,0x18 0x0000015bf9561c07: mov QWORD PTR [rsp+0x10],rbp ;*synchronization entry ; - jit_disassembly.JmmDekkerInstructionsReorderingJIT::T1@-1 (line 9) 0x0000015bf9561c0c: movabs r10,0x7117dd288 ; {oop(a 'java/lang/Class'{0x00000007117dd288} = 'jit_disassembly/JmmDekkerInstructionsReorderingJIT')} 0x0000015bf9561c16: mov eax,DWORD PTR [r10+0x74] ;*getstatic y {reexecute=0 rethrow=0 return_oop=0} ; - jit_disassembly.JmmDekkerInstructionsReorderingJIT::T1@4 (line 10) 0x0000015bf9561c1a: mov DWORD PTR [r10+0x70],edx ;*putstatic x {reexecute=0 rethrow=0 return_oop=0} ; - jit_disassembly.JmmDekkerInstructionsReorderingJIT::T1@1 (line 9) [Verified Entry Point] # {method} {0x0000015b920032b0} 'T2' '(I)I' in 'jit_disassembly/JmmDekkerInstructionsReorderingJIT' # parm0: rdx = int # [sp+0x20] (sp of caller) 0x0000015bf9561f00: sub rsp,0x18 0x0000015bf9561f07: mov QWORD PTR [rsp+0x10],rbp ;*synchronization entry ; - jit_disassembly.JmmDekkerInstructionsReorderingJIT::T2@-1 (line 14) 0x0000015bf9561f0c: movabs r10,0x7117dd288 ; {oop(a 'java/lang/Class'{0x00000007117dd288} = 'jit_disassembly/JmmDekkerInstructionsReorderingJIT')} 0x0000015bf9561f16: mov eax,DWORD PTR [r10+0x70] ;*getstatic x {reexecute=0 rethrow=0 return_oop=0} ; - jit_disassembly.JmmDekkerInstructionsReorderingJIT::T2@4 (line 15) 0x0000015bf9561f1a: mov DWORD PTR [r10+0x74],edx ;*putstatic y {reexecute=0 rethrow=0 return_oop=0} ; - jit_disassembly.JmmDekkerInstructionsReorderingJIT::T2@1 (line 14) Как видите, запись и чтение были переупорядочены друг с другом. Стало страшно? Читайте далее, чтобы не попасть в такую ситуацию. Введение: JMM Теперь, получив контекст и поняв проблемы, можно начать говорить о JMM. Мы поняли, что as-if-serial семантики недостаточно для многопоточных программ. Почему же не распространить as-if-serial гарантию на всю программу и ядра процессора? Ответ простой — это сильно ударило бы по производительности программ или процессора. Одно из решений описанных проблем — это начать полагаться на строгие гарантии определенной микро-архитектуры процессора или имплементации компилятора/JVM. Но это очень хрупкое решение, которое заставляет думать о среде запуска программы, что препятствует кросс-платформенности. Например, ARM архитектура обладает гораздо более слабыми гарантиями по сравнению с x86: мы можем обнаружить намного больше багов в программе, если однажды стабильно работавшую на x86 программу запустим на ARM. Более того, обычно компиляторы не дают никаких гарантий, а вольны делать любые оптимизации. В общем, нам нужна поддержа со стороны спецификации языка. Поэтому более надежное решение — это создание так называемой модели памяти (memory model), которая строго описывает какое выполнение программы является валидным. Модель памяти делает легальными многие оптимизации компилятора, JVM и процессора, но в то же время закрепляет условия, при которых программа будет вести себя корректно в многопоточной среде даже в присутствие оптимизаций. Таким образом, модель памяти:
Так вот, Java имеет свою модель памяти под названием Java Memory Model (JMM). По умолчанию JMM разрешает любые переупорядочивания и не гарантирует видимости изменений. Однако при выполнении определенных условий нам гарантируется порядок действий, консистентный с порядком в коде, а также видимость всех изменений. Таким образом, JMM позволяет нам писать программы, которые будут полностью корректно работать среди множества различных имплементаций JDK и микро-архитектур процессоров, в то же время сохраняя преимущества оптимизаций. Введение: Memory Ordering Для полного понимания модели памяти нам необходимо разобрать такое понятие как "memory ordering". Memory Ordering описывает наблюдаемый программой порядок, в котором происходят действия с памятью. Смотрите: со стороны программы есть только действия записи/чтения и их порядок в коде. Также со стороны программы кажется, что мы имеем единую общую память, записи в которую становятся сразу видны другим тредам. Программа не подозревает ни о каких instruction scheduling reordering/out-of-order execution/caching/register allocation и прочих оптимизациях под капотом. Если по какой-то причине мы наблюдаем результат, не консистентный с порядком в программе, то со стороны программы (высокоуровнево) это выглядит так, что действия c памятью просто были переупорядочены. Другими словами, порядок взаимодействия с памятью (memory order) может отличаться от порядка действий в коде (program order). Для большего понимания давайте взглянем на уже знакомую нам программу с точки зрения Memory Ordering: В случае результата выполнения Таким образом, в многопоточной программе нам важно знать ответы на следующие вопросы:
Дать ответ на каждый из вопросов — это и есть задача модели памяти. Java Memory Model разрешает все возможные переупорядочивания в отсутствие синхронизации, поэтому ответ на эти вопросы такой:
Ваша программа отрабатывает в одном из порядков, валидных с точки зрения JMM. Таким образом, если программа не правильно синхронизирована, не стоит удивляться некорретному результату выполнения. Ведь важно то, валиден ли результат выполнения с точки зрения модели памяти, а не то, валиден он или нет для вас как пользователя. Однако то, что какой-то неконсистентный порядок валиден, еще не значит, что вы всегда получите некорректный результат, ведь и консистентный порядок возможен в отсутствие синхронизации — это вы могли видеть по jcstress тесту, который является вероятностным. Понятно, что вы не хотите надеяться на волю случая, поэтому необходимо ограничить возможный сет порядков выполнения до только консистентных. А для этого необходимо использовать предоставляемые моделью примитивы синхронизации, которые мы рассмотрим позднее. В свою очередь, Memory Reordering — это высокоуровневое понятие, которое абстрагирует и обобщает низкоуровневые проблемы, которые мы рассматривали выше. Всего существует 4 типа memory reordering:
В дальнейшем, когда я буду говорить "переупорядочивание" или "reordering", я буду иметь в виду именно Memory Reordering, если не сказано обратное. Memory Model описывает, какие переупорядочивания возможны. В зависимости от строгости модели памяти подразделяются на следующие типы:
Модель памяти существует как на уровне языка, так и на уровне процессора, но они не связаны напрямую. Модель языка может предоставлять как более слабые, так и более строгие гарантии, чем модель процессора. В частности, как уже было сказано выше, Java Memory Model не дает никаких гарантий, пока не использованы необходимые примитивы синхронизации. И напротив, посмотрите на главу Memory Ordering из Intel Software Developer’s Manual:
Как видите, Intel разрешает только Однако даже если вы пишите программу под x86, вам все равно необходимо считаться с более слабой Java Memory Model, так как последняя разрешает все переупорядочивания на уровне компилятора. Модель памяти языка — прежде всего. Memory Ordering vs Instructions Ordering Еще раз закрепим: Memory Ordering и Instructions Ordering — это не одно и то же. Инструкции могут переупорядочиваться под капотом как угодно, но их memory effect должен подчиняться некоторым Memory Ordering правилам, которые гарантируются (или не гарантируются) Memory Model. Наконец, memory ordering — это высокоуровневое понятие, созданное для простоты понимания работы с памятью. Например, Intel запрещает 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:
Введение: Sequential Consistency Sequential Consistency Model (SC) — это очень строгая модель памяти, которая гарантирует отсутствие переупорядочиваний. Интуитивно SC можно понять очень просто: возьмите действия тредов, как они идут в порядке программы, и просто выполните их последовательно, возможно переключаясь между тредами. Формальное определение SC также достаточно простое: [Lamport, 1979 — How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs] ...the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program. Давайте разберем SC на примере. Возьмем все тот же Dekker lock, который мы рассматривали выше: В SC модели могут быть следующие memory order и никакие больше: write(x, 1) -> write(y, 1) -> read(y):1 -> read(x):1 write(x, 1) -> write(y, 1) -> read(x):1 -> read(y):1 write(x, 1) -> read(y):0 -> write(y, 1) -> read(x):1 write(y, 1) -> write(x, 1) -> read(x):1 -> read(y):1 write(y, 1) -> write(x, 1) -> read(y):1 -> read(x):1 write(y, 1) -> read(x):0 -> write(x, 1) -> read(y):1 Назовем такие порядки "sequentially consistent memory orders". А вот такой memory order, где присутствуют read(y):0 -> read(x):0 -> write(x, 1) -> write(y, 1) Введение: Sequential Consistency-Data Race Free Отлично, все это звучит здорово, но как же нам получить такую модель памяти? Ведь как мы уже поняли, JMM — это слабая модель памяти, которая не гарантирует консистентного порядка памяти. Однако я уже упоминал выше, что при соблюдении некоторых условий наша программа будет считаться правильно синхронизированной и всегда работать корректно. Так вот, Java Memory Model — это Sequential Consistency-Data Race Free (SC-DRF) модель: нам предоставляется sequential consistency, но только в том случае, если мы избавимся от всех data race в программе — про это мы еще поговорим далее. Введение: Sequential Consistency: Why? Вы наверное сейчас сидите и думаете: абстракция над абстракцией и абстракцией погоняет… Memory model, memory order, sequential consistency… Ну зачем, зачем же все эти абстракции? Давайте вместе разбираться. Посмотрим на определение Sequential Consistency еще раз: ...the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program. Обратите внимание на выделенное в определении "is the same as if" — это очень важная деталь. Это означает, что инструкции под капотом не должны выполняться именно в sequential порядке. Важно лишь то, чтобы результат выполнения был не отличим от одного из таких порядков. Хорошо, а нам то что с этого? А нам ничего. Зато разработчикам JMM такое определение позволяет делать любые оптимизации под капотом, пока они не приводят к результату, который не возможен в SC выполнении. Давайте разберем подробнее. Снова возвращаемся к нашей программе: Снова перечисляем все возможные sequentially consistent порядки, где каждому выполнению запишем конечный результат: write(x, 1) -> write(y, 1) -> read(y):1 -> read(x):1 // result: (x,y)=(1, 1) write(x, 1) -> write(y, 1) -> read(x):1 -> read(y):1 // result: (x,y)=(1, 1) write(x, 1) -> read(y):0 -> write(y, 1) -> read(x):1 // result: (x,y)=(1, 0) write(y, 1) -> write(x, 1) -> read(x):1 -> read(y):1 // result: (x,y)=(1, 1) write(y, 1) -> write(x, 1) -> read(y):1 -> read(x):1 // result: (x,y)=(1, 1) write(y, 1) -> read(x):0 -> write(x, 1) -> read(y):1 // result: (x,y)=(0, 1) В итоге мы получаем следующий сет возможных результатов: Так вот, любая имплементация нашей программы, которая гарантированно приводит к одному из sequentially consistent результатов, является валидной вне зависимости от того, какие оптимизации были сделаны. Главное то, чтобы эти оптимизации не могли привести к sequentially inconsistent результату. Например, в SC модели памяти компилятор не может вот так переставить чтение с записью в нашей программе: Ведь такая имплементация может привести к результату А поэтому компилятору запрещается делать такую оптимизацию, ведь такой результат не входит в набор sequentially consistent результатов, который мы определили выше. А вот, например, какую оптимизацию компилятор может сделать: Компилятор полностью убрал записи и просто заинлайнил значения в чтения. Нарушает ли это Sequential Consistency? Совсем нет, ведь эта имплементация всегда приводит нас к результату Таким образом, представить себе работу SC модели памяти можно следующим образом:
Что получается в итоге? А в итоге модель памяти, memory order, sequential consistency — это все абстракция между нами, пользователями JMM, и собственно самой имплементацией JMM. Нам данные абстракции позволяют нам рассуждать о корректности нашей программы без вдавания в низкоуровневые подробности, а разработчикам JMM делать любые оптимизации под капотом, пока они не нарушают этих абстракций. Однако в рамках данной статьи я все-таки покрою уровни ниже, чтобы иметь полную картину. В конце концов, как можно понять важность JMM, если не увидеть хоть части от всей сложности под капотом? So, here's the deal: вы избавляетесь от всех data race в коде и получаете программу, результат которой будет всегда не отличим от одного из sequentially consistent порядков, а разработчики JMM получают возможность делать любые оптимизации под капотом, пока они приводят к валидному результату. Введение: data race Data race возникает тогда, когда с shared данными работает одновременно два или больше тредов, где как минимум один из них пишет и их действия не синхронизированы. Для действий в гонке не гарантируется никакого консистентного memory order, поэтому не стоит удивляться неожиданным результатам. Data race в рамках JMM — это ключевая вещь, которая формально отличает SC и не-SC выполнения: если мы докажем, что никакое выполнение нашей программы не имеет гонок, то результат выполнения программы будет всегда объясним с точки зрения одного из sequentially consistent порядков. Давайте пройдемся по формальным определениям в спеке. Для начала взглянем на формальное определение data race:
А теперь найдем ответ на следующий вопрос: как же нам добиться SC? Смотрим на JLS §17.4.3. Programs and Program Order: 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:
Вот и то самое SC-DRF, про которое мы говорили выше: чтобы добиться sequential consistency, необходимо избавиться от всех data race в программе. Все это звучит просто, но не так просто это сделать. Избавиться от гонок можно двумя способами:
Далее мы рассмотрим оба этих способа. Ну что ж, поехали! JMM: Synchronization Order Как мы уже знаем, JMM — это слабая модель памяти, которая не соблюдает порядок программы. Поэтому модель должна нам предоставить специальные примитивы, при использовании которых гарантировался бы консистентный порядок. И такое решение действительно есть: JMM предоставляет специальные примитивы под названием Synchronization Actions (SA). Для таких действий образуется полный порядок под названием Synchronization Order (SO), в котором:
JLS 17.4.4. Synchronization Order: Every execution has a synchronization order. A synchronization order is a total order over all of the synchronization actions of an execution. For each thread , the synchronization order of the synchronization actions (§17.4.2) in is consistent with the program order (§17.4.3) of .
Постойте, но это же звучит как Sequential Consistency? Именно так — SO это SC! Нас интересуют следующие synchronization actions:
Давайте сразу перейдем к практике. Например, если в Dekker lock мы пометим обе переменные как public class DekkerVolatile { private volatile int x; private volatile int y; public void T1() { x = 1; int r1 = y; } public void T2() { y = 1; int r2 = x; } } Ранее мы уже перечисляли все возможные SC порядки, поэтому не будем их повторять. Давайте лучше для нагладяности перечислим порядки, которые не подчиняются свойствам SO, а поэтому не считаются валидными с точки зрения JMM: write(x, 1) --SO-> read(y):0 --non-SO-> read(x):1 --non-SO-> write(y, 1) // violates SO-PO consistency - not consistent with program order read(y):0 --non-SO-> read(x):0 --non-SO-> write(x, 1) --non-SO-> write(y, 1) // violates SO-PO consistency - not consistent with program order write(x, 1) --SO-> write(y, 1) -> read(y):0 --SO-> read(x):0 // violates SO consistency - does not see preceding writes Итак, мы получили Sequential Consistency, и на этом разговор о JMM можно завершать. Всем спасибо за внимание. Шучу. Заметим, что SO предлагает нам все или ничего: или мы используем только synchronization actions (например, помечаем все shared переменные как volatile) и получаем SC, или не получаем ничего, ведь не-SA действия (обычные записи и чтения shared переменной) снова будут выполняться в неконсистентном порядке, нарушая корректность программы. Более того, синхронизировать всю программу таким способом просто неудобно: представьте, что вы пишите объект с кучей полей — вам придется пометить как внутренние поля, так и ссылку на объект как Давайте теперь зададимся вопросом: зачем вообще JMM явно предоставляет SA, если можно было бы сделать все действия такими неявно и дать нам SC по-умолчанию? Сиди и помечай все переменные как Хотя связать все действия с shared данными в SO иногда бывает необходимо (как в Dekker локе или IRIW тесте), но в большинстве случаев это избыточно и запретит многие оптимизации компилятора, ухудшив производительность программы. Да, SO/SC действительно представляют собой лишь иллюзию последовательного выполнения, но многие оптимизации все равно будут запрещены. Именно поэтому ни в каком языке или микроархитектуре мы никогда не встретим SC модели по-умолчанию. Так вот, в JMM существует основанный на SA формальный способ разрешить оптимизации под капотом, при этом все так же дав пользователям возможность писать корректные программы. И вот наконец мы начинаем говорить о частичном happens-before (hb) порядке, который все так же гарантирует консистентный порядок и видимость изменений, но при этом является намного более удобным в использовании и позволяет нам не синхронизировать всю программу. JMM: Happens-before: теория Happens-before определяется как отношение между двумя действиями:
JLS §17.4.5. Happens-before Order: Two actions can be ordered by a happens-before relationship. If one action another, then the first is visible to and ordered before the second. happens-before Happens-before — это еще один способ, с помощью которого мы добьемся sequential consistency. Смотрите:
Давайте сразу проясним один момент: нет, 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. [Happens-Before] Same thread actions Если действие If
Это формальное определение as-if-serial семантики, которую я уже упоминал в начале статьи: если действие Еще раз закрепим: happens-before не означает, что инструкции будут действительно выполняться в таком порядке под капотом. Например, давайте посмотрим на первый тред из Dekker: Для этого треда гарантируется, что Сравните: В такой программе действия связаны — на записи в [Happens-Before] Monitor lock Освобождение монитора happens-before каждый последующий захват того же самого монитора. An unlock action on monitor
happens-before [Happens-Before] Volatile Запись в A write to a volatile variable [Happens-Before] Final thread action Финальное действие в треде T1 happens-before любое действие в треде T2, которое обнаруживает, что тред T1 завершен. The final action in a thread
happens-before Это приводит нас к таким happens-before:
[Happens-before] Thread start action Действие запуска треда ( An action that starts a thread happens-before the first action in the thread it starts. [Happens-before] Thread interrupt action Если тред If thread
happens-before
[Happens-Before] Default initialization Дефолтная инициализация ( The write of the default value ( happens-before
Happens-before transitivity Важно отметить, что отношение happens-before является транзитивным. То есть, если Это приводит нас к одному очень важному и интересному наблюдению. Мы знаем, что два последовательных действия в одном и том же треде связаны с помощью happens-before (same thread actions). Тогда если действие Еще раз: если есть последовательные действия
Вот как мы можем применить это знание:
Давайте с учетом этой информации запишем более полное определение happens-before:
JMM: Happens-before: практика Мы уже на полпути к написанию корректных многопоточных программ — теперь осталось только применить полученные значения на практике. За основу для дальнейших примеров возьмем следующую нерабочую программу: 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) */ } } } Можно подумать, что если мы прочитали значение
С точки зрения программы мы говорим, что произошли Давайте лично убедимся в том, что такие переупорядочивания возможны, написав jcstress тест: ("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% случаев мы получили неконсистентный с порядком в программе результат. Далее мы доведем эту программу до полной корректности, используя happens-before. Нашей целью будет связать доступ к этим переменным с помощью happens-before, чтобы избавиться от гонок и добиться только sequentially consistent выполнений. Monitor lock Monitor lock не только предоставляет happens-before между освобождением и взятием лока, но также является и мьютексом, который позволяет обеспечить эксклюзивный доступ к критической секции. Каждый объект в Java содержит внутри себя такой лок, но его нельзя использовать напрямую — чтобы воспользоваться им, необходимо применить keyword Вот как мы можем исправить приведенную выше программу с помощью монитора: 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 */ } } } } В данном примере мы используем монитор объекта Таким образом, мы имеем happens-before как при доступе к monitorEnter() -> write(x, 5) -> write(initialized, true) -> monitorExit() -> monitorEnter() -> read(initialized):true -> read(x):5 -> monitorExit() // (initialized, x) = (true, 5) monitorEnter() -> read(initialized):false -> monitorExit() -> monitorEnter() -> write(x, 5) -> write(initialized, true) -> monitorExit() // (initialized, x) = (false, _) Volatile Volatile предоставляет happens-before гарантию между записью и чтением из Вот так с помощью 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 */ } } } В данном примере мы синхронизируемся на Таким образом, аналогично мы имеем happens-before как при доступе к write(x, 5) -> write(initialized, true) -> read(initialized):true -> read(x):5 // (initialized, x) = (true, 5) write(x, 5) -> read(initialized):false -> write(initialized, true) // (initialized, x) = (false, _) read(initialized):false -> write(x, 5) -> write(initialized, true) // (initialized, x) = (false, _) Здесь по сравнению с предыдущим примером возможных SC порядков побольше, так как нет эксклюзивного захвата лока, но итоговый сет результатов все равно остается таким же. Safe Publication/RA semantics Наверняка вы заметили, что в обоих примерах прослеживается некая идиома: делаем все необходимые изменения до некоторого синхронизирующего треды действия Можно сказать, что release действие паблишит все изменения другим тредам, а acquire действие принимает эти изменения. Если говорить более формально, то release семантика запрещает переупорядочивание release действия с записями, идущими в порядке программе ранее (StoreStore), а acquire семантика запрещает переупорядочивание acquire действия с чтениями, идущими в порядке программы позже (LoadLoad). Конечно, это всего лишь высокоуровневая интерпретация свойств happens-before и таких терминов вы не найдете в спеке, но для простоты понимания можно вполне мыслить в рамках "безопасной публикации". Именно благодаря тому, что happens-before порядок не требует строгого порядка для обычных записей/чтений, все действия до release или после acquire могут быть переупорядочены под капотом как угодно. Но главное то, что они не будут переупорядочены с самим release/acquire действием. Например, если мы имеем действия Безопасная публикация распространяется и на объекты — взгляните на следующий пример: public class JmmReorderingObjectExample { private Foo instance; private static class Foo { private int x; Foo() { this.x = 5; /* W1 */ } } public void writer() { instance = new Foo(); /* W2, non-safe publish */ } public void reader() { Foo r1 = instance; /* R1 */ if (r1 != null) { int r2 = r1.x; /* R2: may be default value (0) */ } } } В данной программе мы можем наблюдать дефолтное значение на чтении Но как только мы пометим переменную write(x, 5) --hb-> write(volatile instance, ref) --hb-> read(volatile instance):ref -> read(x): 5 monitorEnter() --hb-> write(x, 5) --hb-> write(instance, ref) --hb-> monitorExit() --hb-> monitorEnter() --hb-> read(instance):ref --hb-> read(x): 5 --hb-> monitorExit() Смотрите jcstress тесты, наглядно демонстрирующие безопасную публикацию объектов — https://github.com/blinky-z/JmmArticleHabr/tree/main/jcstress/tests/object. Как видите, пользоваться happens-before достаточно просто. Анализируйте свою программу: в большинстве случаев применима именно безопасная публикация, которая гарантирует вам видимость всех сделанных изменений. И только в редких случаях вам придется связать все действия в Synchronization Order как в случае с Dekker локом. В любом случае, оба способа гарантируют вам отсутствие гонок и sequentially consistent результаты. Happens-before is about actions После того как мы рассмотрели работу happens-before на практике, я бы хотел подробнее остановиться на одном важном моменте. Может кому-то все далее написанное покажется очевидным, но разобраться не помешает. Вы уже достаточно много раз увидели слово "действие" (action) пока читали раздел про happens-before, но скорее всего смысл его не до конца очевиден. Так вот, под "действием" спека имеет в виду результат выполнения выражения в рантайме, а не само статическое выражение в коде (statement). Например, Теперь следует сказать, что happens-before (и все остальные порядки в JMM) — это именно о действиях (actions), которые имеют какой-то результат, а не об абстрактных выражениях в программе. Давайте еще раз взглянем на определение happens-before для треда в изоляции: If
actions
Как видите, здесь говорится именно о действиях (actions): два любых действия в одном и том же треде связаны happens-before, если их соответствующие выражения идут последовательно в порядке программы. Приведу отдельный пример: happens-before существует не между выражениями А вот в случае A write to a volatile variable
subsequent Обратите внимание на слово "subsequent" — это очень важная деталь, которая различает действия и выражения. Определение говорит, что чтение должно обнаружить эту запись, чтобы между записью и чтением Именно поэтому даже в случае использования Да, все это звучит банально, но я лишь хотел предостеречеть вас от написания такой программы: public class VolatileNotAlwaysHappensBefore { private int x; private volatile boolean initialized; public void writer() { x = 5; /* W1 */ initialized = true; /* W2 */ } public void reader() { boolean r1 = initialized; /* R1 */ int r2 = x; /* R2, ??? 5 or 0 */ } } Где вы могли бы неверно рассуждать, что на write(x, 5) --hb-> write(initialized, true) --hb-> read(initialized):true --hb-> read(x):5 А вот так нет: write(x, 5) --hb-> write(initialized, true) ... read(initialized):false --hb-> read(x):? Подводя итог, этим разделом я хотел сказать следующее:
Cache Coherence В самом начале статьи я уже затрагивал тему Cache Coherence, а теперь разберемся в ней подробнее. Перед тем как идти дальше, рассмотрим устройство кэша на базовом уровне:
Из-за того, что ядра имеют собственный локальный кэш, возникает потенциальная проблема чтения неактуальных значений. Например, пусть два ядра прочитали одно и то же значение из памяти и сохранили в свой локальный кэш. Затем первое ядро записывает новое значение в свой локальный кэш, но другое ядро не видит этого изменения и продолжает читать устаревшее значение. Как итог, данные среди локальных кэшей не консистентны. Если бы в процессоре существовал только общий кэш, то проблемы чтения неактуальных значений просто не существовало бы: так как все записи и чтения проходят через кэш, а не идут напрямую в память, то общий кэш по сути был бы master копией памяти, где всегда лежали бы актуальные значения. Но это сильно ударило бы по производительности процессора, так как кэш может обрабатывать только один цикл единовременно, а значит ядра простаивали бы в очереди. Более того, локальный кэш распаян физически ближе к ядру, поэтому доступ к нему стоит дешевле. Именно поэтому и необходим локальный кэш, чтобы каждое ядро могло эффективно работать с кэшем независимо от других ядер. На самом деле, процессоры умеют поддерживать консистентность данных среди локальных кэшей так, что любое из ядер всегда читает актуальное значение одного и того же адреса памяти. Cache Coherence (когерентность кэша) — это механизм процессора, гарантирующий, что любое ядро всегда читает самое актуальное значение из кэша. Данным механизмом обладают многие современные архитектуры процессоров в той или иной имплементации. Самый популярный из протоколов — это MESI протокол и его производные. Например, Intel использует MESIF, а AMD — MOESI протокол. В MESI протоколе линия кэша может находиться в одном из следующих состояний:
Когда одно из ядер процессора хочет изменить линию кэша, то оно должно установить exclusive доступ к ней. Для этого ядро посылает всем остальным ядрам сообщение о том, что указанную линию кэша необходимо пометить как invalid в их локальном кэше. Только после того, как ядра обработают запрос, пометив свою копию как invalid, ядро сможет записать новое значение вместе с этим помечая линию кэша как modified. Таким образом, при записи только одно ядро может удерживать значение в локальном кэше, а значит неконсистентность данных просто невозможна. Когда любое ядро хочет прочитать какой-нибудь адрес в памяти, то алгоритм действий выглядит так:
Это очень упрощенное описание работы кэша — я опустил многие детали, но надеюсь, что примерная картина вам понятна. Скажу сразу, что я не претендую на полную корректность вышенаписанного: где-то я мог и соврать, ибо не являюсь специалистом в такой низкоуровневой теме как процессоры. Более того, многие моменты могут отличаться в зависимости от микроархитектуры процессора и используемого Cache Coherence протокола. В конце статьи я приведу ссылки на другие полезные источники, где вы сможете узнать подробнее о работе кэша. Таким образом, как только значение попадает в локальный кэш, оно сразу же становится видно другим ядрам. Теперь наверняка у вас возник закономерный вопрос: так что же, значит visibility проблемы на уровне процессора не существует? На самом деле, не все так просто. Invalidation Queue Когда ядро получает запрос на инвалидацию записи в кэше, он может быть обработан не сразу, а поставиться в очередь Invalidation Queue (IQ). Эта оптимизация необходима по следующим причинам: во-первых, ядро может быть занято другой работой, и во-вторых, мы хотим, чтобы при большом количестве запросов ядро не заблокировалось на долгое время в их обработке, а обработало все постепенно. Таким образом, можно сказать, что invalidate запросы являются асинхронными Проблема в том, что мы рискуем не прочитать самое актуальное значение просто потому, что запрос в invalidation queue еще не был обработан, а в кэше лежало еще не инвалидированное, но уже устаревшее значение. Например: Как видите, мы прочитали устаревшее значение, хотя запрос на invalidate уже пришел. Store Buffer В некоторых микро-архитектурах (как x86) каждое ядро имеет локальный FIFO Store Buffer (SB, write buffer), который является прослойкой между CPU и кэшем. В этот буфер ядро кладет все записи, которые будут ожидать там сброса в локальный кэш до тех пор, пока все остальные ядра не инвалидируют эту запись в своем кэше и не пришлют acknowledgement. Эта оптимизация требуется для того, чтобы не задерживать работу пишущего ядра, пока остальные ядра обрабатывают запрос на инвалидацию. При чтении ядро сперва смотрит в свой SB перед тем, как идти в локальный кэш, чтобы избежать чтения неактуальных значений и таким образом поддержать as-if-serial гарантию внутри одного ядра Проблема в том, что другие ядра не увидят новой записи, пока пишущее ядро не сбросит запись из SB в локальный кэш, так как SB — это часть ядра, но не кэша. Другими словами, Cache Coherence механизм не распространяется на Store Buffer. Соответственно, некоторый промежуток времени пишущее ядро будет оперировать актуальным значением, но все остальные — устаревшим. Например: Как видите, CORE 0 произвело запись в Итак, ядра действительно всегда видят актуальное значение, но только кроме короткого временного окна после записи. Другими словами, нам гарантируется eventual visibility изменений. В заключение приведу полное устройство кэша: Eventual Visibility Можно наивно предположить, что благодаря Cache Coherence нам гарантируется eventual visibility и на уровне Java для обычных записей и чтений, то есть не связанных happens-before. Однако, это не правда, так как мы работаем на уровне языка, а не процессора. Компилятор может оптимизировать код так, что запись никогда не станет видна другому треду. Яркий пример — это такой busy wait, где в бесконечном цикле проверяется значение shared переменной. JCStress уже имеет готовый тест для этого случая — BasicJMM_04_Progress#PlainSpin: @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 Как видите, в половине случаев тред завис навсегда. Это произошло по той причине, что компилятор оптимизировал цикл Исправить этот пример можно пометив переменную как volatile или работая с переменной под монитором — только в этом случае нам гарантируется eventual visibility изменений. Таким образом, пока мы работаем с обычными записями и чтениями, не связанными отношением happens-before, нам не гарантируется видимость изменений, сделанных из других тредов. Memory Barriers Процессор может переупорядочивать выполняемые им инструкции, даже если на уровне компилятора мы обеспечили необходимый порядок. Хотя процессор делает только такие переупорядочивания, которые не меняют итогового результата, но это гарантируется только для единственного ядра в изоляции, поэтому переупорядочивание может повлиять на другие ядра. Более того, все еще существует проблема видимости изменений, которую мы обсудили выше. Именно поэтому JMM ответственна и за синхронизацию на уровне процессора. Для решения этих проблем Java использует готовые низкоуровневые механизмы синхронизации под названием "memory barrier", предоставляемые самим процессором. Задача барьеров памяти — запретить (memory) переупорядочивания, которые обычно разрешены моделью памяти процессора. Таким образом, точно так же как мы используем примитивы синхронизации Memory barrier (memory fence, барьер памяти) — это тип процессорной инструкции, которая заставляет процессор гарантировать memory ordering для инструкций, работающих с памятью. Всего существует 4 типа барьеров памяти — они напрямую матчатся в возможные memory reordering и запрещают каждый из них:
То, как имплементированы барьеры — это дело процессора. К примеру, они могут запрещать переупорядочивание инструкций и ожидать полной обработки Store Buffer/Invalidation Queue, но мы не знаем точной имплементации. На самом деле, знание таких деталей и не нужно — мы просто мыслим в терминах Memory Ordering и тех гарантий порядка, которые дают нам барьеры. Соответствующие процессорные инструкции или отображаются 1-в-1 в эти типы, или же объединяют в себе сразу несколько типов барьеров. Все процессоры имеют как минимум одну full memory barrier инструкцию, которая объединяет в себя сразу все типы барьеров, запрещая memory reordering как load, так и store инструкций вокруг барьера. Например, на x86 мы имеем mfence и lock prefix, которые являются full memory barrier. Однако процессоры могут предоставлять и более дешевые, гранулярные барьеры памяти. Обычно Load- и Store- барьеры используются в паре: Store барьер гарантирует, что записи будут видны другому ядру, а Load барьер гарантирует, что чтения будут выполнены в необходимом порядке. Например, вот как мы можем исправить уже знакомый нам по вступлению пример с помощью барьера: Если мы поставим Давайте лично убедимся в наличии барьеров под капотом Java на примере
Так, давайте здесь прервемся и проясним несколько моментов:
Теперь продолжаем. Пусть есть такая простая программа с использованием 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 и посмотрим на сгенерированный JIT-компилятором нативный код. Запускаем дизассемблер на Intel Core i7-11700 (x86), Windows 10 x64, OpenJDK 17. Вот сгенерированный ASM код для [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) В Наверняка у вас возник вопрос: где же
Из этого следует, что нет необходимости использовать Теперь посмотрим на ASM код для [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) И снова заметим, что барьеры Как JMM обеспечивает консистентный memory order: подводим итоги Итак, давайте резюмируем, что делает JMM на каждом из уровней, чтобы правильно синхронизированная программа не имела переупорядочиваний. Happens-before — это конечно хорошо, но это всего лишь абстракция. А вот на нижнем уровне компилятора и хардвара JMM на самом деле делает следующее:
Первые два уровня зависят полностью от самой Java — именна она имплементирует гарантию порядка. Уровень процессора же зависит не только от Java, но и от самого процессора, который предоставляет и имплементирует барьеры памяти. JMM: Atomicity Важная часть JMM, которую я не упоминал ранее, это атомарность некоторых базовых действий. А именно:
Что же нам дают эти свойства в многопоточной среде? Нам гарантируется, что при shared чтении переменной мы увидим или значение по умолчанию ( Но почему мы вообще могли бы прочитать половинное значение? Дело в том, что некоторые типы в языке имеют размер (в битах) больший, чем длина машинного слова процессора. Например, 32-х битный процессор оперирует словами по 32 бита, но тип long/double содержит 64 бита. Соответственно, языку требуется совершить 2 записи по 32 бит, чтобы полностью записать значение. Из JLS §17.7. Non-Atomic Treatment of double and long: For the purposes of the Java programming language memory model, a single write to a non-volatile
JCStress имеет готовый тест для этого случая — BasicJMM_02_AccessAtomicity.java: (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... Как видите, в некоторых случаях мы увидели неконсистентное состояние переменной. То есть мы наблюдали переменную прямо посередине записи — Один из способов обеспечить атомарность записи и чтения для long/double — это пометить переменную как JMM: final fields JMM дает очень полезную гарантию порядка и видимости записей для Из спеки JLS §17.5. final Field Semantics: 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 fields.
Это очень сильная гарантия, которая полностью избавляет нас от проблем memory reordering при чтении состояния объекта. Обычно интерпретация этих свойств звучит как Safe Initialization (безопасная инициализация), которая по сути дает нам всегда безопасную пабликацию (safe publication). Обычно под капотом эта гарантия имплементируется с помощью
Таким образом, вот так JVM создает объект с Object _obj = <new> // memory allocation _obj.f = 5; // write final field in constructor [StoreStore] obj = _obj; // publish Благодаря тому, что Читаем объект мы следующим образом: Object _obj = obj; [LoadLoad] r1 = _obj.f; // read final field Здесь Однако как и в случае с Интересно, что благодаря такой имплементации, которая, например, используется в HotSpot JVM (см. http://hg.openjdk.java.net/jdk/jdk/file/ee1d592a9f53/src/hotspot/share/opto/parse1.cpp#l1001), нам неявно гарантируется видимость и всех остальных non- Семантика Давайте рассмотрим использование 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) */ } } } Интересные наблюдения:
Benign data races Интересно, что наличие data race не всегда плохо, если это не влияет на корректность программы, а в некоторых случаях гонка даже является намеренной. Такие гонки называются benign data race (безопасная гонка). Не будем далеко ходить за примером — взгляните на имплементацию String#hashCode() из 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; } } Как видите, поля
Однако из обоих ситуаций мы аккуратно восстанавливаемся. В первой ситуации мы повторно вычисляем и записываем значения полей. Это валидно, так как результат вычисления Вторую ситуацию мы обрабатываем путем вычитывания Это очень тонкий момент, который может быть сложен для понимания. Представим, что мы убрали локальную переменную и сразу читаем shared переменную Сравните — без volatile валидны следующие выполнения: write(hash, val) --race-> read(hash):val --hb-> read(hash): 0 write(hash, val) --race-> read(hash):val --hb-> read(hash): val А с volatile валидно только такое выполнение: write(hash, val) --hb-> read(hash):val --hb-> read(hash):val Happens-before для действий в одном и том же треде здесь никак не помогает, потому что это независимые чтения. Как я уже говорил, happens-before не означает, что инструкции будут действительно выполняться в таком порядке, если это не нарушает happens-before. И да, переменную Если вы хотите убедиться в том, что LoadLoad переупорядочивание возможно даже для одной и той же переменной, взгляните на данный jcstress тест — https://github.com/blinky-z/JmmArticleHabr/blob/main/jcstress/tests/object/JmmReorderingObjectSameReadNullTest.java. Как вы уже поняли, сделать правильно безопасную гонку нужно уметь. Если вы не поняли ничего из вышенаписанного или не уверены в своем случае, лучше ставьте везде где только можно Заключение Надеюсь, данная статья дала вам некоторое понимание JMM, а полученные знания помогут вам писать безопасные и корректные многопоточные программы. Хотя я и привёл здесь много низкоуровневой информации, но на самом деле запоминать такие детали совершенно не обязательно — я лишь хотел дать вам более глубокое понимание того, что происходит под капотом JMM. Просто пользуйтесь предоставленными примитивами синхронизации, а JMM сделает все за вас, ведь она создана как раз с той целью, чтобы скрыть, абстрагировать нижние уровни и предоставить гарантии, избавляющие вас от проблем memory reordering. Пользуйтесь JMM, и да пребудет с вами thread safety. P.S: и запомните: data races are evil. Ресурсы Обратите внимание на репозиторий в поддержку данной статьи — https://github.com/blinky-z/JmmArticleHabr. Там вы сможете найти еще больше, не включенных в статью jcstress тестов и дизассемблированных программ, а также инструкции и результаты запуска тестов и дизассемблера на x86/arm64. Основы:
Memory Model:
Блог Алексея Шипилева — это целая кладезь знаний про JMM и не только. Крайне советую прочитать следующие его статьи:
Compiler Memory Ordering:
CPU Memory ordering/Memory Barrier:
CPU Cache:
Volatile:
Книги:
Источник Источник: m.vk.com Комментарии: |
|