Java Memory Model

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

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

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

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

Например, программа использует локальные переменные r1 и r2 и общие переменные A и B. Первоначально A == B == 0.

Thread 1Thread 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 1Thread 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:

  • Чтение (нормальное, не volatile). Чтение переменной.
  • Запись (нормальная, не volatile). Запись переменной.
  • volatile read. Чтение volatile переменной.
  • volatile write. Запись volatile переменной.
  • Lock. Взятие блокировки монитора.
  • Unlock. Освобождение блокировки монитора.
  • (синтетические) первое и последнее действие в потоке.
  • Действия по запуску нового потока или обнаружения остановки потока.
  • Внешние действия. Это действия, которые могут быть обнаружены снаружи выполняющегося потока, например, взаимодействия с внешним окружением.
  • Thread divergence actions. Действия потока, находящегося в бесконечном цикле без синхронизаций, работы с памятью или внешних действий.

Program order

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

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

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

Synchronization order

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

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

  • Действие освобождения блокировки монитора synchronizes-with все последующие действия по взятию блокировки этого монитора.
  • Присвоение значения volatile переменной synchronizes-with все последующие чтения этой переменной любым потоком.
  • Действие запуска потока synchronizes-with с первым действием внутри запущенного потока.
  • Присвоение значения по умолчанию (0, false, null) каждой переменной synchronizes-with с первым действием каждого потока.
  • Последнее действие в потоке synchronizes-with с любым действием других потоков, которые проверяют, что первый поток завершился.
  • Если поток 1 прерывает поток 2, то прерывание выполнения потока 2 synchronizes-with с любой точкой, где другой поток (и прерывающий тоже) проверяет, что поток 2 был прерван ( InterruptedException, Thread.interrupted, Thread.isInterrupted).

Happens-before

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

Happens-before возникает:

  • Освобожение монитора happens-before любого последующего взятия блокировки этого монитора.
  • Присвоение значение volatile полю happens-before любого последующего чтения значения этого поля.
  • Запуск потока happens-before любых действий в запущенном потоке.
  • Все действия внутри потока happens-before любого успешного завершения join() над этим потоком.
  • Инициализация по умолчанию для любого объекта happens-before любых других действий программы.

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

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

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

Word tearing

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

Поэтому при записи разных байт в одном массиве всё же нужно пользоваться синхронизацией. На сколько я знаю, в x86 такой проблемы нет, но всё равно полагаться на это не нужно, здесь нужна синхронизация.


Поделиться:
1 Звезда2 Звезды3 Звезды4 Звезды5 Звезд (Пока оценок нет)
Загрузка...

Добавить комментарий

Ваш e-mail не будет опубликован.