Глава 17. Потоки и блокировки
Оглавление
- 17.1. Синхронизация
- 17.2. Наборы ожидания и уведомления
- 17.3. Сон и уступка
- 17.4. Модель памяти
-
- 17.4.1. Общие переменные
- 17.4.2. Действия
- 17.4.3. Программы и порядок выполнения в программе
- 17.4.4. Порядок синхронизации
- 17.4.5. Порядок happens-before
- 17.4.6. Выполнения
- 17.4.7. Корректно сформированные выполнения
- 17.4.8. Выполнения и требования к причинно-следственной связи
- 17.4.9. Наблюдаемое поведение и бесконечные выполнения
- 17.5.
finalСемантика полей - 17.6. Разрыв слова
- 17.7. Неатомарная обработка
doubleиlong
В то время как большая часть обсуждения в предыдущих главах касается только поведения кода при выполнении одной инструкции или выражения за раз, то есть одним потоком, виртуальная машина Java может поддерживать множество потоков выполнения одновременно. Эти потоки независимо выполняют код, который работает со значениями и объектами, находящимися в общей основной памяти. Потоки могут поддерживаться с помощью нескольких процессоров, с помощью разделения времени одного процессора или с помощью разделения времени многих процессоров.
Потоки представлены классом Thread. Единственный способ для пользователя создать поток — создать объект этого класса; каждый поток связан с таким объектом. Поток начнётся, когда метод start() будет вызван на соответствующем объекте Thread.
Поведение потоков, особенно когда они не правильно синхронизированы, может быть запутанным и неинтуитивным. В этой главе описываются семантика многопоточных программ; она включает правила, определяющие, какие значения могут быть получены при чтении из общей памяти, которая обновляется несколькими потоками. Поскольку спецификация похожа на модели памяти для разных архитектур аппаратного обеспечения, эти семантика известна как модель памяти языка программирования Java. Когда нет путаницы, мы просто будем ссылаться на эти правила как на «модель памяти».
Эти семантика не предписывают, как должно выполняться многопоточная программа. Скорее, они описывают поведение, которое многопоточные программы могут демонстрировать. Любая стратегия выполнения, которая генерирует только разрешённые поведения, является приемлемой стратегией выполнения.
Язык программирования Java предоставляет несколько механизмов для взаимодействия между потоками. Наиболее базовый из этих методов — синхронизация, которая реализуется с помощью мониторов. Каждый объект в Java связан с монитором, который поток может заблокировать или разблокировать. Одновременно только один поток может удерживать блокировку монитора. Любые другие потоки, пытающиеся заблокировать этот монитор, будут заблокированы до тех пор, пока не смогут получить блокировку этого монитора. Поток t может блокировать определённый монитор несколько раз; каждая разблокировка отменяет эффект одной операции блокировки.
Инструкция synchronized (§14.19) вычисляет ссылку на объект; затем она пытается выполнить действие блокировки на мониторе этого объекта и не продолжает выполнение, пока действие блокировки не будет успешно завершено. После выполнения действия блокировки выполняется тело инструкции synchronized. Если выполнение тела когда-либо завершается, либо нормально, либо прерыванием, автоматически выполняется действие разблокировки на том же самом мониторе.
Метод synchronized (§8.4.3.6) автоматически выполняет действие блокировки при вызове; его тело не выполняется, пока действие блокировки не будет успешно завершено. Если метод является методом экземпляра, он блокирует монитор, связанный с экземпляром, для которого он был вызван (то есть, объект, который будет известен как this во время выполнения тела метода). Если метод является static, он блокирует монитор, связанный с Class объектом, который представляет класс, в котором определен метод. Если выполнение тела метода когда-либо завершается, либо нормально, либо прерыванием, автоматически выполняется действие разблокировки на том же самом мониторе.
Язык программирования Java не предотвращает и не требует обнаружения условий тупика. Программы, где потоки удерживают (прямо или косвенно) блокировки на нескольких объектах, должны использовать стандартные техники для предотвращения тупика, создавая более высокие уровни примитивов блокировки, которые не приводят к тупику, при необходимости.
Другие механизмы, такие как чтение и запись переменных volatile и использование классов в пакете java.util.concurrent, предоставляют альтернативные способы синхронизации.
Каждый объект, помимо связанного монитора, имеет связанный набор ожиданий. Набор ожиданий — это набор потоков.
Когда объект создается впервые, его набор ожиданий пустой. Элементарные действия, добавляющие и удаляющие потоки из наборов ожиданий, являются атомарными. Наборы ожиданий управляются только с помощью методов Object.wait, Object.notify и Object.notifyAll.
Действия с наборами ожиданий также могут зависеть от состояния прерывания потока и от методов класса Thread, связанных с прерыванием. Кроме того, методы класса Thread для сна и присоединения к другим потокам обладают свойствами, полученными от действий ожидания и уведомления.
Действия ожидания происходят при вызове wait() или временных формах wait(long millisecs) и wait(long millisecs, int nanosecs).
Вызов wait(long millisecs) с параметром ноль или вызов wait(long millisecs,
int nanosecs) с двумя параметрами ноль эквивалентен вызову wait().
Поток возвращается нормально из ожидания, если он возвращается без выбрасывания InterruptedException.
Пусть поток t выполняет метод wait на объекте m, а n — количество действий блокировки потоком t на m, не сопоставленных действиями разблокировки. Происходит одно из следующих действий:
-
Если n равно нулю (т. е. поток t не владеет блокировкой для объекта m), то выбрасывается
IllegalMonitorStateException. -
Если это временное ожидание, и аргумент
nanosecsне находится в диапазоне0-999999, или аргументmillisecsотрицателен, то выбрасываетсяIllegalArgumentException. -
Если поток t прерван, то выбрасывается
InterruptedException, и состояние прерывания t устанавливается в ложь. -
В противном случае происходит следующая последовательность:
-
Поток t добавляется в набор ожиданий объекта m, и выполняет n действий разблокировки на m.
-
Поток t не выполняет дальнейших инструкций до тех пор, пока не будет удален из набора ожиданий m. Поток может быть удален из набора ожиданий из-за любого из следующих действий, и возобновится в какой-то момент позже:
-
Действие
notify, выполняемое на m, при котором t выбирается для удаления из набора ожиданий. -
Действие
notifyAll, выполняемое на m. -
Действие
interrupt, выполняемое на t. -
Если это временное ожидание, внутреннее действие удаления t из набора ожиданий m, которое происходит по крайней мере через
millisecsмиллисекунд плюсnanosecsнаносекунд с начала действия ожидания. -
Внутреннее действие реализации. Реализации разрешено, хотя и не рекомендуется, выполнять "ложные пробуждения", то есть удалять потоки из наборов ожиданий и таким образом разрешать возобновление без явных инструкций.
Обратите внимание, что это положение требует практики программирования Java, используя
waitтолько в циклах, которые завершаются только когда выполняется некоторое логическое условие, которого поток ждет.
Каждый поток должен определить порядок событий, которые могут привести к его удалению из набора ожиданий. Этот порядок не должен совпадать с другими упорядочиваниями, но поток должен вести себя так, как будто эти события произошли в этом порядке.
Например, если поток t находится в наборе ожиданий для m, а затем происходят как прерывание t, так и уведомление m, должен быть порядок этих событий. Если прерывание считается произошедшим первым, тогда t в конечном итоге вернется из
wait, выбросивInterruptedException, а какой-то другой поток в наборе ожиданий для m (если таковые имеются на момент уведомления) должен получить уведомление. Если уведомление считается произошедшим первым, тогда t в конечном итоге вернётся нормально изwaitс ещё ожидающим прерыванием. -
-
Поток t выполняет n действий блокировки на m.
-
Если поток t был удален из набора ожиданий m на шаге 2 из-за прерывания, то состояние прерывания t устанавливается в ложь, и метод
waitвыбрасываетInterruptedException.
-
Действия уведомления происходят при вызове методов notify и notifyAll.
Пусть поток t выполняет любой из этих методов на объекте m, а n — количество действий блокировки потоком t на m, не сопоставленных действиями разблокировки. Происходит одно из следующих действий:
-
Если n равно нулю, то выбрасывается
IllegalMonitorStateException.Это случай, когда поток t не владеет блокировкой объекта m.
-
Если n больше нуля и это действие
notify, то если набор ожиданий m не пустой, выбирается и удаляется из набора ожиданий поток u, который является членом текущего набора ожиданий m.Нет гарантии, какой поток из набора ожиданий будет выбран. Это удаление из набора ожиданий позволяет u возобновить действие ожидания. Однако обратите внимание, что действия блокировки u при возобновлении не могут быть успешными, пока t полностью не разблокирует монитор для m.
-
Если n больше нуля и это действие
notifyAll, то все потоки удаляются из набора ожиданий m, и, таким образом, возобновляются.Однако обратите внимание, что только один из них за раз заблокирует монитор, необходимый во время возобновления ожидания.
Действия прерывания происходят при вызове Thread.interrupt, а также методов, определённых для его вызова, таких как ThreadGroup.interrupt.
Пусть t — поток, вызывающий u.interrupt для некоторого потока u, где t и u могут быть одинаковыми. Это действие приводит к установке состояния прерывания u в истинное.
Кроме того, если существует объект m, чей набор ожиданий содержит u, то u удаляется из набора ожиданий m. Это позволяет u возобновить действие ожидания, в результате чего это ожидание, после повторного блокирования монитора m, выбросит InterruptedException.
Вызовы Thread.isInterrupted могут определять состояние прерывания потока. Метод static Thread.interrupted может вызываться потоком для наблюдения и сброса собственного состояния прерывания.
Вышеуказанные спецификации позволяют определить несколько свойств, связанных с взаимодействием ожиданий, уведомлений и прерываний.
Если поток прерывается и получает уведомление во время ожидания, он может:
-
нормально завершить
wait, при этом прерывание останется ожидающим (другими словами, вызовThread.interruptedвернёт true) -
завершиться из
wait, сбросивInterruptedException
Поток не может сбросить свой статус прерывания и нормально завершить вызов wait.
Аналогично, уведомления не могут быть утеряны из-за прерываний. Предположим, что набор s потоков находится в наборе ожиданий объекта m, и другой поток выполняет notify для m. Тогда либо:
-
по крайней мере один поток в s должен нормально завершить
wait, или -
все потоки в s должны выйти из
wait, сбросивInterruptedException
Обратите внимание, что если поток прерывается и пробуждается через notify, и этот поток завершается из wait, сбросив InterruptedException, то какой-то другой поток в наборе ожиданий должен получить уведомление.
Thread.sleep заставляет текущий поток перейти в состояние ожидания (временно прекратить выполнение) на указанный период, с учетом точности и стабильности системных таймеров и планировщиков. Поток не теряет владения мониторами, а возобновление выполнения будет зависеть от планирования и доступности процессоров для выполнения потока.
Важно отметить, что ни Thread.sleep, ни Thread.yield не имеют семантики синхронизации. В частности, компилятор не обязан выгружать записи, кэшированные в регистрах, в общую память перед вызовом Thread.sleep или Thread.yield, и не обязан перезагружать значения, кэшированные в регистрах, после вызова Thread.sleep или Thread.yield.
Например, в следующем (некорректном) фрагменте кода, предположим, что this.done — это поле без volatile boolean:
while (!this.done)
Thread.sleep(1000);
Компилятор свободен читать поле this.done только один раз и повторно использовать кэшированное значение в каждом выполнении цикла. Это означало бы, что цикл никогда не завершится, даже если другой поток изменит значение this.done.
Модель памяти описывает, для данной программы и трассировки её выполнения, является ли эта трассировка законным выполнением программы. Модель памяти языка программирования Java работает, проверяя каждый доступ к чтению в трассировке выполнения и проверяя, что запись, наблюдаемая этим доступом к чтению, является действительной согласно определённым правилам.
Модель памяти описывает возможные поведения программы. Реализация может генерировать любой код, при условии, что все результаты выполнения программы соответствуют предсказываемым моделью памяти.
Это обеспечивает большую свободу разработчику реализации для выполнения множества преобразований кода, включая переупорядочение действий и удаление ненужной синхронизации.
Пример 17.4-1. Неправильно синхронизированные программы могут демонстрировать неожиданное поведение
Семантика языка программирования Java позволяет компиляторам и микропроцессорам выполнять оптимизации, которые могут взаимодействовать с неправильно синхронизированным кодом таким образом, что могут производить поведение, которое кажется парадоксальным. Вот несколько примеров того, как неправильно синхронизированные программы могут демонстрировать неожиданное поведение.
Рассмотрим, например, примеры трассировок программы, показанные в таблице 17.1. Эта программа использует локальные переменные r1 и r2 и общие переменные A и B. Изначально, A == B ==
0.
Таблица 17.1. Неожиданные результаты, вызванные переупорядочением инструкций - исходный код
| Поток 1 | Поток 2 |
|---|---|
1: r2 = A;
| 3: r1 = B;
|
2: B = 1;
| 4: A = 2;
|
Может показаться, что результат r2 ==
2 и r1 == 1 невозможен. Интуитивно, либо инструкция 1, либо инструкция 3 должны быть первыми в выполнении. Если инструкция 1 идёт первой, она не должна видеть запись в инструкции 4. Если инструкция 3 идёт первой, она не должна видеть запись в инструкции 2.
Если какое-то выполнение демонстрировало такое поведение, то мы бы знали, что инструкция 4 пришла перед инструкцией 1, которая пришла перед инструкцией 2, которая пришла перед инструкцией 3, которая пришла перед инструкцией 4. Это, на первый взгляд, абсурдно.
Однако компиляторам разрешается переупорядочивать инструкции в каждом потоке, когда это не влияет на выполнение этого потока изолированно. Если инструкция 1 переупорядочена с инструкцией 2, как показано в трассировке в таблице 17.2, то легко понять, как может возникнуть результат r2 == 2 и r1 == 1.
Таблица 17.2. Неожиданные результаты, вызванные переупорядочением инструкций - допустимое преобразование компилятора
| Поток 1 | Поток 2 |
|---|---|
B = 1; | r1 = B; |
r2 = A; | A = 2; |
Для некоторых программистов это поведение может показаться "сломанным". Однако следует отметить, что этот код неправильно синхронизирован:
-
есть запись в одном потоке,
-
чтение той же переменной другим потоком,
-
и запись и чтение не упорядочены синхронизацией.
Эта ситуация является примером гонки данных (§17.4.5). Когда код содержит гонку данных, часто возможны неинтуитивные результаты.
Несколько механизмов могут привести к переупорядочению в таблице 17.2. Компилятор Just-In-Time в реализации Java Virtual Machine может переупорядочивать код, или процессор. Кроме того, иерархия памяти архитектуры, на которой выполняется реализация Java Virtual Machine, может сделать так, что код кажется переупорядоченным. В этой главе мы будем называть все, что может переупорядочивать код, компилятором.
Другой пример неожиданных результатов можно увидеть в таблице 17.3. Изначально, p ==
q и p.x == 0. Эта программа также неправильно синхронизирована; она записывает в общую память без соблюдения порядка между этими записями.
Таблица 17.3. Неожиданные результаты, вызванные подстановкой вперёд
| Поток 1 | Поток 2 |
|---|---|
r1 = p; | r6 = p; |
r2 = r1.x; | r6.x = 3; |
r3 = q; | |
r4 = r3.x; | |
r5 = r1.x; |
Одна распространённая оптимизация компилятора заключается в повторном использовании значения, считанного для r2, для r5: они оба считывают r1.x без промежуточной записи. Эта ситуация показана в таблице 17.4.
Таблица 17.4. Неожиданные результаты, вызванные подстановкой вперёд
| Поток 1 | Поток 2 |
|---|---|
r1 = p; | r6 = p; |
r2 = r1.x; | r6.x = 3; |
r3 = q; | |
r4 = r3.x; | |
r5 = r2; |
Теперь рассмотрим случай, когда присвоение переменной r6.x в потоке 2 происходит между первым чтением r1.x и чтением r3.x в потоке 1. Если компилятор решит повторно использовать значение r2 для r5, то r2 и r5 будут иметь значение 0, а r4 будет иметь значение 3. С точки зрения программиста, значение, хранящееся в p.x, изменилось с 0 на 3, а затем вернулось обратно.
Модель памяти определяет, какие значения могут быть прочитаны в любой точке программы. Действия каждого потока в изоляции должны вести себя так, как это регулируется семантикой этого потока, за исключением того, что значения, видимые каждым доступом к чтению, определяются моделью памяти. Когда мы ссылаемся на это, мы говорим, что программа подчиняется внутрипоточной семантике. Внутрипоточная семантика - это семантика однопоточных программ и позволяет полностью предсказать поведение потока на основе значений, наблюдаемых действиями чтения внутри потока. Чтобы определить, являются ли действия потока t в выполнении законными, мы просто оцениваем реализацию потока t так, как она выполнялась бы в однопоточном контексте, как определено в остальной части этого спецификации.
Каждый раз, когда оценка потока t генерирует межпотоковое действие, она должна совпадать с межпотоковым действием a потока t, которое следует за ним в порядке программы. Если a является чтением, то дальнейшая оценка t использует значение, увиденное a, как определено моделью памяти.
Эта секция предоставляет спецификацию модели памяти языка программирования Java, за исключением вопросов, связанных с final полями, которые описаны в §17.5.
Указанная здесь модель памяти не основана фундаментально на объектно-ориентированной природе языка программирования Java. Для краткости и простоты в наших примерах мы часто демонстрируем фрагменты кода без определений классов или методов или явного разыменования. Большинство примеров состоит из двух или более потоков, содержащих операторы с доступом к локальным переменным, общим глобальным переменным или полям экземпляра объекта. Мы обычно используем имена переменных, такие как r1 или r2, для обозначения переменных, локальных для метода или потока. Такие переменные недоступны для других потоков.
Память, которая может быть общей для нескольких потоков, называется общей памятью или кучей памяти.
Все поля экземпляров, поля static и элементы массивов хранятся в куче памяти. В этой главе мы используем термин переменная для обозначения как полей, так и элементов массива.
Локальные переменные (§14.4), формальные параметры методов (§8.4.1) и параметры обработчиков исключений (§14.20) никогда не являются общими для потоков и не затрагиваются моделью памяти.
Два обращения к (чтения или записи в) одной и той же переменной считаются конфликтующими, если хотя бы одно из обращений является записью.
Межпотоковое действие — это действие, выполняемое одним потоком, которое может быть обнаружено или непосредственно повлиять на другой поток. Существует несколько видов межпотоковых действий, которые может выполнять программа:
-
Чтение (обычное, или не-volatile). Чтение переменной.
-
Запись (обычное, или не-volatile). Запись в переменную.
-
Действия синхронизации, которые включают:
-
Чтение volatile. Чтение volatile переменной.
-
Запись volatile. Запись volatile переменной.
-
Блокировка. Блокировка монитора
-
Разблокировка. Разблокировка монитора.
-
Первое и последнее (синтетические) действие потока.
-
Действия, которые запускают поток или обнаруживают, что поток завершился (§17.4.4).
-
-
Внешние действия. Внешнее действие — это действие, которое может быть наблюдаемо вне выполнения, и имеет результат, зависящий от внешней для выполнения среды.
-
Действия расхождения потоков (§17.4.9). Действие расхождения потока выполняется только потоком, который находится в бесконечном цикле, в котором не выполняются действия памяти, синхронизации или внешние действия. Если поток выполняет действие расхождения потоков, за ним последует бесконечное количество действий расхождения потоков.
Действия расхождения потоков вводятся для моделирования того, как поток может заставить все другие потоки остановиться и не продвинуться.
Данное описание касается только межпотоковых действий. Нам не нужно беспокоиться о внутрипотоковых действиях (например, сложении двух локальных переменных и сохранении результата в третьей локальной переменной). Как уже упоминалось, все потоки должны подчиняться правильным внутрипотоковым семантикам для Java-программ. Обычно мы будем кратко называть межпотоковые действия просто действиями.
Действие a описывается кортежем < t, k, v, u >, состоящим из:
-
t - поток, выполняющий действие
-
k - тип действия
-
v - переменная или монитор, участвующие в действии.
Для действий блокировки v — это блокируемый монитор; для действий разблокировки v — это разблокируемый монитор.
Если действие — это (volatile или не-volatile) чтение, v — это считываемая переменная.
Если действие — это (volatile или не-volatile) запись, v — это записываемая переменная.
-
u - произвольный уникальный идентификатор действия
Кортеж внешнего действия содержит дополнительный компонент, который содержит результаты внешнего действия, как они воспринимаются потоком, выполняющим действие. Это может быть информация о успехе или неудаче действия, и любые значения, прочитанные действием.
Параметры внешнего действия (например, какие байты записываются в какой сокет) не являются частью кортежа внешнего действия. Эти параметры устанавливаются другими действиями внутри потока и могут быть определены путем изучения внутрипотоковой семантики. Они не обсуждаются в явном виде в модели памяти.
В бесконечных выполнениях не все внешние действия наблюдаемы. Бесконечные выполнения и наблюдаемые действия обсуждаются в §17.4.9.
Среди всех межпотоковых действий, выполняемых каждым потоком t, порядок программы потока t — это общий порядок, который отражает порядок, в котором эти действия были бы выполнены согласно внутрипотоковой семантике t.
Набор действий последовательно согласован, если все действия происходят в общем порядке (порядке выполнения), согласованном с порядком программы, и, кроме того, каждое чтение r переменной v видит значение, записанное записью w в v таким образом, что:
-
w предшествует r в порядке выполнения, и
-
нет другой записи w' такой, что w предшествует w' и w' предшествует r в порядке выполнения.
Последовательная согласованность — это очень сильная гарантия, которая дается относительно видимости и порядка в выполнении программы. В последовательно согласованном выполнении существует общий порядок всех отдельных действий (таких как чтения и записи), который согласуется с порядком программы, и каждое отдельное действие является атомным и немедленно видно каждому потоку.
Если программа не имеет гонок данных, то все выполнения программы будут казаться последовательно согласованными.
Последовательная согласованность и/или отсутствие гонок данных все равно допускают ошибки, возникающие из-за групп операций, которые должны восприниматься как атомные, но таковыми не являются.
Если бы мы использовали последовательную согласованность в качестве нашей модели памяти, многие из обсуждавшихся нами оптимизаций компилятора и процессора были бы незаконными. Например, в трассировке в Таблице 17.3, как только произошла запись 3 в p.x, последующие чтения этой позиции должны будут увидеть это значение.
Каждое выполнение имеет порядок синхронизации. Порядок синхронизации — это общий порядок всех действий синхронизации в данном выполнении. Для каждого потока t порядок синхронизации действий синхронизации (§17.4.2) в t согласован с порядком выполнения программы (§17.4.3) потока t.
Действия синхронизации вызывают отношение synchronized-with на действиях, определяемое следующим образом:
-
Действие разблокировки монитора m synchronizes-with все последующие действия блокировки на m (где «последующее» определяется порядком синхронизации).
-
Запись в переменную v типа volatile (§8.3.1.4) synchronizes-with все последующие чтения v любым потоком (где «последующее» определяется порядком синхронизации).
-
Действие, которое запускает поток synchronizes-with первое действие в запущенном потоке.
-
Запись значения по умолчанию (ноль,
falseилиnull) в каждую переменную synchronizes-with первое действие в каждом потоке.Хотя может показаться немного странным, что значение по умолчанию записывается в переменную до выделения памяти для объекта, содержащего переменную, концептуально каждый объект создается в начале программы со значениями по умолчанию.
-
Конечное действие в потоке
T1synchronizes-with любое действие в другом потокеT2, который обнаруживает, чтоT1завершился.T2может достичь этого, вызвавT1.isAlive()илиT1.join(). -
Если поток
T1прерывает потокT2, прерывание отT1synchronizes-with любой момент, когда любой другой поток (включаяT2) определяет, чтоT2был прерван (путем выбросаInterruptedExceptionили вызоваThread.interruptedилиThread.isInterrupted).
Источник ребра synchronizes-with называется освобождением, а пункт назначения — приобретением.
Две операции могут быть упорядочены отношением happens-before. Если одна операция happens-before другой, то первая видна и упорядочена перед второй.
Если у нас есть две операции x и y, мы записываем hb(x, y) для обозначения того, что x происходит до y.
-
Если x и y — операции одного потока, и x предшествует y в порядке программы, то hb(x, y).
-
Существует отношение happens-before от конца конструктора объекта до начала финализатора (§12.6) для этого объекта.
-
Если операция x синхронизируется с последующей операцией y, то также hb(x, y).
-
Если hb(x, y) и hb(y, z), то hb(x, z).
Методы класса wait (§17.2.1) имеют связанные с ними операции блокировки и разблокировки; их отношения happens-before определяются этими связанными операциями.
Следует отметить, что наличие отношения happens-before между двумя операциями не обязательно означает, что они должны выполняться в этом порядке в реализации. Если переупорядочение приводит к результатам, согласующимся с законным выполнением, то это не является незаконным.
Например, запись значения по умолчанию в каждое поле объекта, созданного потоком, не обязательно должна произойти до начала этого потока, если ни один чтение никогда не наблюдает этот факт.
Более конкретно, если две операции имеют отношение happens-before, они не обязательно должны казаться произошедшими в этом порядке для любого кода, с которым они не разделяют отношение happens-before. Например, записи в одном потоке, которые находятся в гонке данных с чтениями в другом потоке, могут показаться не в том порядке для этих чтений.
Отношение happens-before определяет, когда происходят гонки данных.
Набор синхронизационных рёбер, S, является достаточным, если это минимальный набор, такой что транзитивное замыкание S с порядком программы определяет все рёбра happens-before в выполнении. Этот набор единственный.
Из вышеизложенных определений следует, что:
-
Разблокировка монитора happens-before любой последующей блокировки этого монитора.
-
Запись в поле
volatile(§8.3.1.4) happens-before любого последующего чтения этого поля. -
Вызов
start()в потоке happens-before любых действий в запущенном потоке. -
Все действия в потоке happens-before любого другого потока, успешно вернувшегося из
join()в этом потоке. -
Инициализация по умолчанию любого объекта happens-before любых других действий (кроме записей по умолчанию) программы.
Когда программа содержит два конфликтующих доступа (§17.4.1), которые не упорядочены отношением happens-before, она содержит гонку данных.
Семантика операций, отличных от межпотоковых действий, таких как чтение длин массивов (§10.7), выполнение проверок типов (§5.5, §15.16) и вызовы виртуальных методов (§15.12), напрямую не затрагиваются гонками данных.
Поэтому гонка данных не может привести к неправильному поведению, например, возвращению неправильной длины массива.
Программа считается правильно синхронизированной тогда и только тогда, когда все последовательно согласованные выполнения не содержат гонок данных.
Если программа правильно синхронизирована, все выполнения программы будут казаться последовательно согласованными (§17.4.3).
Это чрезвычайно сильная гарантия для программистов. Программистам не нужно учитывать переупорядочения, чтобы определить, содержит ли их код гонки данных. Поэтому им не нужно учитывать переупорядочения при определении, правильно ли синхронизирован их код. После того, как будет установлено, что код правильно синхронизирован, программист не должен беспокоиться о том, что переупорядочения повлияют на его код.
Программа должна быть правильно синхронизирована, чтобы избежать видов неинтуитивного поведения, которое может наблюдаться, когда код переупорядочен. Использование правильной синхронизации не гарантирует, что общее поведение программы является правильным. Однако его использование позволяет программисту рассуждать о возможных поведении программы простым способом; поведение правильно синхронизированной программы гораздо меньше зависит от возможных переупорядочений. Без правильной синхронизации возможны очень странные, запутанные и неинтуитивные поведения.
Мы говорим, что чтение r переменной v может наблюдать запись w в v, если в частичном порядке happens-before следа выполнения:
-
r не упорядочен до w (т.е. неверно, что hb(r, w)), и
-
нет промежуточной записи w' в v (т.е. нет записи w' в v такой, что hb(w, w') и hb(w', r)).
Неформально, чтение r разрешено видеть результат записи w, если нет упорядочения happens-before, которое может этого воспрепятствовать.
Набор действий A называется согласованным по happens-before, если для всех чтений r в A, где W(r) — действие записи, увиденное r, неверно, что либо hb(r, W(r)), либо существует запись w в A такая, что w.v = r.v и hb(W(r), w) и hb(w, r).
В наборе действий, согласованном по happens-before, каждое чтение видит запись, которую оно разрешено видеть по упорядочению happens-before.
Пример 17.4.5-1. Согласованность по happens-before
Для следа в таблице 17.5, изначально A == B ==
0. След может наблюдать r2 == 0 и r1 == 0 и всё равно быть согласованным по happens-before, так как существуют порядки выполнения, которые позволяют каждому чтению видеть соответствующую запись.
Таблица 17.5. Поведение, разрешенное согласованностью по happens-before, но не последовательной согласованностью.
| Поток 1 | Поток 2 |
|---|---|
B = 1; | A = 2; |
r2 = A; | r1 = B; |
Так как нет синхронизации, каждое чтение может увидеть либо запись начального значения, либо запись другого потока. Порядок выполнения, который демонстрирует это поведение, это:
1: B = 1; 3: A = 2; 2: r2 = A; // sees initial write of 0 4: r1 = B; // sees initial write of 0
Другой порядок выполнения, который согласован по happens-before:
1: r2 = A; // sees write of A = 2 3: r1 = B; // sees write of B = 1 2: B = 1; 4: A = 2;
В этом выполнении чтения видят записи, которые происходят позже в порядке выполнения. Это может показаться неинтуитивным, но разрешается согласованностью по happens-before. Разрешение чтению видеть более поздние записи иногда может привести к неприемлемому поведению.
Выполнение E описывается кортежем < P, A, po, so, W, V, sw, hb >, состоящим из:
-
P - программа
-
A - набор действий
-
po - порядок выполнения, который для каждого потока t представляет собой полную упорядоченность всех действий, выполненных t в A
-
so - порядок синхронизации, который представляет собой полную упорядоченность всех действий синхронизации в A
-
W - функция чтения записи, которая для каждого чтения r в A возвращает W(r), действие записи, увиденное r в E.
-
V - функция записи значения, которая для каждой записи w в A возвращает V(w), значение, записанное w в E.
-
sw - синхронизируется-с, частичный порядок над действиями синхронизации
-
hb - происходит-до, частичный порядок над действиями
Обратите внимание, что элементы синхронизируется-с и происходит-до однозначно определяются другими компонентами выполнения и правилами для корректных выполнений (§17.4.7).
Выполнение является последовательным относительно происходит-до, если его набор действий является последовательным относительно происходит-до (§17.4.5).
Мы рассматриваем только корректные выполнения. Выполнение E = < P, A, po, so, W, V, sw, hb > является корректным, если выполнены следующие условия:
-
Каждое чтение видит запись в ту же переменную в выполнении.
Все чтения и записи переменных volatile являются действиями volatile. Для всех чтений r в A у нас есть W(r) в A и W(r).v = r.v. Переменная r.v является volatile тогда и только тогда, когда r - volatile чтение, а переменная w.v является volatile тогда и только тогда, когда w - volatile запись.
-
Порядок происходит-до является частичным порядком.
Порядок происходит-до задается транзитивным замыканием рёбер синхронизируется-с и порядка выполнения. Он должен быть допустимым частичным порядком: рефлексивным, транзитивным и антисимметричным.
-
Выполнение подчиняется внутрипотоковой согласованности.
Для каждого потока t, действия, выполненные t в A, такие же, как были бы сгенерированы этим потоком в порядке выполнения изолированно, причём каждая запись w записывает значение V(w), при условии, что каждое чтение r видит значение V(W(r)). Видимые значения каждого чтения определяются моделью памяти. Указанный порядок выполнения должен соответствовать порядку выполнения в программе согласно внутрипотоковой семантике P.
-
Выполнение является последовательным относительно происходит-до (§17.4.6).
-
Выполнение подчиняется согласованности порядка синхронизации.
Для всех volatile чтений r в A, не выполняется ни so(r, W(r)), ни существует запись w в A такая, что w.v = r.v и so(W(r), w) и so(w, r).
Мы используем f|d для обозначения функции, полученной путем ограничения области определения f до d. Для всех x в d, f|d(x) = f(x), и для всех x, не принадлежащих d, f|d(x) не определено.
Мы используем p|d для обозначения ограничения частичного порядка p на элементы в d. Для всех x,y в d, p(x,y) тогда и только тогда, когда p|d(x,y). Если x или y не принадлежат d, то p|d(x,y) неверно.
Выполнение E = < P, A, po, so, W, V, sw, hb > считается корректным при фиксировании действий из A. Если все действия в A могут быть зафиксированы, то выполнение удовлетворяет требованиям причинно-следственной связи модели памяти языка программирования Java.
Начиная с пустого множества как C0, мы выполняем последовательность шагов, где мы берем действия из множества действий A и добавляем их в множество зафиксированных действий Ci для получения нового множества зафиксированных действий Ci+1. Для демонстрации того, что это приемлемо, для каждого Ci нам необходимо продемонстрировать выполнение E, содержащее Ci, которое удовлетворяет определенным условиям.
Формально, выполнение E удовлетворяет требованиям причинно-следственной связи модели памяти языка программирования Java тогда и только тогда, когда существуют:
-
Множества действий C0, C1, ... такие, что:
-
C0 — пустое множество
-
Ci — собственное подмножество Ci+1
-
A = ∪ (C0, C1, ...)
Если A конечно, то последовательность C0, C1, ... будет конечной, заканчиваясь множеством Cn = A.
Если A бесконечно, то последовательность C0, C1, ... может быть бесконечной, и необходимо, чтобы объединение всех элементов этой бесконечной последовательности было равно A.
-
-
Корректные выполнения E1, ..., где Ei = < P, Ai, poi, soi, Wi, Vi, swi, hbi >.
Учитывая эти множества действий C0, ... и выполнения E1, ..., каждое действие в Ci должно быть одним из действий в Ei. Все действия в Ci должны иметь тот же относительный порядок «происходит до» и порядок синхронизации как в Ei, так и в E. Формально:
-
Ci является подмножеством Ai
-
hbi|Ci = hb|Ci
-
soi|Ci = so|Ci
Значения, записанные записями в Ci, должны быть одинаковыми как в Ei, так и в E. Только чтения в Ci-1 должны видеть те же записи в Ei, что и в E. Формально:
-
Vi|Ci = V|Ci
-
Wi|Ci-1 = W|Ci-1
Все чтения в Ei, которые не находятся в Ci-1, должны видеть записи, которые происходят до них. Каждое чтение r в Ci - Ci-1 должно видеть записи в Ci-1 как в Ei, так и в E, но может видеть другую запись в Ei по сравнению с той, которую оно видит в E. Формально:
-
Для любого чтения r в Ai - Ci-1, у нас есть hbi(Wi(r), r)
-
Для любого чтения r в (Ci - Ci-1), у нас есть Wi(r) в Ci-1 и W(r) в Ci-1
Учитывая множество достаточных ребер синхронизации для Ei, если существует пара «выпуск-получение», которая происходит до (§17.4.5) действия, которое вы фиксируете, то эта пара должна присутствовать во всех Ej, где j ≥ i. Формально:
-
Пусть sswi — ребра swi, которые также находятся в транзитивном редукции hbi, но не в po. Мы называем sswi достаточными ребрами синхронизации для Ei. Если sswi(x, y) и hbi(y, z) и z в Ci, то swj(x, y) для всех j ≥ i.
Если действие y зафиксировано, все внешние действия, которые происходят до y, также фиксируются.
-
Если y в Ci, x — внешнее действие и hbi(x, y), то x в Ci.
Пример 17.4.8-1. Последовательная согласованность недостаточна
Последовательная согласованность — это необходимое, но недостаточное условие. Простое обеспечение последовательной согласованности допускает неприемлемое поведение — поведение, которое нарушает требования, установленные для программ. Например, последовательная согласованность допускает появление значений «из ниоткуда». Это можно увидеть, подробно рассмотрев трассировку в таблице 17.6.
Таблица 17.6. Последовательная согласованность недостаточна
| Поток 1 | Поток 2 |
|---|---|
r1 = x; | r2 = y; |
if (r1 != 0) y = 1; | if (r2 != 0) x = 1; |
Код, показанный в таблице 17.6, синхронизирован правильно. Это может показаться неожиданным, так как он не выполняет никаких действий синхронизации. Однако помните, что программа синхронизирована правильно, если при выполнении в последовательно согласованном режиме не возникает гонок данных. Если этот код выполняется в последовательно согласованном режиме, каждое действие будет выполняться в порядке программы, и ни одна из записей не будет выполнена. Поскольку записи не выполняются, не может быть гонок данных: программа синхронизирована правильно.
Поскольку эта программа синхронизирована правильно, мы можем допустить только последовательно согласованные варианты поведения. Однако существует выполнение этой программы, которое согласовано с учётом «happens-before», но не последовательно:
r1 = x; // sees write of x = 1 y = 1; r2 = y; // sees write of y = 1 x = 1;
Этот результат согласован с учётом «happens-before»: не существует отношений «happens-before», которые могли бы его предотвратить. Однако, он явно неприемлем: не существует последовательно согласованного выполнения, которое привело бы к такому поведению. Тот факт, что чтение может увидеть запись, которая происходит позже в порядке выполнения, иногда может приводить к неприемлемому поведению.
Хотя чтение, которое видит запись, идущую позже в порядке выполнения, иногда нежелательно, иногда это необходимо. Как мы видели выше, трассировка в таблице 17.5 требует, чтобы некоторые чтения видели записи, которые происходят позже в порядке выполнения. Поскольку чтения идут первыми в каждом потоке, самым первым действием в порядке выполнения должно быть чтение. Если это чтение не может увидеть запись, которая происходит позже, то оно не может увидеть ни одного значения, кроме начального значения для переменной, которую оно читает. Это явно не отражает всё поведение.
Мы называем проблему, когда чтения могут видеть будущие записи, причинно-следственной связью, из-за проблем, возникающих в таких случаях, как тот, который найден в таблице 17.6. В этом случае чтения вызывают записи, а записи вызывают чтения. Нет «первопричины» для действий. Поэтому нашей модели памяти нужен согласованный способ определения того, какие чтения могут видеть записи раньше.
Примеры, подобные тому, который приведен в таблице 17.6, показывают, что спецификация должна быть осторожна при указании того, может ли чтение увидеть запись, которая происходит позже в выполнении (имея в виду, что если чтение видит запись, которая происходит позже в выполнении, это означает, что запись фактически выполняется раньше).
Модель памяти принимает на вход заданное выполнение и программу и определяет, является ли это выполнение законным выполнением программы. Она делает это, постепенно создавая набор «зафиксированных» действий, которые отражают, какие действия были выполнены программой. Обычно следующее действие для фиксации отражает следующее действие, которое может быть выполнено последовательно согласованным выполнением. Однако, чтобы отразить чтения, которые должны видеть более поздние записи, мы разрешаем некоторым действиям фиксироваться раньше, чем другие действия, которые предшествуют им.
Очевидно, что некоторые действия могут быть зафиксированы раньше, а некоторые — нет. Если, например, одна из записей в таблице 17.6 была зафиксирована до чтения этой переменной, чтение могло бы увидеть запись, и мог возникнуть результат «из ниоткуда». Неформально, мы разрешаем действию фиксироваться раньше, если мы знаем, что действие может произойти без предположения о возникновении гонки данных. В таблице 17.6 мы не можем выполнить ни одну запись раньше, потому что записи не могут произойти, пока чтения не увидят результат гонки данных.
Для программ, которые всегда завершаются в некоторый ограниченный конечный период времени, их поведение можно понять (неформально) просто в терминах их допустимых выполнений. Для программ, которые могут не завершиться за ограниченное время, возникают более тонкие вопросы.
Наблюдаемое поведение программы определяется конечными наборами внешних действий, которые программа может выполнить. Например, программа, которая просто постоянно печатает "Hello", описывается набором поведений, который для любого неотрицательного целого числа i включает поведение печати "Hello" i раз.
Завершение не моделируется явно как поведение, но программу можно легко расширить, чтобы она генерировала дополнительное внешнее действие executionTermination, которое происходит, когда все потоки завершились.
Мы также определяем специальное действие hang. Если поведение описывается набором внешних действий, включающим действие hang, это указывает на поведение, при котором после наблюдения внешних действий программа может выполняться неограниченное время без выполнения дополнительных внешних действий или завершения. Программы могут зависнуть, если все потоки заблокированы или если программа может выполнить неограниченное количество действий без выполнения каких-либо внешних действий.
Поток может быть заблокирован в различных обстоятельствах, таких как попытка получить блокировку или выполнить внешнее действие (например, чтение), которое зависит от внешних данных.
Выполнение может привести к тому, что поток будет заблокирован на неопределённое время, и выполнение не завершится. В таких случаях действия, сгенерированные заблокированным потоком, должны состоять из всех действий, сгенерированных этим потоком вплоть до и включая действие, которое привело к блокировке потока, и никаких действий, которые были бы сгенерированы потоком после этого действия.
Для анализа наблюдаемых поведений нам нужно говорить о множествах наблюдаемых действий.
Если O является множеством наблюдаемых действий для выполнения E, то множество O должно быть подмножеством действий E, A, и должно содержать только конечное число действий, даже если A содержит бесконечное число действий. Кроме того, если действие y находится в O, и либо hb(x, y), либо so(x, y), то x находится в O.
Обратите внимание, что набор наблюдаемых действий не ограничивается внешними действиями. Скорее, только внешние действия, которые находятся в наборе наблюдаемых действий, считаются наблюдаемыми внешними действиями.
Поведение B является допустимым поведением программы P тогда и только тогда, когда B является конечным набором внешних действий и либо:
-
Существует выполнение E программы P, и множество O наблюдаемых действий для E, и B является множеством внешних действий в O (Если какие-либо потоки в E заканчиваются в заблокированном состоянии, и O содержит все действия в E, то B также может содержать действие hang); или
-
Существует множество O действий, такое что B состоит из действия hang плюс всех внешних действий в O, и для всех k ≥ | O |, существует выполнение E программы P с действиями A, и существует множество действий O' такое, что:
-
И O, и O' являются подмножествами A, которые удовлетворяют требованиям к множествам наблюдаемых действий.
-
O ⊆ O' ⊆ A
-
| O' | ≥ k
-
O' - O не содержит внешних действий
-
Обратите внимание, что поведение B не описывает порядок наблюдения внешних действий в B, но другие (внутренние) ограничения на то, как генерируются и выполняются внешние действия, могут накладывать такие ограничения.
Поля, объявленные final, инициализируются один раз, но в нормальных условиях их значение никогда не изменяется. Подробная семантика final полей несколько отличается от семантики обычных полей. В частности, компиляторы имеют значительную свободу в перемещении чтений final полей через блоки синхронизации и вызовы произвольных или неизвестных методов. Соответственно, компиляторы разрешено сохранять значение final поля в регистре и не перезагружать его из памяти в ситуациях, когда для обычного поля (не-final) это перезагрузка необходима.
final поля также позволяют программистам реализовывать потокобезопасные неизменяемые объекты без синхронизации. Потокобезопасный неизменяемый объект рассматривается как неизменяемый всеми потоками, даже если для передачи ссылок на этот неизменяемый объект между потоками используется гонка данных. Это может обеспечить гарантии безопасности от злоупотребления неизменяемым классом некорректным или вредоносным кодом. final поля необходимо использовать правильно, чтобы гарантировать неизменяемость.
Объект считается полностью инициализированным, когда завершается его конструктор. Поток, который может видеть только ссылку на объект после того, как этот объект был полностью инициализирован, гарантированно увидит правильно инициализированные значения final полей этого объекта.
Модель использования final полей проста: установить final поля объекта в конструкторе этого объекта; и не записывать ссылку на конструируемый объект в месте, где другой поток может его увидеть до завершения конструктора объекта. Если это соблюдается, то когда другой поток увидит объект, этот поток всегда увидит правильно построенную версию final полей этого объекта. Он также увидит версии любых объектов или массивов, на которые ссылаются эти final поля, которые, по крайней мере, так же актуальны, как и сами final поля.
Пример 17.5-1. final Поля в модели памяти Java
Программа ниже иллюстрирует, как final поля сравниваются с обычными полями.
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; // guaranteed to see 3
int j = f.y; // could see 0
}
}
}
Класс FinalFieldExample содержит final int поле x и обычное (не-final) int поле y. Один поток может выполнить метод writer, а другой — метод reader.
Поскольку метод writer записывает f после завершения конструктора объекта, метод reader гарантированно увидит правильно инициализированное значение для f.x: он прочитает значение 3. Однако, f.y не является final; метод reader, следовательно, не гарантируется увидеть значение 4 для него.
Пример 17.5-2. final Поля для безопасности
final поля разработаны для обеспечения необходимых гарантий безопасности. Рассмотрим следующую программу. Один поток (который мы будем называть потоком 1) выполняет:
Global.s = "/tmp/usr".substring(4);
в то время как другой поток (поток 2) выполняет
String myS = Global.s;
if (myS.equals("/tmp"))System.out.println(myS);
String объекты предназначены для неизменяемости, а операции со строками не выполняют синхронизацию. Хотя реализация String не имеет гонок данных, другой код может иметь гонки данных, связанные с использованием String объектов, и модель памяти делает слабые гарантии для программ, которые имеют гонки данных. В частности, если поля класса String не были final, то было бы возможно (хотя и маловероятно), что поток 2 изначально увидит значение по умолчанию для смещения объекта строки, что позволит ему сравнивать его как равное "/tmp". Позже операция с объектом String может увидеть правильное смещение 4, так что объект String воспринимается как "/usr". Многие функции безопасности языка программирования Java зависят от того, что объекты String воспринимаются как действительно неизменяемые, даже если вредоносный код использует гонки данных для передачи String ссылок между потоками.
Пусть o — объект, а c — конструктор для o, в котором записывается final поле f. Действие freeze для final поля f объекта o происходит при завершении конструктора c, будь то нормальное завершение или аварийное.
Обратите внимание, что если один конструктор вызывает другой конструктор, и вызванный конструктор устанавливает final поле, действие freeze для final поля происходит в конце вызываемого конструктора.
Для каждого выполнения поведение чтений определяется двумя дополнительными частичными порядками: цепочкой ссылок dereferences() и цепочкой памяти mc(), которые считаются частью выполнения (и, следовательно, фиксированы для любого конкретного выполнения). Эти частичные порядки должны удовлетворять следующим ограничениям (которые не обязательно имеют единственное решение):
-
Цепочка ссылок: Если действие a — чтение или запись поля или элемента объекта o потоком t, который не инициализировал o, то должен существовать некоторый чтение r потоком t, который видит адрес o, такое, что r dereferences(r, a).
-
Цепочка памяти: Существует несколько ограничений на порядок цепочки памяти:
-
Если r — чтение, которое видит запись w, то должно выполняться mc(w, r).
-
Если r и a — действия такие, что dereferences(r, a), то должно выполняться mc(r, a).
-
Если w — запись адреса объекта o потоком t, который не инициализировал o, то должен существовать некоторый чтение r потоком t, которое видит адрес o, такое, что mc(r, w).
-
Учитывая запись w, действие freeze f, действие a (которое не является чтением final поля), чтение r1 final поля, замороженного f, и чтение r2 такое, что hb(w, f), hb(f, a), mc(a, r1), и dereferences(r1, r2), то при определении значений, которые может увидеть r2, мы учитываем hb(w, r2). (Этот порядок happens-before не транзитивно закрывается с другими порядками happens-before).
Обратите внимание, что порядок dereferences рефлексивен, и r1 может быть таким же, как r2.
Для чтений final полей, единственные записи, которые считаются предшествующими чтению final поля, — это те, которые получены через семантику final поля.
Чтение final поля объекта внутри потока, который строит этот объект, упорядочено относительно инициализации этого поля внутри конструктора обычными правилами happens-before. Если чтение происходит после того, как поле установлено в конструкторе, оно видит значение, присвоенное final полю, иначе — значение по умолчанию.
В некоторых случаях, таких как десериализация, системе потребуется изменить поля final объекта после его создания. Поля final могут быть изменены с помощью рефлексии и других механизмов, зависящих от реализации. Единственный шаблон, в котором это имеет разумную семантику, заключается в том, что объект создаётся, а затем поля final объекта обновляются. Объект не должен быть доступен другим потокам, и поля final не должны считываться, пока не будут завершены все обновления полей final объекта. Замораживание поля final происходит как в конце конструктора, в котором устанавливается поле final, так и сразу после каждого изменения поля final с помощью рефлексии или других специальных механизмов.
Даже тогда есть ряд осложнений. Если поле final инициализируется выражением константы времени компиляции (§15.28) в объявлении поля, изменения поля final могут не быть замечены, так как использование этого поля final заменяется на значение константного выражения во время компиляции.
Другая проблема заключается в том, что спецификация допускает агрессивную оптимизацию полей final. Внутри потока разрешается переупорядочить чтение поля final с изменениями поля final, которые не происходят в конструкторе.
Пример 17.5.3-1. Агрессивная Оптимизация полей final
class A {
final int x;
A() {
x = 1;
}
int f() {
return d(this,this);
}
int d(A a1, A a2) {
int i = a1.x;
g(a1);
int j = a2.x;
return j - i;
}
static void g(A a) {
// uses reflection to change a.x to 2
}
}
В методе d компилятор имеет право свободно переупорядочивать чтение x и вызов g. Таким образом, new A().f() может вернуть -1, 0 или 1.
Реализация может предоставить способ выполнения блока кода в final-безопасном контексте. Если объект создаётся в final-безопасном контексте, чтение поля final этого объекта не будет переупорядочено с изменениями этого поля final, которые происходят в этом final-безопасном контексте.
final-безопасный контекст имеет дополнительные защиты. Если поток увидел неправильно опубликованную ссылку на объект, который позволяет потоку увидеть значение по умолчанию поля final, а затем, в пределах final-безопасного контекста, считывает правильно опубликованную ссылку на объект, ему гарантированно будет показано правильное значение поля final. В формализме код, выполняемый в пределах final-безопасного контекста, рассматривается как отдельный поток (только для целей семантики поля final).
В реализации компилятор не должен перемещать доступ к полю final в final-безопасный контекст или из него (хотя он может перемещаться вокруг выполнения такого контекста, при условии, что объект не создаётся в этом контексте).
Одно место, где использование final-безопасного контекста было бы уместно, — это в исполнителе или пуле потоков. Выполняя каждый Runnable в отдельном final-безопасном контексте, исполнитель мог бы гарантировать, что неверный доступ одного Runnable к объекту o не удалит гарантии поля final для других Runnable, обрабатываемых тем же исполнителем.
Обычно поле, которое является final и static, не может быть изменено. Однако, поля System.in, System.out и System.err — это static защищенные от записи поля, которые по причинам совместимости должны быть изменяемы методами System.setIn, System.setOut и System.setErr. Мы называем эти поля защищенными от записи, чтобы отличить их от обычных полей final.
Компилятор должен по-разному обрабатывать эти поля по сравнению с другими полями final. Например, чтение обычного поля final «иммунно» к синхронизации: барьер, связанный с блокировкой или считыванием volatile, не должен влиять на значение, считанное из поля final. Поскольку значение защищенных от записи полей может изменяться, события синхронизации должны на них влиять. Поэтому семантика предписывает обрабатывать эти поля как обычные поля, которые нельзя изменить кодом пользователя, если этот код пользователя не находится в классе System.
Одно соображение для реализации Java Virtual Machine заключается в том, что каждое поле и каждый элемент массива рассматриваются как отдельные; обновления одного поля или элемента не должны взаимодействовать со считыванием или обновлением любого другого поля или элемента. В частности, два потока, обновляющие смежные элементы массива байтов по отдельности, не должны взаимодействовать и не нуждаются в синхронизации для обеспечения последовательной согласованности.
Некоторые процессоры не обеспечивают возможность записи в один байт. Было бы неправильно реализовывать обновления массива байтов на таком процессоре, просто считывая всё слово, обновляя соответствующий байт и затем записывая всё слово обратно в память. Эта проблема иногда известна как разрыв слова, и на процессорах, которые не могут легко обновить один байт изолированно, потребуется другой подход.
Пример 17.6-1. Обнаружение Разрыва Слова
Следующая программа — тестовый случай для обнаружения разрыва слова:
public class WordTearing extends Thread {
static final int LENGTH = 8;
static final int ITERS = 1000000;
static byte[] counts = new byte[LENGTH];
static Thread[] threads = new Thread[LENGTH];
final int id;
WordTearing(int i) {
id = i;
}
public void run() {
byte v = 0;
for (int i = 0; i < ITERS; i++) {
byte v2 = counts[id];
if (v != v2) {
System.err.println("Word-Tearing found: " +
"counts[" + id + "] = "+ v2 +
", should be " + v);
return;
}
v++;
counts[id] = v;
}
}
public static void main(String[] args) {
for (int i = 0; i < LENGTH; ++i)
(threads[i] = new WordTearing(i)).start();
}
}
Это показывает, что байты не должны перезаписываться при записи в смежные байты.
Для целей модели памяти языка программирования Java отдельная запись в не-volatile значение long или double рассматривается как две отдельные записи: по одной в каждую 32-битную половину. Это может привести к ситуации, когда поток видит первые 32 бита 64-битного значения из одной записи и вторые 32 бита из другой записи.
Записи и чтения volatile значений long и double всегда атомарны.
Записи и чтения ссылок всегда атомарны, независимо от того, реализованы ли они как 32-битные или 64-битные значения.
Некоторые реализации могут найти удобным разделить одно действие записи в 64-битном значении long или double на два действия записи в соседние 32-битные значения. Ради эффективности это поведение зависит от реализации; реализация Java Virtual Machine свободна выполнять записи в значения long и double атомарно или в двух частях.
Реализации Java Virtual Machine рекомендуют избегать разделения 64-битных значений, когда это возможно. Программисты рекомендуют объявлять общие 64-битные значения как volatile или корректно синхронизировать свои программы, чтобы избежать возможных осложнений.
© Oracle and/or its affiliates. All rights reserved.
Licensed under the Oracle Technology Network License Agreement.