что такое happens before

Модель памяти в примерах и не только

В продолжение серии топиков под названием «фундаментальные вещи о Java, которые стоит знать, но которые многие не знают». Предыдущий топик: Бинарная совместимость в примерах и не только

Модель памяти Java — нечто, что оказывает влияние на то, как работает код любого java-разработчика. Тем не менее, довольно многие пренебрегают знанием этой важной темы, и порой наталкиваются на совершенно неожиданное поведение их приложений, которое объясняется именно особенностями устройства JMM. Возьмём для примера весьма распространённую и некорректную реализацию паттерна Double-checked locking:

Люди, пишущие подобный код, пытаются добиться улучшения производительности, избегая блокировки, если значение уже было присвоено. К сожалению, эти люди не учитывают многих факторов, в результате проявления которых может случиться зомби-апокалипсис. Под катом я расскажу теорию и приведу примеры того, как что-то может пойти не так. Кроме того, как говорили в одном индийском фильме, «Мало знать, что не так. Нужно знать, как сделать так, чтобы было так». Потому и рецепты успеха вы также сможете найти дальше.

Немножко истории

Потому в 2004 году в Java 5 появилась JSR 133, в которой были устранены недостатки первоначальной модели. О том, что получилось, мы и будем говорить.

Atomicity

Хотя многие это знают, считаю необходимым напомнить, что на некоторых платформах некоторые операции записи могут оказаться неатомарными. То есть, пока идёт запись значения одним потоком, другой поток может увидеть какое-то промежуточное состояние. За примером далеко ходить не нужно — записи тех же long и double, если они не объявлены как volatile, не обязаны быть атомарными и на многих платформах записываются в две операции: старшие и младшие 32 бита отдельно. (см. стандарт)

Visibility

В старой JMM у каждого из запущенных потоков был свой кеш (working memory), в котором хранились некоторые состояния объектов, которыми этот поток манипулировал. При некоторых условиях кеш синхронизировался с основной памятью (main memory), но тем не менее существенную часть времени значения в основной памяти и в кеше могли расходиться.

В новой модели памяти от такой концепции отказались, потому что то, где именно хранится значение, вообще никому не интересно. Важно лишь то, при каких условиях один поток видит изменения, выполненные другим потоком. Кроме того, железо и без того достаточно умно, чтобы что-то кешировать, складывать в регистры и вытворять прочие операции.

Важно отметить, что, в отличие от того же C++, «из воздуха» (out-of-thin-air) значения никогда не берутся: для любой переменной справедливо, что значение, наблюдаемое потоком, либо было ранее ей присвоено, либо является значением по умолчанию.

Reordering

Но и это, как говорится, ещё не всё. Если вы сделаете заказ прямо сейчас, то ваши инструкции переставят местами совершенно бесплатно! Процессоры проявляют невероятную проворность в оптимизации исполнения инструкций. В этом им также помогает компилятор и JIT. Одним из примечательных эффектов может оказаться то, что действия, выполненные одним потоком, другой поток увидит в другом порядке. Эту фразу довольно сложно понять, просто прочитав, потому приведу пример. Пусть есть такой код:

Хотя внутри одного потока об этом можно не беспокоиться, в многопоточной среде результаты операций, произведённых другими потоками, могут наблюдаться не в том порядке. Чтобы не быть голословным, я хотел добиться того, чтобы на моей машине сработал assertion, но мне это не удавалось настолько долго ( нет, я не забыл указать при запуске ключ -ea ), что, отчаявшись, я обратился с вопросом «а как же всё-таки спровоцировать reordering» к небезызвестным перформанс-инженерам. Так на мой вопрос ответил Сергей Куксенко:

На машинах с TSO (к коим относится x86) довольно сложно показать
ломающий reordering. Это можно показать на каком-нибудь ARM’е или
PowerPC. Еще можно сослаться на Альфу — процессор с самыми слабыми правилами ордеринга. Альфа — это был ночной кошмар разработчиков компиляторов и ядер операционной системы. Счастье, что он таки умер. В сети можно найти массы историй об этом.

