Модель памяти в примерах и не только
В продолжение серии топиков под названием «фундаментальные вещи о Java, которые стоит знать, но которые многие не знают». Предыдущий топик: Бинарная совместимость в примерах и не только
Модель памяти Java — нечто, что оказывает влияние на то, как работает код любого java-разработчика. Тем не менее, довольно многие пренебрегают знанием этой важной темы, и порой наталкиваются на совершенно неожиданное поведение их приложений, которое объясняется именно особенностями устройства JMM. Возьмём для примера весьма распространённую и некорректную реализацию паттерна Double-checked locking:
Люди, пишущие подобный код, пытаются добиться улучшения производительности, избегая блокировки, если значение уже было присвоено. К сожалению, эти люди не учитывают многих факторов, в результате проявления которых может случиться зомби-апокалипсис. Под катом я расскажу теорию и приведу примеры того, как что-то может пойти не так. Кроме того, как говорили в одном индийском фильме, «Мало знать, что не так. Нужно знать, как сделать так, чтобы было так». Потому и рецепты успеха вы также сможете найти дальше.
Немножко истории
Первая версия JMM появилась вместе с Java 1.0 в 1995 году. Это была первая попытка создать непротиворечивую и кросс-платформенную модель памяти. К сожалению, или к счастью, в ней было несколько серьёзных изъянов и непоняток. Одной из наиболее печальных проблем было отсутствие каких-либо гарантий для final полей. То есть, один поток мог создать объект с final-полем, а другой поток мог значения в этом final-поле не увидеть. Этому был подвержен даже класс java.lang.String . Кроме того, эта модель не давала компилятору возможности производить многие эффективные оптимизации, а при написании многопоточного кода сложно было быть уверенным в том, что он действительно будет работать так, как это ожидается.
Потому в 2004 году в Java 5 появилась JSR 133, в которой были устранены недостатки первоначальной модели. О том, что получилось, мы и будем говорить.
Atomicity
Хотя многие это знают, считаю необходимым напомнить, что на некоторых платформах некоторые операции записи могут оказаться неатомарными. То есть, пока идёт запись значения одним потоком, другой поток может увидеть какое-то промежуточное состояние. За примером далеко ходить не нужно — записи тех же long и double, если они не объявлены как volatile, не обязаны быть атомарными и на многих платформах записываются в две операции: старшие и младшие 32 бита отдельно. (см. стандарт)
Visibility
В старой JMM у каждого из запущенных потоков был свой кеш (working memory), в котором хранились некоторые состояния объектов, которыми этот поток манипулировал. При некоторых условиях кеш синхронизировался с основной памятью (main memory), но тем не менее существенную часть времени значения в основной памяти и в кеше могли расходиться.
В новой модели памяти от такой концепции отказались, потому что то, где именно хранится значение, вообще никому не интересно. Важно лишь то, при каких условиях один поток видит изменения, выполненные другим потоком. Кроме того, железо и без того достаточно умно, чтобы что-то кешировать, складывать в регистры и вытворять прочие операции.
Важно отметить, что, в отличие от того же C++, «из воздуха» (out-of-thin-air) значения никогда не берутся: для любой переменной справедливо, что значение, наблюдаемое потоком, либо было ранее ей присвоено, либо является значением по умолчанию.
Reordering
Но и это, как говорится, ещё не всё. Если вы сделаете заказ прямо сейчас, то ваши инструкции переставят местами совершенно бесплатно! Процессоры проявляют невероятную проворность в оптимизации исполнения инструкций. В этом им также помогает компилятор и JIT. Одним из примечательных эффектов может оказаться то, что действия, выполненные одним потоком, другой поток увидит в другом порядке. Эту фразу довольно сложно понять, просто прочитав, потому приведу пример. Пусть есть такой код:
И в этом коде из одного потока вызывается метод checkValues , а из другого потока — setValues . Казалось бы, код должен выполняться без проблем, ведь полю second значение true присваивается позже, чем полю first , и потому когда (точнее, если) мы видим, что, второе поле истинно, то и первое тоже должно быть таким. Но не тут-то было.
Хотя внутри одного потока об этом можно не беспокоиться, в многопоточной среде результаты операций, произведённых другими потоками, могут наблюдаться не в том порядке. Чтобы не быть голословным, я хотел добиться того, чтобы на моей машине сработал assertion, но мне это не удавалось настолько долго ( нет, я не забыл указать при запуске ключ -ea ), что, отчаявшись, я обратился с вопросом «а как же всё-таки спровоцировать reordering» к небезызвестным перформанс-инженерам. Так на мой вопрос ответил Сергей Куксенко:
На машинах с TSO (к коим относится x86) довольно сложно показать
ломающий reordering. Это можно показать на каком-нибудь ARM’е или
PowerPC. Еще можно сослаться на Альфу — процессор с самыми слабыми правилами ордеринга. Альфа — это был ночной кошмар разработчиков компиляторов и ядер операционной системы. Счастье, что он таки умер. В сети можно найти массы историй об этом.
Классический пример:
(пример аналогичен приведённому выше — прим. автора)
… на x86 будет отрабатывать корректно всегда, ибо если вы увидели
стор в «b», то увидите и стор в «a».
А когда я сказал, что хотел бы для каждого рассмотренного в статье аспекта найти работающую демонстрацию того, что происходит, Сергей порадовал меня, сказав что необходимо долго и усердно читать мануалы по соответствующему железу. Какое-то время я подумывал попробовать добиться эффекта на телефоне, но в итоге решил, что это не настолько важно. К тому же, я всё-таки специализируюсь вовсе не на особенностях каких-то конкретных платформ.
Итак, вернёмся к нашему изначальному примеру и поймём, как может его испортить reordering. Пусть наш класс Data в конструкторе выполняет какие-то не очень тривиальные вычисления и, главное, записывает какие-то значения в не final поля:
- Выделит память под новый объект
- Вызовет конструктор класса Data
- Запишет значение 42 в поле answer класса Data
- Запишет какую-то строку в поле question класса Data
- Запишет значение 9000 в поле maxAllowedValue класса Data
- Запишет только что созданный объект в поле data класса Keeper
Happens-before
Определение
Пусть есть поток X и поток Y (не обязательно отличающийся от потока X). И пусть есть операции A (выполняющаяся в потоке X) и B (выполняющаяся в потоке Y).
В таком случае, A happens-before B означает, что все изменения, выполненные потоком X до момента операции A и изменения, которые повлекла эта операция, видны потоку Y в момент выполнения операции B и после выполнения этой операции.
На словах такое определение, возможно, воспринимается не очень хорошо, потому немного поясню. Начнём с самого простого случая, когда поток только один, то есть X и Y — одно и то же. Внутри одного потока, как мы уже говорили, никаких проблем нет, потому операции имеют по отношению к друг другу happens-before в соответствии с тем порядком, в котором они указаны в исходном коде (program order). Для многопоточного случая всё несколько сложнее, и тут без… картинки не разобраться. А вот и она:
Здесь слева зелёным помечены те операции, которые гарантированно увидит поток Y, а красным — те, что может и не увидеть. Справа красным помечены те операции, при исполнении которых ещё могут быть не видны результаты выполнения зелёных операций слева, а зелёным — те, при исполнении которых уже всё будет видно. Важно заметить, что отношение happens-before транзитивно, то есть если A happens-before B и B happens-before C, то A happens-before C.
Операции, связанные отношением happens-before
Посмотрим теперь, что же именно за ограничения на reordering есть в JMM. Глубокое и подробное описание можно найти, например, в The JSR-133 Cookbook, я же приведу всё на несколько более поверхностном уровне и, возможно, пропущу некоторые из ограничений. Начнём с самого простого и известного: блокировок.
1. Освобождение (releasing) монитора happens-before заполучение (acquiring) того же самого монитора. Обратите внимание: именно освобождение, а не выход, то есть за безопасность при использовании wait можно не беспокоиться.
Посмотрим, как это знание поможет нам исправить наш пример. В данном случае всё очень просто: достаточно убрать внешнюю проверку и оставить синхронизацию как есть. Теперь второй поток гарантированно увидит все изменения, потому что он получит монитор только после того, как другой поток его отпустит. А так как он его не отпустит, пока всё не проинициализирует, мы увидем все изменения сразу, а не по отдельности:
2. Запись в volatile переменную happens-before чтение из той же самой переменной.
То изменение, которое мы внесли, конечно, исправляет некорректность, но возвращает того, кто написал изначальный код, туда, откуда он пришёл — к блокировке каждый раз. Спасти может ключевое слово volatile. Фактически, рассматриваемое утверждение (2) значит, что при чтении всего, что объявлено volatile, мы всегда будем получать актуальное значение. Кроме того, как я говорил раньше, для volatile полей запись всегда (в т.ч. long и double) является атомарной операцией. Ещё один важный момент: если у вас есть volatile сущность, имеющая ссылки на другие сущности (например, массив, List или какой-нибудь ещё класс), то всегда «свежей» будет только ссылка на саму сущность, но не на всё, в неё входящее.
Итак, обратно к нашим Double-locking баранам. С использованием volatile исправить ситуацию можно так:
Тут у нас по-прежнему есть блокировка, но только в случае, если data == null . Остальные случаи мы отсеиваем, используя volatile read. Корректность обеспечивается тем, что volatile store happens-before volatile read, и все операции, которые происходят в конструкторе, видны тому, кто читает значение поля.
Кроме того, тут используется интересное предположение, которое стоит проверить: volatile store + read быстрее, чем блокировка. Однако, как неустанно повторяют нам всё те же инженеры производительности, микробенчмарки имеют мало отношения с реальностью, особенно если вы не знаете, как устроено то, что вы пытаетесь измерить. Более того, если вы думаете, что знаете, как оно устроено, то вы, скорее всего, ошибаетесь и не учитываете какие-нибудь важные факторы. У меня нет достаточной уверенности в глубине своих познаний, чтобы производить свои бенчмарки, поэтому таких замеров тут не будет. Впрочем, некоторая информация по производительности volatile есть в этой презентации начиная со слайда #54 (хотя я настойчиво рекомендую прочитать всё). UPD: есть интересный комментарий, в котором говорят, что volatile существенно быстрее синхронизации, by design.
3. Запись значения в final -поле (и, если это поле — ссылка, то ещё и всех переменных, достижимых из этого поля (dereference-chain)) при конструировании объекта happens-before запись этого объекта в какую-либо переменную, происходящая вне этого конструктора.
Это тоже выглядит довольно запутанно, но на самом деле суть проста: если есть объект, у которого есть final -поле, то этот объект можно будет использовать только после установки этого final -поля (и всего, на что это поле может ссылаться). Не стоит, впрочем, забывать, что если вы передадите из конструктора ссылку на конструируемый объект (т.е. this ) наружу, то кто-то может увидеть ваш объект в недостроенном состоянии.
В нашем примере оказывается, что достаточно сделать поле, запись в которое происходит последней, final , как всё магически заработает и без volatile и без синхронизации каждый раз:
Только в том-то и соль, что заработает оно именно магически, и человек, который не знает о вашем хитроумном приёме, может вас не понять. Да и вы тоже можете о таком довольно быстро позабыть. Есть, конечно же, вариант добавить горделивый комментарий типа «neat trick here!», описывающий, что же тут происходит, но мне это почему-то кажется не очень хорошей практикой.
UPD: Это неправда. В комментариях описано, почему. UPD2: По результатам обсуждения вопроса Руслан написал статью.
Кроме того, важно помнить, что поля бывают ещё и статические, а что инициализацию классов JVM гарантированно выполняет лишь один раз при первом обращении. В таком случае, тот же синглетон ( не будем в рамках данной статьи называть его ни паттерном, ни антипаттерном. Статья ведь совсем не об этом 😉 ) можно реализовать вот так:
Это, конечно, не является советом к тому, как нужно реализовывать синглетон, поскольку все, читавшие Effective Java, знают, что если вы совершенно неожидано по какой-то причине вдруг зачем-то решили его написать, то лучше всего использовать enum и получить из коробки решение всех проблем и с многопоточностью, и с сериализацией, и с клонированием. UPD: По поводу того, как лучше реализовать singleton, можно почитать этот топик.
Кстати, тем, кто знает, что final-поля можно изменить через Reflection и заинтересовавшимся, как такие изменения будут видны, могу сказать вот что: «всё, кажется, будет хорошо, только непонятно, почему, и непонятно, действительно ли всё и действительно ли хорошо». Есть несколько топиков на эту тему, наиболее интерен этот. Если кто-нибудь расскажет в комментариях, как оно на самом деле, я буду крайне рад. Впрочем, если никто не расскажет, то я и сам выясню и обязательно расскажу. UPD: В комментариях рассказали.
Наверняка есть ещё какие-то операции, связанные отношением happens-before, которые я не осветил в данной статье, но они уже гораздо более специфичны, и при наличии интереса вы можете их найти сами в стандарте или где-то ещё, а затем поделиться со всеми в комментариях.
Credits, links and stuff
В первую очередь хотелось бы поблагодарить за некоторые консультации и предварительную проверку статьи на содержание клинического бреда упомянутых ранее инженеров производительности: Алексея TheShade Шипилёва и Сергея Walrus Куксенко.
Java Specialist
Мне часто приходится объяснять почему тот или иной код иногда может не работать. Чтобы доходчиво объяснить почему это происходит, и что надо сделать, чтобы он заработал, желательно понимать основные принципы модели памяти Java. Когда заходит речь о Java Memory Model (JMM), я считаю бесчеловечным отправлять человека читать спецификацию, так как там черт ногу сломит. На самом деле JMM очень хорошо описана в книге Brian Goetz «Java Concurrency in Practice», но, во-первых, её надо иметь, а, во-вторых, там тоже нужно прочитать не одну страничку. Поэтому я и решил описать вкратце JMM, чтобы всегда можно было сюда сослаться.
Две основные вещи, которые вводят энтропию в многопоточный код — это reordering и visibility.
Видимость (visibility)
Один поток может в какой-то момент временно сохранить значение некоторых полей не в основную память, а в регистры или локальный кэш процессора, таким образом второй поток, выполняемый на другом процессоре, читая из основной памяти, может не увидеть последних изменений поля. И наоборот, если поток на протяжении какого-то времени работает с регистрами и локальными кэшами, читая данные оттуда, он может сразу не увидеть изменений, сделанных другим потоком в основную память.
Reordering
Для увеличения производительности процессор/компилятор могут переставлять местами некоторые инструкции/операции. Вернее, с точки зрения потока, наблюдающего за выполнением операций в другом потоке, операции могут быть выполнены не в том порядке, в котором они идут в исходном коде.
Так же эффект реордеринга может наблюдаться, когда один поток кладет результаты первой операции в регистр или локальный кэш, а результат второй операции кладет непосредственно в основную память. Тогда второй поток, обращаясь к основной памяти может сначала увидеть результат второй операции, и только потом первой, когда все регистры или кэши синхронизируются с основной памятью.
Еще одна причина reordering, может заключаться в том, что процессор может решить поменять порядок выполнения операций, если, например, сочтет что такая последовательность выполнится быстрее.
На практике это может иметь очень печальные последствия. После всего вышесказанного, думаю, проблема в коде ниже не нуждается в пояснениях:
Тут еще важно отметить, что для выполнения операций в рамках одного потока, спецификация JVM разрешает делать только такой reordering, который приводит к абсолютно тем же результатам, если бы все операции выполнялись в порядке указанном в исходном коде с точки зрения потока, в котором этот код выполняется. Т.е. в одном потоке reordering никогда не проявляется.
Happend-before
В Java Memory Model введена такая абстракция как happens-before. Она обозначает, что если операция X связана отношением happens-before с операцией Y, то весь код следуемый за операцией Y, выполняемый в одном потоке, видит все изменения, сделанные другим потоком, до операции X.
Список операций связанных отношением happens-before:
- В рамках одного поток любая операция happens-before любой операцией следующей за ней в исходном коде
- Освобождение лока (unlock) happens-before захват того же лока (lock)
- Выход из synhronized блока/метода happens-before вход в synhronized блок/метод на том же мониторе
- Запись volatile поля happens-before чтение того же самого volatile поля
- Завершение метода run экземпляра класса Thread happens-before выход из метода join() или возвращение false методом isAlive() экземпляром того же треда
- Вызов метода start() экземпляра класса Thread happens-before начало метода run() экземпляра того же треда
- Завершение конструктора happens-before начало метода finalize() этого класса
- Вызов метода interrupt() на потоке happens-before когда поток обнаружил, что данный метод был вызван либо путем выбрасывания исключения InterruptedException, либо с помощью методов isInterrupted() или interrupted()
Очень важный момент: как освобождение/захват монитора, так и записать/чтение в volatile переменную связаны отношением happens-before, только если операции проводятся над одним и тем же экземпляром объекта.
Так же важно понимать, что в отношении happens-before участвуют только два потока, о видимости и reordering остальных потоков ничего сказать нельзя, пока в каждом из них не наступит отношение happens-before с к другим потоком.
Еще в отношении happens-before есть очень большой дополнительный бонус: данное отношение дает не только видимость volatile полей или результатов операций защищенных монитором или локом, но и видимость вообще всего-всего, что делалось до события hapens-before.

