Java Memory Model
Модель памяти Java или Java Memory Model (JMM) описывает поведение программы в многопоточной среде. Она объясняет возможное поведение потоков и то, на что должен опираться программист, разрабатывающий приложение.
В этой статье дальше приведено достаточно большое количество терминов. Думаю, что большая часть из них пригодится вам только на собеседованиях, но представлять общую картину того, что такое Java Memory Model всё-таки полезно.
- Разделяемые переменные
- Действия
- Program order
- Synchronization order
- Happens-before
- Final
- Word tearing
Java может работать на разных процессорах и разных операционных системах, что приводит к затруднению синхронизации между потоками. Многие современные процессоры имеют несколько ядер, могут выполнять команды не в той последовательности, в которой они записаны, а также компиляторы могут менять последовательность команд для оптимизации.
Неправильно синхронизированные программы могут приводить к неожиданным результатам.
Например, программа использует локальные переменные r1 и r2 и общие переменные A и B . Первоначально A == B == 0 .
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:
- Чтение (нормальное, не 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 любого последующего взятия блокировки этого монитора.
- Присвоение значение volatile полю happens-before любого последующего чтения значения этого поля.
- Запуск потока happens-before любых действий в запущенном потоке.
- Все действия внутри потока happens-before любого успешного завершения join ( ) над этим потоком.
- Инициализация по умолчанию для любого объекта happens-before любых других действий программы.
Работа с final полями
Все final поля должны быть инициализированы либо конструкциями инициализации, либо внутри конструктора. Не стоит внутри конструкторов обращаться к другим потокам. Поток увидит ссылку на объект только после полной инициализации, то есть по окончании работы конструктора. Так как final полям присваивается значение только один раз, то просто не обращайтесь к другим потоком внутри конструкторов и блоков инициализации и проблем возникнуть не должно.
Однако final поля могут быть изменены через Java Reflection API, чем пользуются, например, десериализаторы. Просто не отдавайте ссылку на объект другим потокам и не читайте значение final поля до его обновления и всё будет нормально.
Word tearing
Некоторые процессоры не позволяют записывать один байт в ОЗУ, что приводит к проблеме, называемой word tearing. Представьте, что у нас есть массив байт. Один поток записывает первый байт, а второй поток пытается записать значение в рядом стоящий байт. Но если процессор не может записать один байт, а только целое машинное слово, то запись рядом стоящего байта может быть проблематичной. Если просто считать машинное слово, обновить один байт и записать обратно, то мы помешаем другому потоку.
В JVM нет проблемы word tearing. Два потока, пишущие рядом стоящие байты не должны мешать друг другу.
Другие детали синхронизации и многонитиевости
Есть такая здоровенная тема, называется Java Memory Model. В принципе знать ее тебе пока не обязательно, но услышать про это – будет полезно.
С целью устранить все возможные проблемы, в Java изменили механизм работы памяти. Теперь не просто память делится на локальный кэш нити и глобальную, но и механизм стал еще лучше.
— Да, лучше и сложнее. Это как самолет. Летать на самолете лучше, чем идти пешком, но сложнее. Попробую объяснить тебе новую ситуацию очень упрощенно.
Вот что было придумано. В код был добавлен механизм синхронизации локальной памяти нитей, названный «happens before» (дословно «случилось перед»). Был придуман ряд правил/условий, при наступлении которых память синхронизируется – обновляется до актуального состояния.
public int y = 1; public int x = 1; x=2; synchronized(mutex)
synchronized(mutex) < if (y == x) System.out.println("YES"); >
Одно из таких условий – это захват освобожденного мютекса. Если мютекс был освобожден и снова захвачен, то перед захватом обязательно выполнится синхронизация памяти. Нить 2 увидит «самые новые» значения переменных x и y, даже если не объявлять их volatile.
— Как интересно. И много таких условий?
— Достаточно, вот некоторые условия синхронизации памяти:
- В рамках одной нити любая команда happens-before (читается «случается перед») любой операцией, следующей за ней в исходном коде.
- Освобождение лока (unlock) happens-before захватом того же лока (lock).
- Выход из synchronized блока/метода happens-before вход в synchronized блок/метод на том же мониторе.
- Запись volatile поля happens-before чтение того же самого volatile поля.
- Завершение метода run экземпляра класса Thread happens-before выход из метода join() или возвращение false методом isAlive() экземпляром той же нити.
- Вызов метода start() экземпляра класса Thread happens-before начало метода run() экземпляра той же нити.
- Завершение конструктора happens-before начало метода finalize() этого класса
- Вызов метода interrupt() на нити happens-before, когда нить обнаружила, что данный метод был вызван, либо путем выбрасывания исключения InterruptedException, либо с помощью методов isInterrupted() или interrupted()
— Т.е. все немного сложнее, чем я думал?
— Да, немного сложнее…
— Спасибо, Риша, буду думать.
— Не заморачивайся сильно на эту тему. Придет время, сам все поймешь. Пока тебе лучше разбираться в основах, чем лезть в дебри внутреннего устройства Java-машины.
— О_о. М-да. Некоторые вещи лучше не знать.
Уровень 27. Ответы на вопросы к собеседованию по теме уровня
- Что такое дедлок?Дедлок – это ситуация, когда два и более нитей заблокированы, ждущие друг друга. Дедлоком также называется взаимная блокировка. Взаимная блокировка – это ситуация в которой, два или более процесса занимая некоторые ресурсы, пытаются заполучить некоторые другие ресурсы, занятые другими процессами и ни один из процессов не может занять необходимый им ресурс, и соответственно освободить занимаемый. Бывают взаимные блокировки порядка синхронизации (решаются назначением порядка); Взаимная блокировка между объектами (различные объекты пытаются получить доступ к одним и тем же синхронизированным блокам); Ресурсная взаимоблокировка (при попытке получить доступ к некоторым ресурсам, которые может использовать одновременно только один поток).
- Какие вы знаете стратегии, предотвращающие появление дедлоков? Безусловно, если код написан без каких-либо ошибок, то взаимных блокировок в нем не будет. Но кто может поручиться, что его код написан без ошибок? Безусловно, тестирование помогает выявить значительную часть ошибок, но как мы уже видели ранее, ошибки в многопоточном коде нелегко диагностировать и даже после тестирования нельзя быть уверенным в отсутствии ситуаций взаимных блокировок. Можем ли мы как-то перестраховаться от блокировок? Ответ – да. Подобные техники применяются в движках баз данных, которым нередко необходимо восстанавливаться после взаимных блокировок (связанных с механизмом транзакций в БД). Интерфейс Lock и его реализации доступные в пакете java.util.concurrent.locks позволяют попытаться занять монитор, связанный с экземпляром данного класса методом tryLock (возвращает true, если удалось занять монитор). Также есть стратегия применения открытых вызовов, то есть вызывать методы других объектов вне синхронизированного блока. Ссылка на статью: Взаимная блокировка(deadlock) в Java и методы борьбы с ней
- Могут ли возникнуть дедлоки при использовании методов wait-notify ? Ответить на этот вопрос сложно лично мне, но прочитав в интернете разные дискуссии на эту тему, можно сказать следующее: Дедлоков можно избежать за счет разумного использования synchronized , volatile , монитора ( wait() , notify() , notifyAll() ),а если копать глубже, то используя классы java.utils.concurrent : вместо обычных коллекций — многопоточные варианты ( ConcurrentHashMap , например); если нужен более сложный способ синхронизации потоков — различные CyclicBarrier , CountDownLatch . Если грамотно использовать wait – notify , то дедлоки возникнуть не должны.))) Вот ссылка: Взаимная блокировка или Deadlock.
- Что чаще используется: notify или notifyAll ? The java.lang.Object.notify() wakes up a single thread that is waiting on this object’s monitor. If any threads are waiting on this object, one of them is chosen to be awakened. The choice is arbitrary and occurs at the discretion of the implementation. A thread waits on an object’s monitor by calling one of the wait methods. This method should only be called by a thread that is the owner of this object’s monitor. A thread becomes the owner of the object’s monitor in one of three ways:
- By executing a synchronized instance method of that object.
- By executing the body of a synchronized statement that synchronizes on the object.
- For objects of type Class, by executing a synchronized static method of that class.
Only one thread at a time can own an object’s monitor.
The java.lang.Object.notifyAll() wakes up all threads that are waiting on this object’s monitor. A thread waits on an object’s monitor by calling one of the wait methods.
The awakened threads will not be able to proceed until the current thread relinquishes the lock on this object. The awakened threads will compete in the usual manner with any other threads that might be actively competing to synchronize on this object; for example, the awakened threads enjoy no reliable privilege or disadvantage in being the next thread to lock this object.
This method should only be called by a thread that is the owner of this object’s monitor.
Это отрывки из документации. Вопрос – то по большей части риторический, смотря какое приложение, в зависимости от ситуации))) Я даже не знаю, как бы я ответил. Если у кого-то есть какие-то догадки, то прошу в комментариях оставить, буду очень рад почитать.
if (some condition)
while (some condition)
public class ImmutablePoint < private final int x; private final int y; private final String description; public ImmutablePoint(int x, int y, String description) < this.x = x; this.y = y; this.description = description; >>
private int myInt = 0; public int AddOne()
- Синхронизация и мониторы:
- Захват монитора (начало synchronized , метод lock ) и всё, что после него в том же потоке.
- Возврат монитора (конец synchronized , метод unlock ) и всё, что перед ним в том же потоке.
- Таким образом, оптимизатор может заносить строки в синхроблок, но не наружу.
- Возврат монитора и последующий захват другим потоком.
- Запись и чтение:
- Любые зависимости по данным (то есть запись в любую переменную и последующее чтение её же) в одном потоке.
- Всё, что в том же потоке перед записью в volatile -переменную, и сама запись.
- volatile -чтение и всё, что после него в том же потоке.
- Запись в volatile -переменную и последующее считывание её же.[4][2] Таким образом, volatile -запись делает с памятью то же, что возврат монитора, а чтение — то же, что захват.[5] А значит: если один поток записал в volatile -переменную, а второй обнаружил это, всё, что предшествует записи, выполняется раньше всего, что идёт после чтения; см. иллюстрацию.
- Для объектных переменных (например, volatile List x; ) столь сильные гарантии выполняются для ссылки на объект, но не для его содержимого.
- Обслуживание объекта:
- Статическая инициализация и любые действия с любыми экземплярами объектов.
- Запись в final -поля в конструкторе[6] и всё, что после конструктора. Как исключение из всеобщей транзитивности, это соотношение happens-before не соединяется транзитивно с другими правилами и поэтому может вызвать межпоточную гонку.[7]
- Любая работа с объектом и finalize() .
- Обслуживание потока:
- Запуск потока и любой код в потоке.
- Зануление переменных, относящихся к потоку, и любой код в потоке.
- Код в потоке и join(); код в потоке и isAlive() == false .
- interrupt() потока и обнаружение факта останова.
Happenes before
“Happens-before” — это понятие из Java Memory Model (JMM), которое описывает отношение между операциями чтения и записи в разделяемых переменных. Понятие “happens-before” обеспечивает определенные гарантии относительно порядка выполнения операций в разных потоках
Главное правило “happens-before” ;
Если операция записи (write) в разделяемой переменной произошла “happens-before” операции чтения (read) этой же переменной, то любое значение, записанное в эту переменную до операции записи, должно быть видимым при операции чтения.
Операции, которые создают “happens-before”, включают в себя:
- Завершение конструктора объекта: Когда объект инициализируется в одном потоке, и его ссылка становится видимой в другом потоке после завершения конструктора, “happens-before” создается.
- Завершение вызова Thread.start() : Когда поток запускается с использованием метода start() , он не начинает выполнение кода до завершения этого метода, что создает «happens-before».
- Завершение вызова Thread.join() : Когда поток вызывает метод join() для другого потока, он блокируется до тех пор, пока другой поток не завершит выполнение, что также создает «happens-before».
“Happens-before” является важной концепцией для правильного синхронизированного доступа к разделяемым данным в Java и обеспечивает корректное взаимодействие между потоками.