Классический пример:
(пример аналогичен приведённому выше — прим. автора)
… на x86 будет отрабатывать корректно всегда, ибо если вы увидели
стор в «b», то увидите и стор в «a».

А когда я сказал, что хотел бы для каждого рассмотренного в статье аспекта найти работающую демонстрацию того, что происходит, Сергей порадовал меня, сказав что необходимо долго и усердно читать мануалы по соответствующему железу. Какое-то время я подумывал попробовать добиться эффекта на телефоне, но в итоге решил, что это не настолько важно. К тому же, я всё-таки специализируюсь вовсе не на особенностях каких-то конкретных платформ.

Итак, вернёмся к нашему изначальному примеру и поймём, как может его испортить reordering. Пусть наш класс Data в конструкторе выполняет какие-то не очень тривиальные вычисления и, главное, записывает какие-то значения в не final поля:

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 исправить ситуацию можно так:

Кроме того, тут используется интересное предположение, которое стоит проверить: volatile store + read быстрее, чем блокировка. Однако, как неустанно повторяют нам всё те же инженеры производительности, микробенчмарки имеют мало отношения с реальностью, особенно если вы не знаете, как устроено то, что вы пытаетесь измерить. Более того, если вы думаете, что знаете, как оно устроено, то вы, скорее всего, ошибаетесь и не учитываете какие-нибудь важные факторы. У меня нет достаточной уверенности в глубине своих познаний, чтобы производить свои бенчмарки, поэтому таких замеров тут не будет. Впрочем, некоторая информация по производительности volatile есть в этой презентации начиная со слайда #54 (хотя я настойчиво рекомендую прочитать всё). UPD: есть интересный комментарий, в котором говорят, что volatile существенно быстрее синхронизации, by design.

Только в том-то и соль, что заработает оно именно магически, и человек, который не знает о вашем хитроумном приёме, может вас не понять. Да и вы тоже можете о таком довольно быстро позабыть. Есть, конечно же, вариант добавить горделивый комментарий типа «neat trick here!», описывающий, что же тут происходит, но мне это почему-то кажется не очень хорошей практикой.

UPD: Это неправда. В комментариях описано, почему. UPD2: По результатам обсуждения вопроса Руслан написал статью.

Кроме того, важно помнить, что поля бывают ещё и статические, а что инициализацию классов JVM гарантированно выполняет лишь один раз при первом обращении. В таком случае, тот же синглетон ( не будем в рамках данной статьи называть его ни паттерном, ни антипаттерном. Статья ведь совсем не об этом 😉 ) можно реализовать вот так:

Это, конечно, не является советом к тому, как нужно реализовывать синглетон, поскольку все, читавшие Effective Java, знают, что если вы совершенно неожидано по какой-то причине вдруг зачем-то решили его написать, то лучше всего использовать enum и получить из коробки решение всех проблем и с многопоточностью, и с сериализацией, и с клонированием. UPD: По поводу того, как лучше реализовать singleton, можно почитать этот топик.

Кстати, тем, кто знает, что final-поля можно изменить через Reflection и заинтересовавшимся, как такие изменения будут видны, могу сказать вот что: «всё, кажется, будет хорошо, только непонятно, почему, и непонятно, действительно ли всё и действительно ли хорошо». Есть несколько топиков на эту тему, наиболее интерен этот. Если кто-нибудь расскажет в комментариях, как оно на самом деле, я буду крайне рад. Впрочем, если никто не расскажет, то я и сам выясню и обязательно расскажу. UPD: В комментариях рассказали.

Читайте также:  что значит обнаружено новое место авторизации в дискорде

Наверняка есть ещё какие-то операции, связанные отношением happens-before, которые я не осветил в данной статье, но они уже гораздо более специфичны, и при наличии интереса вы можете их найти сами в стандарте или где-то ещё, а затем поделиться со всеми в комментариях.

Credits, links and stuff

В первую очередь хотелось бы поблагодарить за некоторые консультации и предварительную проверку статьи на содержание клинического бреда упомянутых ранее инженеров производительности: Алексея TheShade Шипилёва и Сергея Walrus Куксенко.

Источник

Java Memory Model