Так на рисунке выше ThreadB гарантированно увидит изменение в поле y , сделанное ThreadA, хотя он не является volatile и запись в него происходит вне synchronized блока.
Вышеописанные свойства могут достигаться JVM следующим образом: в момент отпускания монитора (записи в volatile переменную и дальше по списку) все регистры и локальные кэши процессора синхронизируются с основной памятью, а в момент последующего захвата лока (чтения volatile переменной и т.д.) процессор на котором выполняется второй поток инвалидирует свой кэш и зачитывает все последние данные из основной памяти. Почему же тогда надо обязательно синхронизироваться на один и тот же монитор, спросите вы? Да потому, что только в этом случае будет гарантироваться строгий порядок, т.е. второй поток гарантировано сбросит свой кэш, только после того как первый синхронизирует свой с основной памятью.
Отношение happens-before так же накладывает сильные ограничения на reordering. С точки зрения потока Y все операцие произошедшие до точки happens-before в потоке X он может рассматривать как операции свершившиеся в своем собственном потоке. Т.е. никакого логического reordering по сравнению с прямым порядком в исходном коде с точки зрения потока Y быть не может.
Если взглянуть внимательнее на границу happens-before с точки зрения reordering для потока Y, то никакие операции располагающиеся выше границы happens-before в потоке X, не могут выполнится ниже границы happens-before в результате reordering, однако, операциям, находящимся ниже границы, разрешено выполнение до неё. Более наглядно это изображено на рисунке.

