Добрый день.
В рамках набора на курс «Multicore programming in Java» я делаю серию переводов классических статей по многопоточности в Java. Всякое изучение многопоточности должно начинаться с введения в модель памяти Java (New JMM), основным источником от авторов модели является «The Java Memory Model» home page, где для старта предлагается ознакомится с JSR 133 (Java Memory Model) FAQ. Вот с перевода этой статьи я и решил начать серию.
Я позволил себе несколько вставок «от себя», которые, по моему мнению, проясняют ситуацию.
Я являюсь специалистом по Java и многопоточности, а не филологом или переводчиком, посему допускаю определенные вольности или переформулировки при переводе. В случае, если Вы предложите лучший вариант — с удовольствием сделаю правку.
Этот статья также подходит в качестве учебного материала к лекции «Лекция #5.2: JMM (volatile, final, synchronized)».
Также я веду курс «Scala for Java Developers» на платформе для онлайн-образования udemy.com (аналог Coursera/EdX).
Ну и да, приходите учиться ко мне!
Jeremy Manson и Brian Goetz, февраль 2004
Содержание:
Что такое модель памяти, в конце концов?
Другие языки, такие как C++, имеют модель памяти?
Что такое JSR 133?
Что подразумевается под «переупорядочением» (reordering)?
Что было не так со старой моделью памяти?
Что вы подразумеваете под «некорректно синхронизированы»?
Что делает синхронизация?
Как может случиться, что финальная поля меняют значения?
How do final fields work under the new JMM?
Что делает volatile?
Решила ли новая модель памяти «double-checked locking» проблему?
Что если я пишу виртуальную машину?
Почему я должен беспокоиться?
В многопроцессорных системах, процессоры обычно имеют один или более слоев кэш-памяти, что повышает производительность как за счет ускорения доступа к данным (поскольку данные ближе к процессору) так и за счет сокращения трафика на шине разделяемой памяти (поскольку многие операции с памятью могут быть удовлетворены локальными кэшами.) Кэши могут чрезвычайно повысить производительность, но их использование бросает и множество новых вызовов. Что, например, происходит, когда два процессора рассматривают одну и ту же ячейку памяти в одно и то же время? При каких условиях они будут видеть одинаковые значения?
На уровне процессора, модель памяти определяет необходимые и достаточные условия для гарантии того, что записи в память другими процессорами будут видны текущему процессору, и записи текущего процессора будут видимы другими процессорами. Некоторые процессоры демонстрируют сильную модель памяти, где все процессоры всегда видят точно одинаковые значения для любой заданной ячейки памяти. Другие процессоры демонстрируют более слабую модель памяти, где специальные инструкции, называемые барьерами памяти, требуются для «сброса» (flush) или объявления недействительными (invalidate) данных в локальном кэше процессора, с целью сделать записи данного процессора видимыми для других или увидеть записи, сделанные другими процессорами. Эти барьеры памяти, как правило, выполняются при захвате (lock) и освобождении (unlock) блокировки; они невидимы для программистов на языках высокого уровня.
Иногда бывает проще писать программы для сильных моделей памяти, из-за снижения потребности в барьерах. Тем не менее, даже на сильнейших моделях памяти, барьеры зачастую необходимы; довольно часто их размещение является противоречащим интуиции. Последние тенденции в области дизайна процессоров поощряют более слабые модели памяти, поскольку послабления, которые они делают для согласованности кэшей обеспечивают повышенную масштабируемость на нескольких процессорах и больших объемах памяти.
Вопрос о том, когда запись становится видимой другому потоку усугубляется переупорядочением инструкций компилятором. Например, компилятор может решить, что более эффективно переместить операцию записи дальше в программе; до тех пор, покуда перемещение кода не меняет семантику программы, он может свободно это делать. Если компилятор задерживает операцию, другой поток не увидит пока она не осуществится; это демонстрирует эффект кэширования.
Кроме того, запись в памяти может быть перемещена раньше в программе; в этом случае, другие потоки могут увидеть запись, прежде чем он на самом деле «происходит». Вся эта гибкость является особенностью дизайна — давая компилятору, среде исполнения и аппаратному обеспечению гибкость выполнять операции в оптимальном порядке в рамках модели памяти, мы можем достичь более высокой производительности.
Простой пример этого можно увидеть в следующем фрагменте кода:
Давайте предположим, что этот код выполняется в двух потоках одновременно и чтение 'у' возвращает значение 2. Поскольку эта запись расположена после записи в 'х', программист может предположить, что чтение 'х' должно вернуть значение 1. Тем не менее, запись в 'x' и 'y', возможно, были переупорядочены. Если это имело место, то могла произойти запись в 'у', затем чтение обеих переменных, и только потом запись в 'х'.Результатом будет то, что r1 имеет значение 2, а r2 имеет значение 0.
Модель памяти Java описывает то, какое поведение является законным в многопоточном коде и, как потоки могут взаимодействовать через память. Она описывает отношения между переменными в программе и низкоуровневые детали сохранения и извлечения их в и из памяти или регистров в реальной компьютерной системе. Модель определяет это таким образом, что это может быть реализовано корректно используя широкий спектр аппаратного оборудования и большое разнообразие оптимизаций компилятора.
Java включает в себя несколько языковых конструкций, в том числе volatile, final и synchronized, которые предназначены, для того, чтобы помочь программисту описать компилятору требования к параллелизму в программе. Модель памяти Java определяет поведение volatile и synchronized, и, что более важно, гарантирует, что корректно синхронизированная Java-программа работает правильно на всех процессорных архитектурах.
Большинство других языков программирования, таких как C и C++, не были разработаны с прямой поддержкой многопоточности. Защитные меры, которые эти языки предлагают против различных видов переупорядочения, происходящих в компиляторах и просессорах во многом зависят от гарантий, предлагаемых используемыми библиотеками распараллеливания (например, pthreads), используемым компилятором и платформой, на которой запускается код.
С 1997 года были обнаружены несколько серьезных недостатков в модели памяти Java, которая определена в главе 17 спецификации языка. Эти недостатки допускали шокирующее поведение (например, позволялось изменение значения final-поля) и препятствовали компилятору в использовании типичных оптимизаций.
Модель памяти Java была амбициозным проектом; впервые спецификация языка программирования попыталась включить модель памяти, которая может обеспечить согласованную семантику для параллелизма среди различных процессорных архитектур. К сожалению, определить модель памяти, которая является и согласованной и интуитивной оказалось гораздо труднее, чем ожидалось. JSR 133 определяет новую модель памяти для Java, которая исправляет недостатки предыдущей модели. Для того чтобы сделать это, семантика final и volatile была изменена.
Полное описание семантики доступно по ссылке http://www.cs.umd.edu/users/pugh/java/memoryModel, но формальное описание не для робких. Удивляет и отрезвляет когда узнаешь, насколько сложным является такое простое понятие, как синхронизации на самом деле. К счастью, вам не нужно понимать все детали формальной семантики — целью JSR 133 было создать набор правил, который обеспечивает интуитивное понимание того, как работают volatile, synchronized и final.
Цели JSR 133 включают в себя:
Есть ряд случаев, в которых доступ к переменным (полям объектов, статическим полям и элементам массива) может выполниться в порядке, отличном от указанного в программе. Компилятор свободен в расположении инструкций с целью оптимизации. Процессоры могут выполнять инструкции в ином порядке в ряде случаев. Данные могут перемещаться между регистрами, кэшами процессора и оперативной памятью в порядке, отличном от указанного в программе.
Например, если поток пишет в поле 'а', а затем в поле 'b' и значение 'b' не зависит от значения 'a', то компилятор волен изменить порядок этих операций, и кэш имеет право сбросить (flush) 'b' в оперативную память раньше чем 'a'. Есть несколько потенциальных источников переупорядочения, таких как компилятор, JIT и кэш-память.
Компилятор, среда исполнения и аппаратное обеспечение допускают изменение порядка инструкций при сохранении иллюзии, как-если-последовательной (as-if-serial) семантики, что означает, что однопоточные программы не должны наблюдать эффекты переупорядочения. Тем не менее, изменение порядка следования может вступить в игру в некорректно синхронизированных многопоточных программах, где один поток может наблюдать эффекты производимые другими потоками, и такие программы могут быть в состоянии обнаружить, что переменные становятся видимыми для других потоков в порядке, отличном от указанного в исходном коде.
Большую часть времени, потоки не учитывают, что делают другие потоки. Но когда им это требуется, то в игру вступает синхронизация.
Было несколько серьезных проблем со старой моделью памяти. Она была трудна для понимания и поэтому часто нарушалась. Например, старая модель во многих случаях не позволяла многие виды переупорядочения, которые были реализованы в каждой JVM. Эта путаница со смыслом старой модели привела к тому, что были вынуждены создать JSR-133.
Было широко распространено мнение, что при использовании final-поля не было необходимости в синхронизации между потоками, чтобы гарантировать, что другой поток будет видеть значение поля. Хотя это разумное предположение о разумном поведении, да и вообще мы бы хотели чтобы все именно так и работало, но под старой моделью памяти, это было просто не правдой. Ничто в старой модели памяти не отличало final-поля от любых других данных в памяти — это значит, что синхронизация была единственный способом обеспечить, чтобы все потоки увидели значение final-поля записанного в конструкторе. В результате, была возможно того, что вы увидите значение поля по умолчанию, а затем через некоторое время увидите присвоенное значение. Это означает, например, что неизменяемые объекты, такие как строки могут менять свое значение — тревожная перспектива.
Старая модель памяти позволила менять порядок между записью в volatile и чтением/записью обычных переменных, что не согласуется с интуитивными представлениями большинства разработчиков о volatile и поэтому вызывает замешательство.
Наконец, предположения программистов о том, что может произойти, если их программы некорректно синхронизированы часто ошибочны. Одна из целей JSR-133 — обратить внимание на этот факт.
Под некорректно синхронизированным кодом разные люди подразумевают разные вещи. Когда мы говорим о некорректно синхронизированном коде в контексте модели памяти Java, мы имеем в виду любой код, в котором:
Когда это происходит, мы говорим, что происходит гонка потоков (data race) на этой переменной. Программы с гонками потоков — некорректно синхронизированные программы.
Синхронизация имеет несколько аспектов. Наиболее хорошо понимаемый является взаимное исключение (mutual exclusion) — только один поток может владеть монитором, таким образом синхронизации на мониторе означает, что как только один поток входит в synchronized-блок, защищенный монитором, никакой другой поток не может войти в блок, защищенный этом монитором пока первый поток не выйдет из synchronized-блока.
Но синхронизация — это больше чем просто взаимное исключение. Синхронизация гарантирует, что данные записанные в память до или в синхронизированном блоке становятся предсказуемо видимыми для других потоков, которые синхронизируются на том же мониторе. После того как мы выходим из синхронизированного блока, мы освобождаем (release) монитор, что имеет эффект сбрасывания (flush) кэша в оперативную память, так что запись сделанные нашим потоком могут быть видимыми для других потоков. Прежде чем мы сможем войти в синхронизированный блок, мы захватываем (asquire) монитор, что имеет эффект объявления недействительными данных локального процессорного кэша (invalidating the local processor cache), так что переменные будут загружены из основной памяти. Тогда мы сможем увидеть все записи, сделанные видимым предыдущим освобождением (release) монитора.
Обсуждая ситуацию в терминах кэшей, может показаться, что эти вопросы влияют только на многопроцессорные машины. Тем не менее, эффекты переупорядочения можно легко увидеть и на одном процессоре. Компилятор не может переместить ваш код до захвата монитора или после освобождения. Когда мы говорим, что захват и освобождение мониторов действуют на кэши, мы используем сокращение для ряда возможных эффектов.
Семантика новой модели памяти накладывает частичный порядок на операции с памятью (чтение поля, запись поля, захват блокировки (lock), освобождение блокировки (unlock)) и другие операции с потоками (start(), join()). Некоторые действия, как говорят, «происходят прежде» (happens before) других. Когда одно действие «происходит прежде» (happens before) другого, первое будет гарантированно расположено до и видно второму. Правила этого упорядочения таковы:
Это означает, что любые операции с памятью, которые были видны в потоке перед выходом из синхронизированного блока видны любому другому потоку после того, как он заходит в синхронизированный блок, защищенный тем же монитором, так как все операции с памятью «произойдут прежде» освобождения монитора, а освобождение монитора «происходит прежде» захвата.
Другим следствием является то, что следующий шаблон, который некоторые люди используют, чтобы установить барьер памяти, не работает:
Это конструкция является на самом деле «пустышкой» (no-op), и ваш компилятор может удалить ее полностью, потому что компилятор знает, что никакой другой поток не будет синхронизироваться на том же мониторе. Вы должны установить отношение «происходит прежде» отношения для одного потока, чтобы увидеть результаты другого.
Важное примечание: Обратите внимание, важно для обоих потоков синхронизироваться на одном и том же мониторе, чтобы установить отношение «происходит прежде» (happens before relationship) должным образом. Это не тот случай, когда все видимое потоку A, когда он синхронизируется на объекте X становится видно потоку B после того, как тот синхронизирует на объекте Y. Освобождение и захват должны «соответствовать» (то есть, быть выполнены с одним и тем же монитором), чтобы была обеспечена правильная семантика. В противном случае код содержит гонку данных (data race).
Один из лучших примеров того, как значения final-полей могут измениться, включает одну конкретную реализацию класса String.
Строка может быть реализована как объект с тремя полями — массив символов, смещение в этом массиве, и длины. Причиной выбора реализации String таким образом вместо реализации в виде одного поля типа char[], может быть то, что она позволяет нескольким строкам и объектам StringBuffer разделять один и тот же массив символов и избежать дополнительного выделение объекта и копирование памяти. Так, например, метод String.substring() может быть реализован путем создания новой строки, которая разделяет тот же массив символов с исходной строкой и просто отличается полями длина и смещение. У String все три поля — final.
Строка s2 будет иметь смещение (offset) равное 4 и длину (length) равную 4. Но в рамках старой модели памяти другому потоку было возможно увидеть значение смещение (offset) по умолчанию (0), а позже увидеть корректное значение 4. Будет казаться, будто строка "/usr" изменилась на "/tmp".
Первоначальная модель памяти позволяла такое поведение и некоторые JVM его демонстрировали. Новая модель памяти запрещает его.
Значения для final-полей объекта задаются в конструкторе. Если предположить, что объект построен «правильно», то как только объект построен, значения, присвоенные final-полям в конструкторе будут видны всем другим потокам без синхронизации. Кроме того, видимые значения для любого другого объекта или массива, на который ссылается эти final-поля будут по крайней мере, так же «свежи», как и значения final-полей.
Что значит для объект быть «правильно построенным»? Это просто означает, что ссылка на объект «не утечет» до окончания процесса построения экземпляра (см. Safe Construction Techniques для примеров).
Другими словами, не помещайте ссылку на только строящийся объект в любом месте, в котором другой поток может увидеть ее. Не присваивайте ее статическому полю, не регистрируйте объект в качестве слушателя в любом другом объекте, и так далее. Эти задачи должны быть сделаны по завершению конструктора (вне конструктора, после его вызова), не в нем.
Этот класс является примером того, как должны использоваться final-поля. Поток, вызывающий метод reader() гарантированно прочитает 3 в f.x, поскольку это final-поле. Но нет гарантий, что прочитает 4 в y, поскольку это не-final-поле. Если бы конструктор класса FinalFieldExample выглядел таким образом:
тогда нет гарантий, что поток, прочитавший ссылку на данный объект из global.obj прочитает 3 из x.
Возможность увидеть правильно построенное значение для поля это хорошо, но если данное поле само является ссылкой, то вы также хотите, чтобы ваш код видел «свежее» значение в ссылаемом объекте (или массиве). Вы получаете такую гарантию, если ваше поле — final. Таким образом, вы можете иметь final-ссылку на массив и не беспокоиться, что другие потоки увидят правильное значение для ссылки на массив, но неправильные значения для содержания массива. Опять же, под «правильным» здесь, мы имеем в виду «на момент окончания конструктора объекта», а не «последнего сохраненного значения».
После всего вышесказанного, хочется сделать замечание, что даже после конструирования неизменного (immutable) объекта (объекта, содержащего исключительно final-поля), если вы хотите убедиться, что все остальные потоки увидят вашу ссылку вам все равно необходимо использовать синхронизацию. Нет другого способа убедиться, что ссылка на неизменный (immutable) объект видна в другом потоке. Гарантии, получаемые вашим кодом от использования final-полей, должны быть глубоко и аккуратно согласованы с понимание того, как вы справляетесь с concurrency в вашем коде.
Если вы используете JNI для изменения final-поля, то поведение не определено.
volatile-поля являются специальными полями, которые используются для передачи состояние между потоками. Каждое чтение из volatile возвратит результат последней записи любым другим потоком; по сути, они указываются программистом как поля, для которых не приемлемо увидеть «несвежее» (stale) значение в результате кэширования или переупорядочения. Компилятору и runtime-среде запрещено размещать их в регистрах. Они также должны убедиться, что после записи в volatile данные «проталкиваются» (flushed) из кэша в основную память, поэтому они сразу же становятся видны другим потокам. Аналогично, перед чтением volatile-поля кэш должен быть освобожден, так что мы увидим значение в оперативной памяти, а не в кэше Существуют также дополнительные ограничения на изменение порядка обращения к volatile переменным.
При старой модели памяти, доступ к volatile переменным не могли быть переупорядочены друг с другом, но они могли быть переупорядочены с не-volatile переменными. Это сводило на нет полезность volatile полей как средства передачи сигнала от одного потока к другому.
В соответствии с новой моделью памяти, по-прежнему верно, что volatile переменные не могут быть переупорядочены друг с другом. Разница в том, что теперь уже не так легко изменить порядок между обычными полями расположенными рядом volatile. Запись в volatile поле имеет тот же эффект для памяти как и освобождение монитора (monitor release), а чтение из volatile поля имеет тот же эффект для памяти как и захват монитора (monitor acquire). В сущности, так как новая модель накладывает более строгие ограничения на изменение порядка между доступом к volatile полям и другими полями (volatile или обычным), все, что было видимо для потока A когда он писал в volatile поле f становится видимым для потока B, когда он прочтет f.
Вот простой пример того, как volatile поля могут быть использованы
Назовем один поток писателем, а другой — читателем. Запись в v в писателе «сбрасывает» данные x в оперативную память, а чтение v «захватывает» это значение из памяти. Таким образом, если читатель увидит значение true поля v, то также гарантированно увидит значение 42 в x. Это не было верно, для старой моделью памяти (в старой — можно было «спустить» запись в не-volatile «ниже» записи в volatile). Если бы v не было volatile, то компилятор мог бы изменить порядок записи в писателе, и читатель мог бы увидеть 0 в х.
Семантика volatile была существенно усиленна, почти до уровня synchronized. Каждое чтение или запись volatile действует как «половина» synchronized с точки зрения видимости.
Важное примечание: Обратите внимание, важно чтобы оба потока сделали чтение-запись по одной и той же volatile переменной, с целью добиться установления happens before отношения. Это не тот случай, когда все, что видимо для потока А, когда он пишет volatile-поле f становится видимым для потока B после того, как он считает volatile-поле g. Чтение и запись должны относиться к одной и той же volatile-переменной, чтобы иметь должную семантику.
(Печально известная) double-checked locking идиома (также называемая multithreaded singleton pattern) — это трюк, предназначенный для поддержки отложенной инициализации при отсутствии накладных расходов на синхронизацию. В самых ранних JVM синхронизация была медленной и разработчики стремились удалить ее, возможно, слишком ретиво. Double-checked locking идиома выглядит следующим образом:
Это выглядит ужасно умно — мы избегаем синхронизации на наиболее частом пути выполнения. Есть только одна проблема с этим — идиома не работает. Почему? Наиболее очевидной причиной является то, что запись данных, инициализирующих экземпляр и запись ссылки на экземпляра в статическое поле могут быть переупорядочены компилятором или кэшом, что будет иметь эффект возвращения чего-то «частично построенного». Результатом будет то, что мы читаем неинициализированный объект. Есть много других причин, почему некорректна как эта идиома, так и алгоритмические поправки к ней. Нет никакого способа, чтобы исправить это в старой модели памяти Java. Более подробную информацию можно найти «Double-checked locking: Clever, but broken» и тут «The 'Double Checked Locking is broken' declaration».
Многие полагали, что использование ключевого слова volatile позволит устранить проблемы, которые возникают при попытке использовать шаблон double-checked-locking. В виртуальных машинах до 1.5, volatile все равно не будет гарантировать корректную работу. В соответствии с новой моделью памяти, объявление поля как volatile «исправит» проблемы с double-checked-locking, так как будет установлено отношение «произошло прежде» (happens before) между инициализацией Something конструирующим потоком и возвратом читающему потоку.
Тем не менее, для любителей double-checked locking (и мы действительно надеемся, что их не осталось), новости по-прежнему не очень хороши. Весь смысл double-checked locking был избежать накладных расходов синхронизации. Мало того, что кратковременная синхронизации теперь НАМНОГО дешевле, чем в Java 1.0, так еще и в рамках новой модели памяти, падение производительности при использовании volatile почти достигло уровня стоимости синхронизации. Так что до сих пор нет хорошей причины для использования с double-checked locking. Отредактировано: volatile обходится дешево на большинстве платформ.
Взамен используйте Initialization On Demand Holder идиому, которая безопасна и намного проще для понимания:
Этот код гарантированно корректен, что следует из гарантий инициализации для статических полей; если поле устанавливается в статическом инициализаторе, он гарантированно сделать его корректно видимым, для любого потока, который обращается к этому классу.
Вам стоит посмотреть на http://gee.cs.oswego.edu/dl/jmm/cookbook.html.
Почему я должен беспокоиться? Ошибки многопоточности очень сложно отлаживать. Они часто не проявляются при тестировании, ожидая момента, когда программа будет запущена под высокой нагрузкой и тяжелы для воспроизведения. Гораздо лучше тратить дополнительные усилия загодя, чтобы гарантировать, что ваша программа корректно синхронизирована; в то время как это не легко, это намного легче, чем пытаться отладить некорректно синхронизированное приложение.
Я занимаюсь онлайн обучением Java (вот курсы программирования) и публикую часть учебных материалов в рамках переработки курса Java Core. Видеозаписи лекций в аудитории Вы можете увидеть на youtube-канале, возможно, видео канала лучше систематизировано в этой статье.
skype: GolovachCourses
email: GolovachCourses@gmail.com
В рамках набора на курс «Multicore programming in Java» я делаю серию переводов классических статей по многопоточности в Java. Всякое изучение многопоточности должно начинаться с введения в модель памяти Java (New JMM), основным источником от авторов модели является «The Java Memory Model» home page, где для старта предлагается ознакомится с JSR 133 (Java Memory Model) FAQ. Вот с перевода этой статьи я и решил начать серию.
Я позволил себе несколько вставок «от себя», которые, по моему мнению, проясняют ситуацию.
Я являюсь специалистом по Java и многопоточности, а не филологом или переводчиком, посему допускаю определенные вольности или переформулировки при переводе. В случае, если Вы предложите лучший вариант — с удовольствием сделаю правку.
Этот статья также подходит в качестве учебного материала к лекции «Лекция #5.2: JMM (volatile, final, synchronized)».
Также я веду курс «Scala for Java Developers» на платформе для онлайн-образования udemy.com (аналог Coursera/EdX).
Ну и да, приходите учиться ко мне!
JSR 133 (Java Memory Model) FAQ
Jeremy Manson и Brian Goetz, февраль 2004
Содержание:
Что такое модель памяти, в конце концов?
Другие языки, такие как C++, имеют модель памяти?
Что такое JSR 133?
Что подразумевается под «переупорядочением» (reordering)?
Что было не так со старой моделью памяти?
Что вы подразумеваете под «некорректно синхронизированы»?
Что делает синхронизация?
Как может случиться, что финальная поля меняют значения?
How do final fields work under the new JMM?
Что делает volatile?
Решила ли новая модель памяти «double-checked locking» проблему?
Что если я пишу виртуальную машину?
Почему я должен беспокоиться?
Что такое модель памяти, в конце концов?
В многопроцессорных системах, процессоры обычно имеют один или более слоев кэш-памяти, что повышает производительность как за счет ускорения доступа к данным (поскольку данные ближе к процессору) так и за счет сокращения трафика на шине разделяемой памяти (поскольку многие операции с памятью могут быть удовлетворены локальными кэшами.) Кэши могут чрезвычайно повысить производительность, но их использование бросает и множество новых вызовов. Что, например, происходит, когда два процессора рассматривают одну и ту же ячейку памяти в одно и то же время? При каких условиях они будут видеть одинаковые значения?
На уровне процессора, модель памяти определяет необходимые и достаточные условия для гарантии того, что записи в память другими процессорами будут видны текущему процессору, и записи текущего процессора будут видимы другими процессорами. Некоторые процессоры демонстрируют сильную модель памяти, где все процессоры всегда видят точно одинаковые значения для любой заданной ячейки памяти. Другие процессоры демонстрируют более слабую модель памяти, где специальные инструкции, называемые барьерами памяти, требуются для «сброса» (flush) или объявления недействительными (invalidate) данных в локальном кэше процессора, с целью сделать записи данного процессора видимыми для других или увидеть записи, сделанные другими процессорами. Эти барьеры памяти, как правило, выполняются при захвате (lock) и освобождении (unlock) блокировки; они невидимы для программистов на языках высокого уровня.
Иногда бывает проще писать программы для сильных моделей памяти, из-за снижения потребности в барьерах. Тем не менее, даже на сильнейших моделях памяти, барьеры зачастую необходимы; довольно часто их размещение является противоречащим интуиции. Последние тенденции в области дизайна процессоров поощряют более слабые модели памяти, поскольку послабления, которые они делают для согласованности кэшей обеспечивают повышенную масштабируемость на нескольких процессорах и больших объемах памяти.
Вопрос о том, когда запись становится видимой другому потоку усугубляется переупорядочением инструкций компилятором. Например, компилятор может решить, что более эффективно переместить операцию записи дальше в программе; до тех пор, покуда перемещение кода не меняет семантику программы, он может свободно это делать. Если компилятор задерживает операцию, другой поток не увидит пока она не осуществится; это демонстрирует эффект кэширования.
Кроме того, запись в памяти может быть перемещена раньше в программе; в этом случае, другие потоки могут увидеть запись, прежде чем он на самом деле «происходит». Вся эта гибкость является особенностью дизайна — давая компилятору, среде исполнения и аппаратному обеспечению гибкость выполнять операции в оптимальном порядке в рамках модели памяти, мы можем достичь более высокой производительности.
Простой пример этого можно увидеть в следующем фрагменте кода:
class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
Давайте предположим, что этот код выполняется в двух потоках одновременно и чтение 'у' возвращает значение 2. Поскольку эта запись расположена после записи в 'х', программист может предположить, что чтение 'х' должно вернуть значение 1. Тем не менее, запись в 'x' и 'y', возможно, были переупорядочены. Если это имело место, то могла произойти запись в 'у', затем чтение обеих переменных, и только потом запись в 'х'.Результатом будет то, что r1 имеет значение 2, а r2 имеет значение 0.
Комментарий переводчика
Предполагается, что у одного и того же объекта метод reader() и метод writer() «почти одновременно» вызываются из различных потоков.
Модель памяти Java описывает то, какое поведение является законным в многопоточном коде и, как потоки могут взаимодействовать через память. Она описывает отношения между переменными в программе и низкоуровневые детали сохранения и извлечения их в и из памяти или регистров в реальной компьютерной системе. Модель определяет это таким образом, что это может быть реализовано корректно используя широкий спектр аппаратного оборудования и большое разнообразие оптимизаций компилятора.
Java включает в себя несколько языковых конструкций, в том числе volatile, final и synchronized, которые предназначены, для того, чтобы помочь программисту описать компилятору требования к параллелизму в программе. Модель памяти Java определяет поведение volatile и synchronized, и, что более важно, гарантирует, что корректно синхронизированная Java-программа работает правильно на всех процессорных архитектурах.
Другие языки, такие как C + +, имеют модель памяти?
Большинство других языков программирования, таких как C и C++, не были разработаны с прямой поддержкой многопоточности. Защитные меры, которые эти языки предлагают против различных видов переупорядочения, происходящих в компиляторах и просессорах во многом зависят от гарантий, предлагаемых используемыми библиотеками распараллеливания (например, pthreads), используемым компилятором и платформой, на которой запускается код.
Комментарий переводчика
Java задала тренд введения модели памяти в спецификации языка и в последнем стандарте C++11 уже есть модель памяти (глава 1.7 «The C++ memory model»). Кажется она есть уже и у C11.
Что такое JSR 133?
С 1997 года были обнаружены несколько серьезных недостатков в модели памяти Java, которая определена в главе 17 спецификации языка. Эти недостатки допускали шокирующее поведение (например, позволялось изменение значения final-поля) и препятствовали компилятору в использовании типичных оптимизаций.
Комментарий переводчика
Старая модель памяти (Old JMM, до Java 5) была описана исключительно в Главе 17 «Chapter 17. Threads and Locks» спецификации языка.
Новая модель памяти (New JMM, начиная с Java 5) описана как в Главе 17 «Chapter 17. Threads and Locks» спецификации языка так и расширенно в отдельном документе (JSR-133).
New JMM
Новая модель памяти (New JMM, начиная с Java 5) описана как в Главе 17 «Chapter 17. Threads and Locks» спецификации языка так и расширенно в отдельном документе (JSR-133).
New JMM
- Java Language Specification (Java SE 8 Edition), «Chapter 17. Threads and Locks»
- Java Language Specification (Java SE 7 Edition), «Chapter 17. Threads and Locks»
- Java Language Specification (Third Edition, Java SE 5.0 / SE 6), «Chapter 17. Threads and Locks»
- JSR-133: JavaTM Memory Model and Thread Specification, 2004
- Java Language Specification (Second edition, Java SE 2.0), «Chapter 17. Threads and Locks»
- Java Language Specification (First edition), «Chapter 17. Threads and Locks»
Модель памяти Java была амбициозным проектом; впервые спецификация языка программирования попыталась включить модель памяти, которая может обеспечить согласованную семантику для параллелизма среди различных процессорных архитектур. К сожалению, определить модель памяти, которая является и согласованной и интуитивной оказалось гораздо труднее, чем ожидалось. JSR 133 определяет новую модель памяти для Java, которая исправляет недостатки предыдущей модели. Для того чтобы сделать это, семантика final и volatile была изменена.
Полное описание семантики доступно по ссылке http://www.cs.umd.edu/users/pugh/java/memoryModel, но формальное описание не для робких. Удивляет и отрезвляет когда узнаешь, насколько сложным является такое простое понятие, как синхронизации на самом деле. К счастью, вам не нужно понимать все детали формальной семантики — целью JSR 133 было создать набор правил, который обеспечивает интуитивное понимание того, как работают volatile, synchronized и final.
Комментарий переводчика
Автором дана ссылка на «домашнюю страницу» Java Memory Model — основной источник информации в интернете.
Комментарий переводчика
Формальная семантика позволяет рассчитать все возможные сценарии поведения произвольной как корректно синхронизированной, так и некорректно синхронизированной программы.
В этой статье не будут рассматриваться детали формальной семантики, будет дано лишь некоторое интуитивное описание.
В этой статье не будут рассматриваться детали формальной семантики, будет дано лишь некоторое интуитивное описание.
Цели JSR 133 включают в себя:
- Сохранение существующих гарантий безопасности, таких как безопасность типов (type safety), а также усиление других. Например, значения переменных не могут появиться «из воздуха»: каждое значение переменной наблюдаемое каким-либо одним потоком должно быть значением, которое было записано каким-то другим потоком.
- Семантика корректно синхронизированных программ должна быть настолько простой и интуитивно понятной, насколько это возможно.
- Семантика не полно или некорректно синхронизированных программ должна быть определена таким образом, чтобы потенциальные угрозы безопасности были минимизированы.
- Программисты должны иметь возможность уверенно рассуждать о том, как многопоточные программы взаимодействуют с памятью.
- Должно быть возможна разработка корректных и высокопроизводительных JVM поверх широкого диапазона популярных аппаратных архитектур.
- Должны быть обеспечена новая гарантия безопасности инициализации. Если объект правильно построен (не было «утечек» ссылок на него во время конструирования), то все потоки, которые видят ссылку на этот объект также без необходимости синхронизации будут видеть значения final-полей, которые были установлены в конструкторе.
- Должно быть обеспечено минимальное влияние на существующий код.
Что подразумевается под «переупорядочением» (reordering)?
Есть ряд случаев, в которых доступ к переменным (полям объектов, статическим полям и элементам массива) может выполниться в порядке, отличном от указанного в программе. Компилятор свободен в расположении инструкций с целью оптимизации. Процессоры могут выполнять инструкции в ином порядке в ряде случаев. Данные могут перемещаться между регистрами, кэшами процессора и оперативной памятью в порядке, отличном от указанного в программе.
Комментарий переводчика
Далее под термином переменные, будут иметься в виду поля объектов, статические поля и элементы массива.
Например, если поток пишет в поле 'а', а затем в поле 'b' и значение 'b' не зависит от значения 'a', то компилятор волен изменить порядок этих операций, и кэш имеет право сбросить (flush) 'b' в оперативную память раньше чем 'a'. Есть несколько потенциальных источников переупорядочения, таких как компилятор, JIT и кэш-память.
Компилятор, среда исполнения и аппаратное обеспечение допускают изменение порядка инструкций при сохранении иллюзии, как-если-последовательной (as-if-serial) семантики, что означает, что однопоточные программы не должны наблюдать эффекты переупорядочения. Тем не менее, изменение порядка следования может вступить в игру в некорректно синхронизированных многопоточных программах, где один поток может наблюдать эффекты производимые другими потоками, и такие программы могут быть в состоянии обнаружить, что переменные становятся видимыми для других потоков в порядке, отличном от указанного в исходном коде.
Большую часть времени, потоки не учитывают, что делают другие потоки. Но когда им это требуется, то в игру вступает синхронизация.
Что было не так со старой моделью памяти?
Было несколько серьезных проблем со старой моделью памяти. Она была трудна для понимания и поэтому часто нарушалась. Например, старая модель во многих случаях не позволяла многие виды переупорядочения, которые были реализованы в каждой JVM. Эта путаница со смыслом старой модели привела к тому, что были вынуждены создать JSR-133.
Было широко распространено мнение, что при использовании final-поля не было необходимости в синхронизации между потоками, чтобы гарантировать, что другой поток будет видеть значение поля. Хотя это разумное предположение о разумном поведении, да и вообще мы бы хотели чтобы все именно так и работало, но под старой моделью памяти, это было просто не правдой. Ничто в старой модели памяти не отличало final-поля от любых других данных в памяти — это значит, что синхронизация была единственный способом обеспечить, чтобы все потоки увидели значение final-поля записанного в конструкторе. В результате, была возможно того, что вы увидите значение поля по умолчанию, а затем через некоторое время увидите присвоенное значение. Это означает, например, что неизменяемые объекты, такие как строки могут менять свое значение — тревожная перспектива.
Комментарий переводчика
Ниже будет детально объяснен пример со строками
Старая модель памяти позволила менять порядок между записью в volatile и чтением/записью обычных переменных, что не согласуется с интуитивными представлениями большинства разработчиков о volatile и поэтому вызывает замешательство.
Комментарий переводчика
Ниже будут приведены примеры с volatile
Наконец, предположения программистов о том, что может произойти, если их программы некорректно синхронизированы часто ошибочны. Одна из целей JSR-133 — обратить внимание на этот факт.
Что вы подразумеваете под «некорректно синхронизированы»?
Под некорректно синхронизированным кодом разные люди подразумевают разные вещи. Когда мы говорим о некорректно синхронизированном коде в контексте модели памяти Java, мы имеем в виду любой код, в котором:
- есть запись переменной одним потоком,
- есть чтение той же самой переменной другим потоком и
- чтение и запись не упорядочены по синхронизации (are not ordered by synchronization)
Когда это происходит, мы говорим, что происходит гонка потоков (data race) на этой переменной. Программы с гонками потоков — некорректно синхронизированные программы.
Комментарий переводчика
Надо понимать, что некорректно синхронизированные программы не являются Абсолютным Злом. Их поведение хотя и недерминировано, но все возможные сценарии полностью описаны в JSR-133. Эти поведения зачастую неинтуитивны. Поиск всех возможных результатов алгоритмически весьма сложен и опирается в том числе на такие новые понятия как commitment protocol и causality loops.
Но в ряде случаев использование некорректно синхронизированных программ, видимо, оправдано. В качестве примера достаточно привести реализацию java.lang.String.hashCode()
При вызове метода hashCode() у одного экземпляра java.lang.String из разных потоков будет гонка потоков (data race) по полю hash.
Интересна статья Hans-J. Boehm, «Nondeterminism is unavoidable, but data races are pure evil»
И ее обсуждение на русском
Руслан Черемин, «Data races is pure evil»
Но в ряде случаев использование некорректно синхронизированных программ, видимо, оправдано. В качестве примера достаточно привести реализацию java.lang.String.hashCode()
public final class String implements Serializable, Comparable<String>, CharSequence {
/** Cache the hash code for the string */
private int hash; // Default to 0
...
public int hashCode() {
int h = hash;
if (h == 0 && count > 0) {
...
hash = h;
}
return h;
}
...
}
При вызове метода hashCode() у одного экземпляра java.lang.String из разных потоков будет гонка потоков (data race) по полю hash.
Интересна статья Hans-J. Boehm, «Nondeterminism is unavoidable, but data races are pure evil»
И ее обсуждение на русском
Руслан Черемин, «Data races is pure evil»
Что делает синхронизация?
Синхронизация имеет несколько аспектов. Наиболее хорошо понимаемый является взаимное исключение (mutual exclusion) — только один поток может владеть монитором, таким образом синхронизации на мониторе означает, что как только один поток входит в synchronized-блок, защищенный монитором, никакой другой поток не может войти в блок, защищенный этом монитором пока первый поток не выйдет из synchronized-блока.
Но синхронизация — это больше чем просто взаимное исключение. Синхронизация гарантирует, что данные записанные в память до или в синхронизированном блоке становятся предсказуемо видимыми для других потоков, которые синхронизируются на том же мониторе. После того как мы выходим из синхронизированного блока, мы освобождаем (release) монитор, что имеет эффект сбрасывания (flush) кэша в оперативную память, так что запись сделанные нашим потоком могут быть видимыми для других потоков. Прежде чем мы сможем войти в синхронизированный блок, мы захватываем (asquire) монитор, что имеет эффект объявления недействительными данных локального процессорного кэша (invalidating the local processor cache), так что переменные будут загружены из основной памяти. Тогда мы сможем увидеть все записи, сделанные видимым предыдущим освобождением (release) монитора.
Обсуждая ситуацию в терминах кэшей, может показаться, что эти вопросы влияют только на многопроцессорные машины. Тем не менее, эффекты переупорядочения можно легко увидеть и на одном процессоре. Компилятор не может переместить ваш код до захвата монитора или после освобождения. Когда мы говорим, что захват и освобождение мониторов действуют на кэши, мы используем сокращение для ряда возможных эффектов.
Семантика новой модели памяти накладывает частичный порядок на операции с памятью (чтение поля, запись поля, захват блокировки (lock), освобождение блокировки (unlock)) и другие операции с потоками (start(), join()). Некоторые действия, как говорят, «происходят прежде» (happens before) других. Когда одно действие «происходит прежде» (happens before) другого, первое будет гарантированно расположено до и видно второму. Правила этого упорядочения таковы:
Комментарий переводчика
Частичный порядок — это не оборот речи, а математическое понятие.
- Каждое действие в потоке «происходит прежде» (happens before) любого другого действия в этом потоке, которое идет «ниже» в коде этого потока.
- Освобождение монитора «происходит прежде» (happens before) каждого последующего захвата того же самого монитора.
- Запись в volatile-поле происходит «происходит прежде» (happens before) каждого последующего чтения того же самого volatile-поля.
- Вызов метода start() потока «происходит прежде» (happens before) любых действий в запущенном потоке.
- Все действия в потоке «происходят прежде» (happens before) любых действий любого другого потока, который успешно завершил ожидание на join() по первому потоку.
Это означает, что любые операции с памятью, которые были видны в потоке перед выходом из синхронизированного блока видны любому другому потоку после того, как он заходит в синхронизированный блок, защищенный тем же монитором, так как все операции с памятью «произойдут прежде» освобождения монитора, а освобождение монитора «происходит прежде» захвата.
Комментарий переводчика
Эта программа (data — volatile, run — volatile) гарантированно остановится и напечатает 1 И в старой И в новой моделях памяти
Эта программа (data — НЕ volatile, run — volatile) гарантированно остановится И в старой И в новой моделях памяти, но в старой может напечатать и 0 и 1, а в новой гарантированно напечатает 1. Это связано с тем, что в новой модели памяти можно «поднимать» запись в не-volatile, «выше» записи в volatile, но нельзя «спускать ниже». А в старой можно было и «поднимать» и «спускать ниже».
Эта программа (data — volatile, run — НЕ volatile) может как остановиться так и не остановиться в обеих моделях. В случае остановки может напечатать как 0 так и 1 и в старой и в новой моделях памяти. Это вызвано тем, что в обеих моделях можно «поднять» запись в не-volatile выше записи в volatile.
Эта программа (data — НЕ volatile, run — НЕ volatile) может как остановиться так и не остановиться в обеих моделях. В случае остановки может напечатать как 0 так и 1 и в старой и в новой моделях памяти.
public class App {
static volatile int data = 0;
static volatile boolean run = true;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
data = 1;
run = false;
}
}).start();
while (run) {/*NOP*/};
System.out.println(data);
}
}
Эта программа (data — НЕ volatile, run — volatile) гарантированно остановится И в старой И в новой моделях памяти, но в старой может напечатать и 0 и 1, а в новой гарантированно напечатает 1. Это связано с тем, что в новой модели памяти можно «поднимать» запись в не-volatile, «выше» записи в volatile, но нельзя «спускать ниже». А в старой можно было и «поднимать» и «спускать ниже».
public class App {
static int data = 0;
static volatile boolean run = true;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
data = 1;
run = false;
}
}).start();
while (run) {/*NOP*/};
System.out.println(data);
}
}
Эта программа (data — volatile, run — НЕ volatile) может как остановиться так и не остановиться в обеих моделях. В случае остановки может напечатать как 0 так и 1 и в старой и в новой моделях памяти. Это вызвано тем, что в обеих моделях можно «поднять» запись в не-volatile выше записи в volatile.
public class App {
static volatile int data = 0;
static boolean run = true;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
data = 1;
run = false;
}
}).start();
while (run) {/*NOP*/};
System.out.println(data);
}
}
Эта программа (data — НЕ volatile, run — НЕ volatile) может как остановиться так и не остановиться в обеих моделях. В случае остановки может напечатать как 0 так и 1 и в старой и в новой моделях памяти.
public class App {
static int data = 0;
static boolean run = true;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
data = 1;
run = false;
}
}).start();
while (run) {/*NOP*/};
System.out.println(data);
}
}
Другим следствием является то, что следующий шаблон, который некоторые люди используют, чтобы установить барьер памяти, не работает:
synchronized (new Object()) {}
Это конструкция является на самом деле «пустышкой» (no-op), и ваш компилятор может удалить ее полностью, потому что компилятор знает, что никакой другой поток не будет синхронизироваться на том же мониторе. Вы должны установить отношение «происходит прежде» отношения для одного потока, чтобы увидеть результаты другого.
Важное примечание: Обратите внимание, важно для обоих потоков синхронизироваться на одном и том же мониторе, чтобы установить отношение «происходит прежде» (happens before relationship) должным образом. Это не тот случай, когда все видимое потоку A, когда он синхронизируется на объекте X становится видно потоку B после того, как тот синхронизирует на объекте Y. Освобождение и захват должны «соответствовать» (то есть, быть выполнены с одним и тем же монитором), чтобы была обеспечена правильная семантика. В противном случае код содержит гонку данных (data race).
Комментарий переводчика
Следующая программа может как остановиться, так и не остановиться в рамках обеих моделей памяти (так как в разных потоках происходит захват и освобождение мониторов различных объектов — lockA / lockB)
public class App {
static Object lockA = new Object();
static Object lockB = new Object();
static boolean run = true;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
synchronized (lockA) {
run = false;
}
}
}).start();
while (true) {
synchronized (lockB) {
if (!run) break;
}
}
}
}
Как может случиться, что финальная поля меняют значения?
Один из лучших примеров того, как значения final-полей могут измениться, включает одну конкретную реализацию класса String.
Строка может быть реализована как объект с тремя полями — массив символов, смещение в этом массиве, и длины. Причиной выбора реализации String таким образом вместо реализации в виде одного поля типа char[], может быть то, что она позволяет нескольким строкам и объектам StringBuffer разделять один и тот же массив символов и избежать дополнительного выделение объекта и копирование памяти. Так, например, метод String.substring() может быть реализован путем создания новой строки, которая разделяет тот же массив символов с исходной строкой и просто отличается полями длина и смещение. У String все три поля — final.
Комментарий переводчика
До update 6 для JRE 7 от Oracle java.lang.String реализована вот так
В начиная с update 6 для JRE 7 от Oracle java.lang.String реализована уже по другому (без полей offset и count)
О причинах Вы можете прочитать тут. Это не меняет сути примера авторов, так как «содержимое» финального массива value тоже может «пока не долететь» (исключительно в старой модели). Даже возможны несколько стадий:
1. ссылка на строку уже не null, а поле value — пока null
2. ссылка на строку уже не null, поле value уже не null, но в некоторых ячейках char[] value не корректные char-ы, а первоначальные 0-и.
public final class String implements Serializable, Comparable<String>, CharSequence {
private final char[] value;
private final int offset;
private final int count;
....
}
В начиная с update 6 для JRE 7 от Oracle java.lang.String реализована уже по другому (без полей offset и count)
О причинах Вы можете прочитать тут. Это не меняет сути примера авторов, так как «содержимое» финального массива value тоже может «пока не долететь» (исключительно в старой модели). Даже возможны несколько стадий:
1. ссылка на строку уже не null, а поле value — пока null
2. ссылка на строку уже не null, поле value уже не null, но в некоторых ячейках char[] value не корректные char-ы, а первоначальные 0-и.
Комментарий переводчика
Ниже — реализация метода String.substring(), как видим новая строка разделяет со старой массив char[] value
Ниже — реализация метода StringBuffer.toString(), как видим новый String разделяет со старым StringBuffer массив символов char[] value
public final class String implements Serializable, Comparable<String>, CharSequence {
private final char[] value;
private final int offset;
private final int count;
....
public String substring(int beginIndex, int endIndex) {
....
return new String(offset + beginIndex, endIndex - beginIndex, value);
}
....
}
Ниже — реализация метода StringBuffer.toString(), как видим новый String разделяет со старым StringBuffer массив символов char[] value
abstract class AbstractStringBuilder implements Appendable, CharSequence {
char[] value;
...
}
public final class StringBuffer extends AbstractStringBuilder implements Serializable, CharSequence {
private final char[] value;
private final int offset;
private final int count;
....
public synchronized String toString() {
return new String(value, 0, count);
}
....
}
String s1 = "/usr/tmp";
String s2 = s1.substring(4);
Строка s2 будет иметь смещение (offset) равное 4 и длину (length) равную 4. Но в рамках старой модели памяти другому потоку было возможно увидеть значение смещение (offset) по умолчанию (0), а позже увидеть корректное значение 4. Будет казаться, будто строка "/usr" изменилась на "/tmp".
Первоначальная модель памяти позволяла такое поведение и некоторые JVM его демонстрировали. Новая модель памяти запрещает его.
Как final-поля работают при новой модели памяти?
Значения для final-полей объекта задаются в конструкторе. Если предположить, что объект построен «правильно», то как только объект построен, значения, присвоенные final-полям в конструкторе будут видны всем другим потокам без синхронизации. Кроме того, видимые значения для любого другого объекта или массива, на который ссылается эти final-поля будут по крайней мере, так же «свежи», как и значения final-полей.
Комментарий переводчика
Значения для final-полей объекта задаются в конструкторе или инициализаторе.
Так
Или так
Так
public class App {
final int k;
public App10(int k) {
this.k = k;
}
}
Или так
public class App {
final int k;
{
this.k = 42;
}
}
Комментарий переводчика
Эта программа может как остановиться, так и не остановиться (будь instance — volatile, она бы гарантированно остановилась). Но если остановится, то гарантированно напечатает "[1, 2]"
Эта программа так же может как остановиться, так и не остановиться. Но если остановится, то может напечатать как "[1, 0]" так и "[1, 2]". Это связанно с тем, что запись элемента с индексом 1 происходит позже записи в final-поле.
Эта программа как и первая так же может как остановиться, так и не остановиться. Но если остановится, то гарантированно напечатает "[1, 2]". Так как запись элемента с индексом 1 происходит до записи в final-поле.
import java.util.Arrays;
public class App {
final int[] data;
public App() {
this.data = new int[]{1, 2};
}
static App instance;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
instance = new App();
}
}).start();
while (instance == null) {/*NOP*/}
System.out.println(Arrays.toString(instance.data));
}
}
Эта программа так же может как остановиться, так и не остановиться. Но если остановится, то может напечатать как "[1, 0]" так и "[1, 2]". Это связанно с тем, что запись элемента с индексом 1 происходит позже записи в final-поле.
import java.util.Arrays;
public class App {
final int[] data;
public App() {
this.data = new int[]{1, 0};
this.data[1] = 2;
}
static App instance;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
instance = new App();
}
}).start();
while (instance == null) {/*NOP*/}
System.out.println(Arrays.toString(instance.data));
}
}
Эта программа как и первая так же может как остановиться, так и не остановиться. Но если остановится, то гарантированно напечатает "[1, 2]". Так как запись элемента с индексом 1 происходит до записи в final-поле.
import java.util.Arrays;
public class App {
final int[] data;
public App() {
int[] tmp = new int[]{1, 0};
tmp[1] = 2;
this.data = tmp;
}
static App instance;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
instance = new App();
}
}).start();
while (instance == null) {/*NOP*/}
System.out.println(Arrays.toString(instance.data));
}
}
Что значит для объект быть «правильно построенным»? Это просто означает, что ссылка на объект «не утечет» до окончания процесса построения экземпляра (см. Safe Construction Techniques для примеров).
Комментарий переводчика
Имеется перевод статьи на русский язык (правда автоматическим переводчиком): «Методы безопасного конструирования».
Другими словами, не помещайте ссылку на только строящийся объект в любом месте, в котором другой поток может увидеть ее. Не присваивайте ее статическому полю, не регистрируйте объект в качестве слушателя в любом другом объекте, и так далее. Эти задачи должны быть сделаны по завершению конструктора (вне конструктора, после его вызова), не в нем.
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}
Комментарий переводчика
Предполагается, что вызовы методов reader() и writer() будут происходить «почти одновременно» из разных потоков.
Этот класс является примером того, как должны использоваться final-поля. Поток, вызывающий метод reader() гарантированно прочитает 3 в f.x, поскольку это final-поле. Но нет гарантий, что прочитает 4 в y, поскольку это не-final-поле. Если бы конструктор класса FinalFieldExample выглядел таким образом:
public FinalFieldExample() { // bad!
x = 3;
y = 4;
// bad construction - allowing this to escape
global.obj = this;
}
тогда нет гарантий, что поток, прочитавший ссылку на данный объект из global.obj прочитает 3 из x.
Возможность увидеть правильно построенное значение для поля это хорошо, но если данное поле само является ссылкой, то вы также хотите, чтобы ваш код видел «свежее» значение в ссылаемом объекте (или массиве). Вы получаете такую гарантию, если ваше поле — final. Таким образом, вы можете иметь final-ссылку на массив и не беспокоиться, что другие потоки увидят правильное значение для ссылки на массив, но неправильные значения для содержания массива. Опять же, под «правильным» здесь, мы имеем в виду «на момент окончания конструктора объекта», а не «последнего сохраненного значения».
После всего вышесказанного, хочется сделать замечание, что даже после конструирования неизменного (immutable) объекта (объекта, содержащего исключительно final-поля), если вы хотите убедиться, что все остальные потоки увидят вашу ссылку вам все равно необходимо использовать синхронизацию. Нет другого способа убедиться, что ссылка на неизменный (immutable) объект видна в другом потоке. Гарантии, получаемые вашим кодом от использования final-полей, должны быть глубоко и аккуратно согласованы с понимание того, как вы справляетесь с concurrency в вашем коде.
Если вы используете JNI для изменения final-поля, то поведение не определено.
Комментарий переводчика
Насколько я понимаю, аналогично нет гарантий для случая изменения поля средствами Reflection API.
Что делает volatile?
volatile-поля являются специальными полями, которые используются для передачи состояние между потоками. Каждое чтение из volatile возвратит результат последней записи любым другим потоком; по сути, они указываются программистом как поля, для которых не приемлемо увидеть «несвежее» (stale) значение в результате кэширования или переупорядочения. Компилятору и runtime-среде запрещено размещать их в регистрах. Они также должны убедиться, что после записи в volatile данные «проталкиваются» (flushed) из кэша в основную память, поэтому они сразу же становятся видны другим потокам. Аналогично, перед чтением volatile-поля кэш должен быть освобожден, так что мы увидим значение в оперативной памяти, а не в кэше Существуют также дополнительные ограничения на изменение порядка обращения к volatile переменным.
При старой модели памяти, доступ к volatile переменным не могли быть переупорядочены друг с другом, но они могли быть переупорядочены с не-volatile переменными. Это сводило на нет полезность volatile полей как средства передачи сигнала от одного потока к другому.
В соответствии с новой моделью памяти, по-прежнему верно, что volatile переменные не могут быть переупорядочены друг с другом. Разница в том, что теперь уже не так легко изменить порядок между обычными полями расположенными рядом volatile. Запись в volatile поле имеет тот же эффект для памяти как и освобождение монитора (monitor release), а чтение из volatile поля имеет тот же эффект для памяти как и захват монитора (monitor acquire). В сущности, так как новая модель накладывает более строгие ограничения на изменение порядка между доступом к volatile полям и другими полями (volatile или обычным), все, что было видимо для потока A когда он писал в volatile поле f становится видимым для потока B, когда он прочтет f.
Комментарий переводчика
И в старой и в новой моделях памяти программа гарантированно остановится и напечатает 1 (data — volatile, run — volatile)
И в старой и в новой моделях памяти программа гарантированно остановится. В новой модели гарантированно напечатает 1, в старой может 0 или 1 (data — НЕ volatile, run — volatile), так в новой нельзя переносить запись в не-volatile «ниже» чем запись в volatile, а в старой — можно
И в старой и в новой моделях памяти программа может НЕ остановиться (run — не volatile и может «залипнуть» в кэше). В обеих моделях если остановится, то может напечатать как 1, так и 0 (data — НЕ volatile, run — НЕ volatile), так как можно менять порядок независимых записей в не-volatile поля
Делаем запись во вторую переменную зависимой от записи в первую переменную. И в старой и в новой моделях памяти программа может НЕ остановиться (run — не volatile и может «залипнуть» в кэше). Но теперь в новой модели в случае остановки напечатает гарантированно 1
И в старой и в новой моделях памяти программа может НЕ остановиться. В обеих моделях если остановится, то может напечатать как 1, так и 0 (data — volatile, run — НЕ volatile), так как можно переносить запись в не-volatile «выше» чем запись в volatile
public class App {
static volatile int data = 0;
static volatile boolean run = true;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
data = 1;
run = false;
}
}).start();
while (run) {/*NOP*/};
System.out.println(data);
}
}
И в старой и в новой моделях памяти программа гарантированно остановится. В новой модели гарантированно напечатает 1, в старой может 0 или 1 (data — НЕ volatile, run — volatile), так в новой нельзя переносить запись в не-volatile «ниже» чем запись в volatile, а в старой — можно
public class App {
static int data = 0;
static volatile boolean run = true;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
data = 1;
run = false;
}
}).start();
while (run) {/*NOP*/};
System.out.println(data);
}
}
И в старой и в новой моделях памяти программа может НЕ остановиться (run — не volatile и может «залипнуть» в кэше). В обеих моделях если остановится, то может напечатать как 1, так и 0 (data — НЕ volatile, run — НЕ volatile), так как можно менять порядок независимых записей в не-volatile поля
public class App {
static int data = 0;
static boolean run = true;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
data = 1;
run = false;
}
}).start();
while (run) {/*NOP*/};
System.out.println(data);
}
}
Делаем запись во вторую переменную зависимой от записи в первую переменную. И в старой и в новой моделях памяти программа может НЕ остановиться (run — не volatile и может «залипнуть» в кэше). Но теперь в новой модели в случае остановки напечатает гарантированно 1
public class App {
static int data = 0;
static boolean run = true;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
data = 1;
run = (data != 1);
}
}).start();
while (run) {/*NOP*/};
System.out.println(data);
}
}
И в старой и в новой моделях памяти программа может НЕ остановиться. В обеих моделях если остановится, то может напечатать как 1, так и 0 (data — volatile, run — НЕ volatile), так как можно переносить запись в не-volatile «выше» чем запись в volatile
public class App {
static volatile int data = 0;
static boolean run = true;
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
data = 1;
run = false;
}
}).start();
while (run) {/*NOP*/};
System.out.println(data);
}
}
Вот простой пример того, как volatile поля могут быть использованы
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
//uses x - guaranteed to see 42.
}
}
}
Назовем один поток писателем, а другой — читателем. Запись в v в писателе «сбрасывает» данные x в оперативную память, а чтение v «захватывает» это значение из памяти. Таким образом, если читатель увидит значение true поля v, то также гарантированно увидит значение 42 в x. Это не было верно, для старой моделью памяти (в старой — можно было «спустить» запись в не-volatile «ниже» записи в volatile). Если бы v не было volatile, то компилятор мог бы изменить порядок записи в писателе, и читатель мог бы увидеть 0 в х.
Комментарий переводчика
Данный пример также детально разобран в Joshua Bloch «Effective Java» 2nd edition (Item 66: Synchronize access to shared mutable data) или в переводе Джошуа Блох «Java. Эффективное программирование» 1 издание (Совет 48. Синхронизируйте доступ потоков к совместно используемым изменяемым данным)
Семантика volatile была существенно усиленна, почти до уровня synchronized. Каждое чтение или запись volatile действует как «половина» synchronized с точки зрения видимости.
Важное примечание: Обратите внимание, важно чтобы оба потока сделали чтение-запись по одной и той же volatile переменной, с целью добиться установления happens before отношения. Это не тот случай, когда все, что видимо для потока А, когда он пишет volatile-поле f становится видимым для потока B после того, как он считает volatile-поле g. Чтение и запись должны относиться к одной и той же volatile-переменной, чтобы иметь должную семантику.
Решила ли новая модель памяти «double-checked locking» проблему?
(Печально известная) double-checked locking идиома (также называемая multithreaded singleton pattern) — это трюк, предназначенный для поддержки отложенной инициализации при отсутствии накладных расходов на синхронизацию. В самых ранних JVM синхронизация была медленной и разработчики стремились удалить ее, возможно, слишком ретиво. Double-checked locking идиома выглядит следующим образом:
// double-checked-locking - don't do this!
private static Something instance = null;
public Something getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new Something();
}
}
return instance;
}
Комментарий переводчика
Мне кажется вот так корректнее (авторы не предоставили законченный класс, но в «каноническом варианте» метод getInstance() — статический, как следствие в нем невозможна синхронизация по this)
или с использованием идиомы Private Mutex
// double-checked-locking - don't do this!
public class Something {
private static Something instance = null;
public static Something getInstance() {
if (instance == null) {
synchronized (Something.class) {
if (instance == null)
instance = new Something();
}
}
return instance;
}
....
}
или с использованием идиомы Private Mutex
// double-checked-locking - don't do this!
public class Something {
private static final Object lock = new Object();
private static Something instance = null;
public static Something getInstance() {
if (instance == null) {
synchronized (lock) {
if (instance == null)
instance = new Something();
}
}
return instance;
}
....
}
Это выглядит ужасно умно — мы избегаем синхронизации на наиболее частом пути выполнения. Есть только одна проблема с этим — идиома не работает. Почему? Наиболее очевидной причиной является то, что запись данных, инициализирующих экземпляр и запись ссылки на экземпляра в статическое поле могут быть переупорядочены компилятором или кэшом, что будет иметь эффект возвращения чего-то «частично построенного». Результатом будет то, что мы читаем неинициализированный объект. Есть много других причин, почему некорректна как эта идиома, так и алгоритмические поправки к ней. Нет никакого способа, чтобы исправить это в старой модели памяти Java. Более подробную информацию можно найти «Double-checked locking: Clever, but broken» и тут «The 'Double Checked Locking is broken' declaration».
Многие полагали, что использование ключевого слова volatile позволит устранить проблемы, которые возникают при попытке использовать шаблон double-checked-locking. В виртуальных машинах до 1.5, volatile все равно не будет гарантировать корректную работу. В соответствии с новой моделью памяти, объявление поля как volatile «исправит» проблемы с double-checked-locking, так как будет установлено отношение «произошло прежде» (happens before) между инициализацией Something конструирующим потоком и возвратом читающему потоку.
Взамен используйте Initialization On Demand Holder идиому, которая безопасна и намного проще для понимания:
private static class LazySomethingHolder {
public static Something something = new Something();
}
public static Something getInstance() {
return LazySomethingHolder.something;
}
Этот код гарантированно корректен, что следует из гарантий инициализации для статических полей; если поле устанавливается в статическом инициализаторе, он гарантированно сделать его корректно видимым, для любого потока, который обращается к этому классу.
Комментарий переводчика
Приведу чуть более полный вариант
Спецификация языка гарантирует как
гарантии ленивости можно найти в спецификации («12.4.1. When Initialization Occurs»):
A class or interface type T will be initialized immediately before the first occurrence of any one of the following:
A class or interface will not be initialized under any other circumstance.
Гарантии корректной инициализации в случае многопоточного доступа можно найти в части «12.4.2. Detailed Initialization Procedure» спецификации.
public class Something {
private static Something instance = null;
public static Something getInstance() {
return LazySomethingHolder.something;
}
....
private static class LazySomethingHolder {
public static Something something = new Something();
}
}
Спецификация языка гарантирует как
- однократность инициализации статического поля LazySomethingHolder.something
- так и ее «ленивость»
гарантии ленивости можно найти в спецификации («12.4.1. When Initialization Occurs»):
A class or interface type T will be initialized immediately before the first occurrence of any one of the following:
- ...
- A static field declared by T is assigned.
- ...
A class or interface will not be initialized under any other circumstance.
Гарантии корректной инициализации в случае многопоточного доступа можно найти в части «12.4.2. Detailed Initialization Procedure» спецификации.
Что если я пишу виртуальную машину?
Вам стоит посмотреть на http://gee.cs.oswego.edu/dl/jmm/cookbook.html.
Почему я должен беспокоиться?
Почему я должен беспокоиться? Ошибки многопоточности очень сложно отлаживать. Они часто не проявляются при тестировании, ожидая момента, когда программа будет запущена под высокой нагрузкой и тяжелы для воспроизведения. Гораздо лучше тратить дополнительные усилия загодя, чтобы гарантировать, что ваша программа корректно синхронизирована; в то время как это не легко, это намного легче, чем пытаться отладить некорректно синхронизированное приложение.
Контакты
Я занимаюсь онлайн обучением Java (вот курсы программирования) и публикую часть учебных материалов в рамках переработки курса Java Core. Видеозаписи лекций в аудитории Вы можете увидеть на youtube-канале, возможно, видео канала лучше систематизировано в этой статье.
skype: GolovachCourses
email: GolovachCourses@gmail.com