Модель памяти Java или Java Memory Model (JMM) описывает поведение программы в многопоточной среде. Она объясняет возможное поведение потоков и то, на что должен опираться программист, разрабатывающий приложение.

В этой статье дальше приведено достаточно большое количество терминов. Думаю, что большая часть из них пригодится вам только на собеседованиях, но представлять общую картину того, что такое Java Memory Model всё-таки полезно.

Java может работать на разных процессорах и разных операционных системах, что приводит к затруднению синхронизации между потоками. Многие современные процессоры имеют несколько ядер, могут выполнять команды не в той последовательности, в которой они записаны, а также компиляторы могут менять последовательность команд для оптимизации.

Неправильно синхронизированные программы могут приводить к неожиданным результатам.

Thread 1 Thread 2
1: r2 = A; 3: r1 = B;
2: B = 1; 4: A = 2;

Может показаться, что результат r2 == 2 и r1 == 1 невозможен, так как либо инструкция 1 должна быть первой, либо инструкция 3 должна быть первой. Если инструкция 1 будет первой, то она не сможет увидеть число 2, записанное в инструкции 4. Если инструкция 3 будет первой, то она не сможет увидеть результат инструкции 2.

Если какое-то выполнение программы привело бы к такому поведению, то мы бы знали, что инструкция 4 была до инструкции 1, которая была до инструкции 2, которая была до инструкции 3, которая была до инструкции 4, что совершенно абсурдно.

Однако современным компиляторам разрешено переставлять местами инструкции в обоих потоках в тех случаях, когда это не затрагивает исполнение одного потока не учитывая другие потоки. Если инструкция 1 и инструкция 2 поменяются местами, то мы с лёгкостью сможем получит результат r2 == 2 и r1 == 1.

Thread 1 Thread 2
B = 1; r1 = B;
r2 = A; A = 2;

Для некоторых программистов подобное поведение может оказаться ошибочным, но здесь нужно сделать замечание, что этот код неверно синхронизирован:

Ситуация, описанные в примере выше, называется «состоянием гонки» или Data Race.

Переставлять команды может Just-In-Time компилятор или процессор. Более того, каждое ядро процессора может иметь свой кеш. А значит, у каждого процессора может быть своё значение одной и той же переменнной, что может привести к аналогичным результатам.

Модель памяти описывает, какие значения могут быть считаны в каждый момент программы. Поведение потока в изоляции должно быть таким, каким описано в самом потоке, но значения, считываемые из переменных определяются моделью памяти. Когда мы ссылаемся на это, то мы говорим, что программа подчиняется intra-thread semantic, то есть семантики однопоточного приложения.

Разделяемые переменные

Память, которая может быть совместно использована разными потоками, называется куча (shared memory или heap memory).

Все переменные экземпляров, статические поля, массивы элементов хранятся в куче. Дальше в этой статье я буду называть их всех просто переменными.

Локальные переменные, параметры конструкторов и методов, а также параметры блока catch никогда не разделяются между потоками.

Два доступа к одной переменной называются конфликтующими, если хотя бы один их доступов меняет значение переменной (другой может как менять, так и считывать текущее значение).

Действия

Inter-thread action (термин такой, не знаю, как перевести, может, межпоточное действие?) — это действие внутри одного потока, которое может повлиять или быть замечено другим потоком. Существует несколько типов inter-thread action:

Program order

Program order (лучше не переводить, чтобы не возникло путаницы) — общий порядок потока, выполняющего действия, который отражает порядок, в котором должны быть выполнены все действия с соответствии с семантикой intra-thread semantic потока.

Действия называются sequentially consistent (лучше тоже не переводить), если все действия выполняются в общем порядке, который соответствует program order, а также каждое чтение переменной видит последнее значение, записанное туда до этого в соответствии с порядком выполнения.

Если в программе нет состояния гонки, то все запуски программы будут sequentially consistent.

Synchronization order

Synchronization order (порядок синхронизации, но лучше не переводить) — общий порядок всех действий по синхронизации в выполнении программы.

Действия по синхронизации вводят связь synchronized-with (синхронизировано с):

Happens-before