Публикация объектов
Публикацией объектов называется явление, когда один поток создает объект и присваивает на него ссылку какому-нибудь полю, которое может увидеть второй поток. Если запись в это поле первым потоком, разделена со чтением этого поля вторым потоком отношением happens-before, то публикация называется безопасной, т.е. второй поток увидит все поля опубликованного объекта, инициализированные первым потоком.
Однако, есть еще один способ добиться безопасной публикации объектов: если ссылка на объект, все поля которого являются final, становится видимой любому потоку, то данный поток видит все final поля, инициализированные во время создания объекта. Более того он будет видеть все значения достижимые из final полей. Рассмотрим пример ниже:
Если кроме как ссылок final на ваши объекты никто не ссылается, то не зависимо от уровня вложенности, поток, который видит ссылку на опубликованный объект, увидит все значения достижимые через final поля, которые были выставлены в конструкторе. Так в примере выше, любой поток, успешно зашедший в метод number (а это значит, что он увидел ссылку на опубликованный объект), то он всегда вернет значение 2. Конечно при условии, что после конструктора содержимое всех объектов больше не модифицируется.
Данное замечательное свойство делает антипаттерн double-check locking работоспособным, если singleton в конструкторе инициализирует только final поля.
Так же данное свойство решает проблему предыдущей модели памяти, где повсеместо используемые все из себя immutable строки, строго говоря не всегда работали.
Еще очень важный момент, который стоит упомянуть, это то, что вышеописанное верно только для объектов, во время конструирования которых, ссылка на объект не покидет конструктор, прежде чем он завершен. Обычно это происходит, когда вы передаете ссылку на экземпляр вашего класса в какой-нибудь listener прямо в конструкторе объекта. Или ваш объект является экземпляром Thread и вы вызываете метод start в конструкторе.
Как же данное свойство может быть реализовано JVM? Я думаю, что JVM гарантирует, что в момент записи ссылки на объект в основную память, все значения, достижимые из final полей выставленных во время конструирования объектов, уже синхронизированы с основной памятью. Таким образом, любой поток, читая ссылку на объект обязательно прочитает и все значения выставленные описанным способом во время конструирования объекта.
Happens-Before Relationship in Java
Happens-before is a concept, a phenomenon, or simply a set of rules that define the basis for reordering of instructions by a compiler or CPU. Happens-before is not any keyword or object in the Java language, it is simply a discipline put into place so that in a multi-threading environment the reordering of the surrounding instructions does not result in a code that produces incorrect output.
The definition might seem a bit overwhelming if this is the first time that you have come across this concept. To understand it let’s first learn where does the need for it originates from.
The Java Memory Model which is also referred to as the JMM model defines how the storage and exchange of data take place between threads and the hardware in a single or multithreaded environment.
Some points to keep in mind are as follows:
- Every CPU core has its own set of registers.
- Every CPU core can execute more than one thread at a time.
- Every CPU core has its own set of cache.
- A thread executes on a CPU core but its data is stored and accessed from RAM where the local variables lie inside the “Thread Stack” and the objects lie inside the “Heap.”

