Привет! Представляю вашему вниманию перевод второй части статьи «Java Memory Model» автора Jakob Jenkov. Первая часть тут.
Аппаратная архитектура памяти
Современная аппаратная архитектура памяти несколько отличается от внутренней Java-модели памяти. Важно понимать аппаратную архитектуру, чтобы понять, как с ней работает Java-модель. В этом разделе описывается общая аппаратная архитектура памяти, а в следующем разделе описывается, как с ней работает Java.
Вот упрощенная схема аппаратной архитектуры современного компьютера:
Современный компьютер часто имеет 2 или более процессоров. Некоторые из этих процессоров также могут иметь несколько ядер. На таких компьютерах возможно одновременное выполнение нескольких потоков. Каждый процессор способен запускать один поток в любой момент времени. Это означает, что если ваше Java-приложение является многопоточным, то внутри вашей программы может быть запущен одновременно один поток на один процессор.
Каждый ЦП содержит набор регистров, которые, по существу, находятся в памяти ЦП. Процессор может выполнять операции с этими регистрами намного быстрее, чем с переменными в основной памяти. Это связано с тем, что процессор может получить доступ к этим регистрам гораздо быстрее, чем к основной памяти.
Каждый ЦП также может иметь слой кэш-памяти. Фактически, большинство современных процессоров имеют слой кэш-памяти некоторого размера. Процессор может получить доступ к своей кэш-памяти намного быстрее, чем к основной памяти, но, как правило, не так быстро, как к своим внутренним регистрам. Таким образом, скорость доступа к кэш-памяти находится где-то между скоростями доступа к внутренним регистрам и к основной памяти. Некоторые процессоры могут иметь многоуровневый кэш, но это не так важно знать, чтобы понять, как Java-модель памяти взаимодействует с аппаратной памятью. Важно знать, что процессоры могут иметь некоторый уровень кэш-памяти.
Компьютер также содержит область основной памяти (ОЗУ). Все процессоры могут получить доступ к основной памяти. Основная область памяти обычно намного больше, чем кэш-память процессоров.
Как правило, когда процессору нужен доступ к основной памяти, он считывает часть основной памяти в свою кэш-память. Он может даже считывать часть данных из кэша в свои внутренние регистры и затем выполнять операции над ними. Когда ЦПУ необходимо записать результат обратно в основную память, он сбрасывает данные из своего внутреннего регистра в кэш-память и в какой-то момент в основную память.
Данные, хранящиеся в кэш-памяти, обычно сбрасываются обратно в основную память, когда процессору необходимо сохранить в кэш-памяти что-то еще. Кэш может одновременно записывать данные в часть своей памяти и одновременно очищать часть своей памяти. Он не должен читать/записывать полный кэш каждый раз, когда он обновляется. Обычно кэш обновляется небольшими блоками памяти, называемыми «строками кэша». Одна или несколько строк кэша могут быть считаны в кэш-память, и одна или более строк кэша могут быть сброшены назад в основную память.
Совмещение Java-модели памяти и аппаратной архитектуры памяти
Как уже упоминалось, Java-модель памяти и аппаратная архитектура памяти различны. Аппаратная архитектура не различает стеки потоков и кучу. На оборудовании стек потоков и куча (heap) находятся в основной памяти. Части стеков и кучи потоков могут иногда присутствовать в кэшах и внутренних регистрах ЦП. Это показано на диаграмме:
Когда объекты и переменные могут храниться в различных областях памяти компьютера, могут возникнуть определенные проблемы. Вот две основные:
• Видимость изменений, которые произвёл поток над общими переменными.
• Состояние гонки при чтении, проверке и записи общих переменных.
Обе эти проблемы будут объяснены в следующих разделах.
Видимость общих объектов
Если два или более потока делят между собой объект без надлежащего использования volatile-объявления или синхронизации, то изменения общего объекта, сделанные одним потоком, могут быть невидимы для других потоков.
Представьте, что общий объект изначально хранится в основной памяти. Поток, выполняющийся на ЦП, считывает общий объект в кэш этого же ЦП. Там он вносит изменения в объект. Пока кэш ЦП не был сброшен в основную память, измененная версия общего объекта не видна потокам, работающим на других ЦП. Таким образом, каждый поток может получить свою собственную копию общего объекта, каждая копия будет находиться в отдельном кэше ЦП.
Следующая диаграмма иллюстрирует набросок ситуации. Один поток, работающий на левом ЦП, копирует в его кэш общий объект и изменяет значение переменной count
на 2. Это изменение невидимо для других потоков, работающих на правом ЦП, поскольку обновление для count
ещё не было сброшено обратно в основную память.
Для того, чтобы решить эту проблему, вы можете использовать ключевое слово volatile
. Оно может гарантировать, что данная переменная считывается непосредственно из основной памяти и всегда записывается обратно в основную память при обновлении.
Состояние гонки
Если два или более потоков совместно используют один объект и более одного потока обновляют переменные в этом общем объекте, то может возникнуть состояние гонки.
Представьте, что поток A считывает переменную count
общего объекта в кэш своего процессора. Представьте также, что поток B делает то же самое, но в кэш другого процессора. Теперь поток A прибавляет 1 к значению переменной count
, и поток B делает то же самое. Теперь var1
была увеличена дважды — отдельно по +1 в кэше каждого процессора.
Если бы эти приращения были выполнены последовательно, переменная count
была бы увеличена в два раза и обратно в основную память было бы записано исходное значение + 2
.
Тем не менее, два приращения были выполнены одновременно без надлежащей синхронизации. Независимо от того, какой из потоков (A или B), записывает свою обновленную версию count
в основную память, новое значение будет только на 1 больше исходного значения, несмотря на два приращения.
Эта диаграмма иллюстрирует возникновение проблемы с состоянием гонки, которое описано выше:
Для решения этой проблемы вы можете использовать синхронизированный блок Java. Синхронизированный блок гарантирует, что только один поток может войти в данный критический раздел кода в любой момент времени. Синхронизированные блоки также гарантируют, что все переменные, к которым обращаются внутри синхронизированного блока, будут считаны из основной памяти, и когда поток выйдет из синхронизированного блока, все обновленные переменные будут снова сброшены в основную память, независимо от того, объявлена ли переменная как volatile
или нет.
Автор: mirniypirojok