Happens-before («выполняется прежде» или «произошло-до») — отношение порядка между атомарными командами. Оно означает, что вторая команда будет видеть изменения первой команды, и что первая команды выполнилась перед второй. Рекомендую ознакомиться с многопоточностью в Java, перед продолжением чтения.

Работа с final полями

Все final поля должны быть инициализированы либо конструкциями инициализации, либо внутри конструктора. Не стоит внутри конструкторов обращаться к другим потокам. Поток увидит ссылку на объект только после полной инициализации, то есть по окончании работы конструктора. Так как final полям присваивается значение только один раз, то просто не обращайтесь к другим потоком внутри конструкторов и блоков инициализации и проблем возникнуть не должно.

Однако final поля могут быть изменены через Java Reflection API, чем пользуются, например, десериализаторы. Просто не отдавайте ссылку на объект другим потокам и не читайте значение final поля до его обновления и всё будет нормально.

Word tearing

Некоторые процессоры не позволяют записывать один байт в ОЗУ, что приводит к проблеме, называемой word tearing. Представьте, что у нас есть массив байт. Один поток записывает первый байт, а второй поток пытается записать значение в рядом стоящий байт. Но если процессор не может записать один байт, а только целое машинное слово, то запись рядом стоящего байта может быть проблематичной. Если просто считать машинное слово, обновить один байт и записать обратно, то мы помешаем другому потоку.

В JVM нет проблемы word tearing. Два потока, пишущие рядом стоящие байты не должны мешать друг другу.

Источник

JSR 133 (Java Memory Model) FAQ (перевод)

Добрый день.
В рамках набора на курс «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

Что такое модель памяти, в конце концов?

В многопроцессорных системах, процессоры обычно имеют один или более слоев кэш-памяти, что повышает производительность как за счет ускорения доступа к данным (поскольку данные ближе к процессору) так и за счет сокращения трафика на шине разделяемой памяти (поскольку многие операции с памятью могут быть удовлетворены локальными кэшами.) Кэши могут чрезвычайно повысить производительность, но их использование бросает и множество новых вызовов. Что, например, происходит, когда два процессора рассматривают одну и ту же ячейку памяти в одно и то же время? При каких условиях они будут видеть одинаковые значения?

На уровне процессора, модель памяти определяет необходимые и достаточные условия для гарантии того, что записи в память другими процессорами будут видны текущему процессору, и записи текущего процессора будут видимы другими процессорами. Некоторые процессоры демонстрируют сильную модель памяти, где все процессоры всегда видят точно одинаковые значения для любой заданной ячейки памяти. Другие процессоры демонстрируют более слабую модель памяти, где специальные инструкции, называемые барьерами памяти, требуются для «сброса» (flush) или объявления недействительными (invalidate) данных в локальном кэше процессора, с целью сделать записи данного процессора видимыми для других или увидеть записи, сделанные другими процессорами. Эти барьеры памяти, как правило, выполняются при захвате (lock) и освобождении (unlock) блокировки; они невидимы для программистов на языках высокого уровня.

Иногда бывает проще писать программы для сильных моделей памяти, из-за снижения потребности в барьерах. Тем не менее, даже на сильнейших моделях памяти, барьеры зачастую необходимы; довольно часто их размещение является противоречащим интуиции. Последние тенденции в области дизайна процессоров поощряют более слабые модели памяти, поскольку послабления, которые они делают для согласованности кэшей обеспечивают повышенную масштабируемость на нескольких процессорах и больших объемах памяти.

Вопрос о том, когда запись становится видимой другому потоку усугубляется переупорядочением инструкций компилятором. Например, компилятор может решить, что более эффективно переместить операцию записи дальше в программе; до тех пор, покуда перемещение кода не меняет семантику программы, он может свободно это делать. Если компилятор задерживает операцию, другой поток не увидит пока она не осуществится; это демонстрирует эффект кэширования.

Кроме того, запись в памяти может быть перемещена раньше в программе; в этом случае, другие потоки могут увидеть запись, прежде чем он на самом деле «происходит». Вся эта гибкость является особенностью дизайна — давая компилятору, среде исполнения и аппаратному обеспечению гибкость выполнять операции в оптимальном порядке в рамках модели памяти, мы можем достичь более высокой производительности.

Простой пример этого можно увидеть в следующем фрагменте кода:

Давайте предположим, что этот код выполняется в двух потоках одновременно и чтение ‘у’ возвращает значение 2. Поскольку эта запись расположена после записи в ‘х’, программист может предположить, что чтение ‘х’ должно вернуть значение 1. Тем не менее, запись в ‘x’ и ‘y’, возможно, были переупорядочены. Если это имело место, то могла произойти запись в ‘у’, затем чтение обеих переменных, и только потом запись в ‘х’.Результатом будет то, что r1 имеет значение 2, а r2 имеет значение 0.

Читайте также:  В чем содержится дофамин

Модель памяти Java описывает то, какое поведение является законным в многопоточном коде и, как потоки могут взаимодействовать через память. Она описывает отношения между переменными в программе и низкоуровневые детали сохранения и извлечения их в и из памяти или регистров в реальной компьютерной системе. Модель определяет это таким образом, что это может быть реализовано корректно используя широкий спектр аппаратного оборудования и большое разнообразие оптимизаций компилятора.

Java включает в себя несколько языковых конструкций, в том числе volatile, final и synchronized, которые предназначены, для того, чтобы помочь программисту описать компилятору требования к параллелизму в программе. Модель памяти Java определяет поведение volatile и synchronized, и, что более важно, гарантирует, что корректно синхронизированная Java-программа работает правильно на всех процессорных архитектурах.

Другие языки, такие как C + +, имеют модель памяти?

Что такое JSR 133?

Модель памяти Java была амбициозным проектом; впервые спецификация языка программирования попыталась включить модель памяти, которая может обеспечить согласованную семантику для параллелизма среди различных процессорных архитектур. К сожалению, определить модель памяти, которая является и согласованной и интуитивной оказалось гораздо труднее, чем ожидалось. JSR 133 определяет новую модель памяти для Java, которая исправляет недостатки предыдущей модели. Для того чтобы сделать это, семантика final и volatile была изменена.

Полное описание семантики доступно по ссылке http://www.cs.umd.edu/users/pugh/java/memoryModel, но формальное описание не для робких. Удивляет и отрезвляет когда узнаешь, насколько сложным является такое простое понятие, как синхронизации на самом деле. К счастью, вам не нужно понимать все детали формальной семантики — целью JSR 133 было создать набор правил, который обеспечивает интуитивное понимание того, как работают volatile, synchronized и final.

Что подразумевается под «переупорядочением» (reordering)?

Например, если поток пишет в поле ‘а’, а затем в поле ‘b’ и значение ‘b’ не зависит от значения ‘a’, то компилятор волен изменить порядок этих операций, и кэш имеет право сбросить (flush) ‘b’ в оперативную память раньше чем ‘a’. Есть несколько потенциальных источников переупорядочения, таких как компилятор, JIT и кэш-память.

Компилятор, среда исполнения и аппаратное обеспечение допускают изменение порядка инструкций при сохранении иллюзии, как-если-последовательной (as-if-serial) семантики, что означает, что однопоточные программы не должны наблюдать эффекты переупорядочения. Тем не менее, изменение порядка следования может вступить в игру в некорректно синхронизированных многопоточных программах, где один поток может наблюдать эффекты производимые другими потоками, и такие программы могут быть в состоянии обнаружить, что переменные становятся видимыми для других потоков в порядке, отличном от указанного в исходном коде.

Большую часть времени, потоки не учитывают, что делают другие потоки. Но когда им это требуется, то в игру вступает синхронизация.

Что было не так со старой моделью памяти?

Было несколько серьезных проблем со старой моделью памяти. Она была трудна для понимания и поэтому часто нарушалась. Например, старая модель во многих случаях не позволяла многие виды переупорядочения, которые были реализованы в каждой JVM. Эта путаница со смыслом старой модели привела к тому, что были вынуждены создать JSR-133.

Было широко распространено мнение, что при использовании final-поля не было необходимости в синхронизации между потоками, чтобы гарантировать, что другой поток будет видеть значение поля. Хотя это разумное предположение о разумном поведении, да и вообще мы бы хотели чтобы все именно так и работало, но под старой моделью памяти, это было просто не правдой. Ничто в старой модели памяти не отличало final-поля от любых других данных в памяти — это значит, что синхронизация была единственный способом обеспечить, чтобы все потоки увидели значение final-поля записанного в конструкторе. В результате, была возможно того, что вы увидите значение поля по умолчанию, а затем через некоторое время увидите присвоенное значение. Это означает, например, что неизменяемые объекты, такие как строки могут менять свое значение — тревожная перспектива.

Что вы подразумеваете под «некорректно синхронизированы»?

Надо понимать, что некорректно синхронизированные программы не являются Абсолютным Злом. Их поведение хотя и недерминировано, но все возможные сценарии полностью описаны в JSR-133. Эти поведения зачастую неинтуитивны. Поиск всех возможных результатов алгоритмически весьма сложен и опирается в том числе на такие новые понятия как commitment protocol и causality loops.
Но в ряде случаев использование некорректно синхронизированных программ, видимо, оправдано. В качестве примера достаточно привести реализацию java.lang.String.hashCode()

При вызове метода hashCode() у одного экземпляра java.lang.String из разных потоков будет гонка потоков (data race) по полю hash.

Что делает синхронизация?

Синхронизация имеет несколько аспектов. Наиболее хорошо понимаемый является взаимное исключение (mutual exclusion) — только один поток может владеть монитором, таким образом синхронизации на мониторе означает, что как только один поток входит в synchronized-блок, защищенный монитором, никакой другой поток не может войти в блок, защищенный этом монитором пока первый поток не выйдет из synchronized-блока.

Но синхронизация — это больше чем просто взаимное исключение. Синхронизация гарантирует, что данные записанные в память до или в синхронизированном блоке становятся предсказуемо видимыми для других потоков, которые синхронизируются на том же мониторе. После того как мы выходим из синхронизированного блока, мы освобождаем (release) монитор, что имеет эффект сбрасывания (flush) кэша в оперативную память, так что запись сделанные нашим потоком могут быть видимыми для других потоков. Прежде чем мы сможем войти в синхронизированный блок, мы захватываем (asquire) монитор, что имеет эффект объявления недействительными данных локального процессорного кэша (invalidating the local processor cache), так что переменные будут загружены из основной памяти. Тогда мы сможем увидеть все записи, сделанные видимым предыдущим освобождением (release) монитора.

Обсуждая ситуацию в терминах кэшей, может показаться, что эти вопросы влияют только на многопроцессорные машины. Тем не менее, эффекты переупорядочения можно легко увидеть и на одном процессоре. Компилятор не может переместить ваш код до захвата монитора или после освобождения. Когда мы говорим, что захват и освобождение мониторов действуют на кэши, мы используем сокращение для ряда возможных эффектов.

Семантика новой модели памяти накладывает частичный порядок на операции с памятью (чтение поля, запись поля, захват блокировки (lock), освобождение блокировки (unlock)) и другие операции с потоками (start(), join()). Некоторые действия, как говорят, «происходят прежде» (happens before) других. Когда одно действие «происходит прежде» (happens before) другого, первое будет гарантированно расположено до и видно второму. Правила этого упорядочения таковы:

Эта программа (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 и в старой и в новой моделях памяти.

Другим следствием является то, что следующий шаблон, который некоторые люди используют, чтобы установить барьер памяти, не работает:

Это конструкция является на самом деле «пустышкой» (no-op), и ваш компилятор может удалить ее полностью, потому что компилятор знает, что никакой другой поток не будет синхронизироваться на том же мониторе. Вы должны установить отношение «происходит прежде» отношения для одного потока, чтобы увидеть результаты другого.

Важное примечание: Обратите внимание, важно для обоих потоков синхронизироваться на одном и том же мониторе, чтобы установить отношение «происходит прежде» (happens before relationship) должным образом. Это не тот случай, когда все видимое потоку A, когда он синхронизируется на объекте X становится видно потоку B после того, как тот синхронизирует на объекте Y. Освобождение и захват должны «соответствовать» (то есть, быть выполнены с одним и тем же монитором), чтобы была обеспечена правильная семантика. В противном случае код содержит гонку данных (data race).

Следующая программа может как остановиться, так и не остановиться в рамках обеих моделей памяти (так как в разных потоках происходит захват и освобождение мониторов различных объектов — lockA / lockB)

Как может случиться, что финальная поля меняют значения?

Один из лучших примеров того, как значения 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-и.

Ниже — реализация метода String.substring(), как видим новая строка разделяет со старой массив char[] value

Ниже — реализация метода StringBuffer.toString(), как видим новый String разделяет со старым StringBuffer массив символов char[] value

Строка s2 будет иметь смещение (offset) равное 4 и длину (length) равную 4. Но в рамках старой модели памяти другому потоку было возможно увидеть значение смещение (offset) по умолчанию (0), а позже увидеть корректное значение 4. Будет казаться, будто строка «/usr» изменилась на «/tmp».

Читайте также:  что значит быстрая потеря веса

Первоначальная модель памяти позволяла такое поведение и некоторые JVM его демонстрировали. Новая модель памяти запрещает его.

Как final-поля работают при новой модели памяти?

Значения для final-полей объекта задаются в конструкторе или инициализаторе.
Так

Эта программа может как остановиться, так и не остановиться (будь instance — volatile, она бы гарантированно остановилась). Но если остановится, то гарантированно напечатает «[1, 2]»

Эта программа так же может как остановиться, так и не остановиться. Но если остановится, то может напечатать как «[1, 0]» так и «[1, 2]». Это связанно с тем, что запись элемента с индексом 1 происходит позже записи в final-поле.

Эта программа как и первая так же может как остановиться, так и не остановиться. Но если остановится, то гарантированно напечатает «[1, 2]». Так как запись элемента с индексом 1 происходит до записи в 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-поля являются специальными полями, которые используются для передачи состояние между потоками. Каждое чтение из 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

Вот простой пример того, как 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» проблему?

(Печально известная) double-checked locking идиома (также называемая multithreaded singleton pattern) — это трюк, предназначенный для поддержки отложенной инициализации при отсутствии накладных расходов на синхронизацию. В самых ранних JVM синхронизация была медленной и разработчики стремились удалить ее, возможно, слишком ретиво. Double-checked locking идиома выглядит следующим образом:

Мне кажется вот так корректнее (авторы не предоставили законченный класс, но в «каноническом варианте» метод getInstance() — статический, как следствие в нем невозможна синхронизация по this)

или с использованием идиомы Private Mutex

Это выглядит ужасно умно — мы избегаем синхронизации на наиболее частом пути выполнения. Есть только одна проблема с этим — идиома не работает. Почему? Наиболее очевидной причиной является то, что запись данных, инициализирующих экземпляр и запись ссылки на экземпляра в статическое поле могут быть переупорядочены компилятором или кэшом, что будет иметь эффект возвращения чего-то «частично построенного». Результатом будет то, что мы читаем неинициализированный объект. Есть много других причин, почему некорректна как эта идиома, так и алгоритмические поправки к ней. Нет никакого способа, чтобы исправить это в старой модели памяти 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 идиому, которая безопасна и намного проще для понимания:

Этот код гарантированно корректен, что следует из гарантий инициализации для статических полей; если поле устанавливается в статическом инициализаторе, он гарантированно сделать его корректно видимым, для любого потока, который обращается к этому классу.

Приведу чуть более полный вариант

Гарантии корректной инициализации в случае многопоточного доступа можно найти в части «12.4.2. Detailed Initialization Procedure» спецификации.

Что если я пишу виртуальную машину?

Почему я должен беспокоиться?

Контакты

Я занимаюсь онлайн обучением Java (вот курсы программирования) и публикую часть учебных материалов в рамках переработки курса Java Core. Видеозаписи лекций в аудитории Вы можете увидеть на youtube-канале, возможно, видео канала лучше систематизировано в этой статье.

Источник

Строительный портал