The Java Memory Model
The local variables and the references to objects inside a thread are stored in the Thread Stack, whereas the objects themselves are stored inside the Heap. The request for a variable by the thread running on the CPU follows this route RAM -> Cache -> CPU Registers. Similarly, when some processing happens on the variable and its value is updated, the changes go through CPU Register -> Cache -> RAM. Thus while working with multiple threads that share a variable, when one thread updates a shared variable’s value, the update has to be done to the register, then cache, and finally the RAM. And when another thread requires to read that shared variable, it reads the value present in the RAM which travels through the cache and registers. If you look at it at the basic level, if the read-write operations are delayed in such a way that the correct value is not stored in memory before another read-write is performed then it can result in memory consistency errors.
When working with multiple threads, this procedure of storage and retrieval may pose some problems such as:
-
: The condition where two threads sharing some variable, read and write on it but do not do so in a synchronized manner, resulting in inconsistent values.
- Update Visibility: where the updates made by one thread to a shared variable may not be visible to the other thread because the value has not yet been updated to the RAM.
These problems are solved by the use of synchronized block and volatile variables.
Instruction Reordering
During compilation or during processing, the compiler or the CPU might reorder the instructions to run them in parallel to increase throughput and performance. For example, we have 3 instructions:
The compiler cannot run 1 and 2 in parallel because 2 needs the output of 1, but 1 and 3 can run in parallel because they are independent of each other. So the compiler or the CPU can reorder these instructions in this way:
However, if reordering is performed in a multi-threaded application where threads share some variables then it may cost us the correctness of our program.
Now recall the two problems we talked about in the previous section, the race condition, and the updated visibility. Java provides us with some solutions to handle these types of situations. We are gonna learn what they are, and finally happens-before will be introduced in that section.
Volatile
For a field/variable declared as volatile,
- Every write to the field will be written/flushed directly to the main memory (i.e. bypassing the cache.)
- Every read of that field is read directly from the main memory.
This means that the shared variable count, whenever written-to or read-by a thread, it will always correspond to its most recently written value. This will prevent race condition because now the threads will always use the correct value of a shared variable. Also, the updates to the shared variable will also be visible to all the threads reading it, thus preventing the update visibility problem.
There are some more important points that the volatile dictates:
- At the time you write to a volatile variable, all the non-volatile variables that are visible to that thread will also get written/flushed to the main memory, i.e. their most recent values will be stored in the RAM along with the volatile variable.
- At the time you read a volatile variable, all the non-volatile variables that are visible to that thread will also get refreshed from the main memory, i.e. their most recent values will be assigned to them.
This is called the visibility guarantee of a volatile variable.
All of this looks and works fine, unless the CPU decides to reorder your instructions, resulting in incorrect execution of your application. Lets understand what we mean. Consider this piece of a program:
Implementation:
The below code in the illustration depicts as conveyed in simpler words is as follows:
- Inputs a fresh assignment submitted by a student
- And then collects that fresh assignment.
Our goal is that each time “only a freshly prepared assignment is collected. So proposing the sample code for the same as follows:
illustration:
- The method submitAssignment() is used by a thread Thread1, which accepts an assignment submitted by a student in the field assign, then increases the count of assignments submitted, and then flips the newAssignment variable to true.
- The method collectAssignment() is used by a thread Thread2, which waits until a new assignment has been submitted, when the value of newAssignment becomes true, it stores the submitted assignment into a variable ‘collectedAssgn’, increasing the count of assignments collected and flips the newAssignment to false, since no pending assignments are left. Finally, it returns the collected assignment.
Now, the volatile variable newAssignment acts as a shared variable between Thread1 and Thread2 which are running concurrently. And since all the other variables are visible to each of the threads along with newAssignment itself, the read-write operations will be done directly using the main memory.
If we focus on the submitAssignment() method, statements 1, 2, and 3 are independent of each other, since no statement makes use of the other statement, hence your CPU might think “Why not reorder them?” for whatever reasons that it may provide better performance. So let us assume the CPU reordered the three statements in this way:
Now think for a second, what our goal was, it was to collect a new fresh assignment each time, but now due to statement 3 updating the newAssignment to true even before the new assgn has been stored in the assgn, the while loop in the Thread2 will now be exited and there is a possibility that Thread2’s instructions execute before the remaining instructions of Thread1, resulting in an older value object of Assignment being submitted. Even though the values are being retrieved directly from the main memory, it is useless if the instructions are executed in the wrong order in this case.
This is the point where even though the visibility of the variables is guaranteed, the reordering of the instructions may lead to incorrect execution. And therefore, Java introduced the happens-before guarantee, with regards to the visibility of volatile variables.
Happens-Before in Volatile
Happens-Before states about reordering. It is as follows:
- When reordering any write to a variable that happened before a write to a volatile, will remain before the write to the volatile variable.
- When reordering any read of a volatile variable that is located before read of some non-volatile or volatile variable, is guaranteed to happen before any of the subsequent reads.
In context to the above example, the first point is relevant. Any write to a variable (Statements 1 and 2) that happened before a write to a volatile (Statement 3), will remain before the write to the volatile variable. This means that reordering of Statement 3 before 1 and 2 is prohibited. This in turn guarantees that newAssignment is only set to true once the new value of Assignment is assigned to ‘assgn’. This is called happens-before visibility guarantee of volatile. Also, statements 1 and 2 may be reordered among themselves as long as they are not being reordered after statement 3.
Synchronization Block
In the case of a synchronization block in Java:
- When a thread enters a synchronization block, the thread will refresh the values of all variables that are visible to the thread at that time from the main memory.
- When a thread exits a synchronization block, the values of all those variables will be written to the main memory.
Happens-Before in Synchronization Block
In case of synchronization block, happens before states that for reordering:
- Any write to a variable that happens before the exit of a synchronization block is guaranteed to remain before the exit of a synchronization block.
- Entrance to a synchronization block that happens before a read of a variable, is guaranteed to remain before any of the reads to the variables that follow the entrance of a synchronized block.
Now getting deeper to the roots of the happens-before relationship in java. Let us consider a scenario to understand it in better terms.
Illustration:
If one action ‘x’ is visible to and ordered before another action ‘y’, then there is a happens-before relationship between the two actions indicated by hb(x, y).
- If x and y are actions of the same thread and x comes before y in program order, then hb(x, y).
- There is a happens-before edge from the end of a constructor of an object to the start of a finalizer for that object.
- If an action x synchronizes with the following action y, then we also have hb(x, y).
- If hb(x, y) and hb(y, z), then hb(x, z).
Note: It is important to know that if we have hb(x, y) then it does not necessarily mean that x always occurs in the implementation before y, as long as the execution produces correct results, reordering of such actions is legal.
Some more rules laid out regarding synchronization state that are as follows: