Глава 17. Потоки и блокировки
Содержание
В то время как большинство обсуждений в предыдущих главах касаются только поведения кода, выполняемого по одной инструкции или выражению за раз, то есть одним потоком, виртуальная машина 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 — поток, вызывающий метод .interrupt для некоторого потока u, где t и u могут быть одинаковыми. Это действие устанавливает состояние прерывания u в истинное.
Кроме того, если существует некоторый объект m, чей набор ожиданий содержит u, то u удаляется из набора ожиданий m. Это позволяет u возобновить действие ожидания, в котором это ожидание, после повторной блокировки монитора m, выбросит исключение InterruptedException.
Вызовы Thread.isInterrupted могут определить состояние прерывания потока. Метод static может быть вызван потоком для наблюдения и очистки собственного состояния прерывания.
Вышеуказанные спецификации позволяют определить несколько свойств, связанных с взаимодействием ожидания, уведомления и прерывания.
Если поток как уведомляется, так и прерывается во время ожидания, он может:
-
нормально вернуться из
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.4-A. Эта программа использует локальные переменные r1 и r2 и общие переменные A и B. Изначально, A == B ==
0.
Таблица 17.4-A. Неожиданные результаты, вызванные переупорядочением операторов - исходный код
| Поток 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.4-B, то легко увидеть, как результат r2 == 2 и r1 == 1 может произойти.
Таблица 17.4-B. Неожиданные результаты, вызванные переупорядочением операторов - допустимое преобразование компилятора
| Поток 1 | Поток 2 |
|---|---|
B = 1; | r1 = B; |
r2 = A; | A = 2; |
Для некоторых программистов такое поведение может показаться «сломанным». Однако следует отметить, что этот код ненадлежащим образом синхронизирован:
-
есть запись в одном потоке,
-
чтение той же переменной другим потоком,
-
и запись и чтение не упорядочены синхронизацией.
Эта ситуация является примером гонки данных (§17.4.5). Когда код содержит гонку данных, часто возможны неинтуитивные результаты.
Несколько механизмов могут привести к переупорядочению в таблице 17.4-B. Компилятор Just-In-Time в реализации Java Virtual Machine может переупорядочивать код, или процессор. Кроме того, иерархия памяти архитектуры, на которой выполняется реализация Java Virtual Machine, может сделать так, что код будет казаться переупорядоченным. В этой главе мы будем называть всё, что может переупорядочивать код, компилятором.
Другой пример неожиданных результатов можно увидеть в таблице 17.4-C. Изначально, p == q и p.x == 0. Эта программа также неправильно синхронизирована; она записывает в общую память без соблюдения какого-либо порядка между этими записями.
Модель памяти определяет, какие значения могут быть прочитаны в любой точке программы. Действия каждого потока изолированно должны вести себя, как определено семантикой этого потока, за исключением того, что значения, наблюдаемые каждым чтением, определяются моделью памяти. Когда мы ссылаемся на это, мы говорим, что программа подчиняется внутрипотоковой семантике. Внутрипотоковая семантика – это семантика однопоточных программ, и позволяет полностью предсказать поведение потока на основе значений, наблюдаемых действиями чтения внутри потока. Для определения, являются ли действия потока 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.4-C, как только произошла запись 3 в p.x, последующие чтения этого местоположения должны были видеть это значение.
Каждое выполнение имеет порядок синхронизации. Порядок синхронизации — это общий порядок всех действий синхронизации в выполнении. Для каждой нити t порядок синхронизации действий синхронизации (§17.4.2) в t согласован с порядком программы (§17.4.3) нити t.
Действия синхронизации определяют отношение synchronized-with между действиями, как следует:
-
Действие разблокировки монитора m synchronizes-with все последующие действия блокировки на m (где «последующее» определяется по порядку синхронизации).
-
Запись в переменную volatile v (§8.3.1.4) synchronizes-with все последующие чтения v любой нитью (где «последующее» определяется по порядку синхронизации).
-
Действие, которое запускает нить t, 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 называется освобождением, а пункт назначения — получением.
Две операции могут быть упорядочены с помощью отношения предшествования. Если одна операция предшествует другой, то первая видна и упорядочена перед второй.
Если у нас есть две операции x и y, мы запишем hb(x, y), чтобы указать, что x предшествует y.
-
Если x и y являются операциями одного потока и x предшествует y в порядке программы, то hb(x, y).
-
Существует связь предшествования от конца конструктора объекта до начала финализатора (§12.6) для этого объекта.
-
Если операция x синхронизируется со следующей операцией y, то у нас также есть hb(x, y).
-
Если hb(x, y) и hb(y, z), то hb(x, z).
Методы wait класса Object (§17.2.1) имеют связанные с ними действия блокировки и разблокировки; их отношения предшествования определяются этими связанными действиями.
Следует отметить, что наличие отношения предшествования между двумя операциями не обязательно подразумевает, что они должны выполняться в этом порядке в реализации. Если переупорядочение приводит к результатам, согласующимся с законным выполнением, это не является ошибкой.
Например, запись значения по умолчанию в каждое поле объекта, созданного потоком, не должна происходить перед началом этого потока, пока ни один чтение никогда не наблюдает это.
Более конкретно, если две операции связаны отношением предшествования, они не обязательно должны появляться в этом порядке для любого кода, с которым они не связаны отношением предшествования. Записи в одном потоке, которые находятся в гонке данных с чтениями в другом потоке, например, могут показаться происходящими вне порядка для этих чтений.
Отношение предшествования определяет, когда происходят гонки данных.
Набор синхронизационных дуг S является достаточным, если это минимальный набор, такой что транзитивное замыкание S с порядком программы определяет все дуги предшествования в выполнении. Этот набор уникален.
Из вышеприведенных определений следует, что:
-
Разблокировка монитора предшествует любой последующей блокировке этого монитора.
-
Запись в поле
volatile(§8.3.1.4) предшествует любому последующему чтению этого поля. -
Вызов
start()в потоке предшествует любым операциям в запущенном потоке. -
Все действия в потоке предшествуют любому другому потоку, который успешно возвращается из
join()в этом потоке. -
Инициализация по умолчанию любого объекта предшествует любым другим действиям (кроме записей по умолчанию) программы.
Когда программа содержит два конфликтующих доступа (§17.4.1), которые не упорядочены отношением предшествования, говорят, что она содержит гонку данных.
Семантика операций, отличных от межпоточных действий, таких как чтение длин массивов (§10.7), выполнение проверок преобразований (§5.5, §15.16) и вызовы виртуальных методов (§15.12), не затрагиваются напрямую гонками данных.
Следовательно, гонка данных не может привести к некорректной работе, например, к возвращению неправильной длины массива.
Программа правильно синхронизирована тогда и только тогда, когда все последовательно согласованные выполнения не содержат гонок данных.
Если программа правильно синхронизирована, все выполнения программы будут казаться последовательно согласованными (§17.4.3).
Это чрезвычайно сильная гарантия для программистов. Программисты не должны учитывать переупорядочения для определения того, что их код содержит гонки данных. Следовательно, им не нужно учитывать переупорядочения при определении, правильно ли синхронизирован их код. После того, как будет сделано заключение о правильной синхронизации кода, программисту не нужно беспокоиться о том, что переупорядочения повлияют на его код.
Программа должна быть правильно синхронизирована, чтобы избежать непонятных поведений, которые могут быть обнаружены при переупорядочении кода. Использование правильной синхронизации не гарантирует правильность поведения всей программы. Однако оно позволяет программисту простым способом рассуждать о возможных поведенческих программах; поведение правильно синхронизированной программы намного меньше зависит от возможных переупорядочений. Без правильной синхронизации возможны очень странные, запутанные и непонятные поведения.
Мы говорим, что чтение r переменной v разрешено наблюдать запись w в v, если в частичном порядке предшествования следа выполнения:
-
r не упорядочено перед w (т.е., неверно, что hb(r, w)), и
-
нет промежуточной записи w' в v (т.е. нет записи w' в v такой, что hb(w, w') и hb(w', r)).
Неформально, чтение r разрешено увидеть результат записи w, если нет порядка предшествования, который может предотвратить это чтение.
Набор действий A является согласованным по предшествованию, если для всех чтений r в A, где W(r) — операция записи, увиденная r, неверно, что либо hb(r, W(r)), либо существует запись w в A, такая что w.v = r.v и hb(W(r), w) и hb(w, r).
В наборе согласованных по предшествованию действий каждое чтение видит запись, которую оно может увидеть в соответствии с порядком предшествования.
Пример 17.4.5-1. Согласованность предшествования
Для следа в Таблице 17.4.5-A, изначально A == B == 0. След может наблюдать r2 == 0 и r1 == 0 и все еще быть согласованным по предшествованию, поскольку существуют порядки выполнения, которые позволяют каждому чтению видеть соответствующую запись.
Таблица 17.4.5-A. Поведение, разрешенное согласованностью предшествования, но не последовательной согласованностью.
| Поток 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
Другой порядок выполнения, который является согласованным по предшествованию:
1: r2 = A; // sees write of A = 2 3: r1 = B; // sees write of B = 1 2: B = 1; 4: A = 2;
В этом выполнении чтения видят записи, которые появляются позже в порядке выполнения. Это может показаться нелогичным, но разрешено согласованностью по предшествованию. Разрешение чтений видеть более поздние записи может иногда привести к неприемлемым поведениям.
Выполнение 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 - происходит-перед, частичный порядок над действиями
Обратите внимание, что элементы synchronizes-with и happens-before однозначно определяются другими компонентами выполнения и правилами для корректных выполнений (§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 записью.
-
Порядок происходит-перед является частичным порядком.
Порядок происходит-перед задаётся транзитивным замыканием рёбер synchronizes-with и порядком выполнения. Он должен быть корректным частичным порядком: рефлексивным, транзитивным и антисимметричным.
-
Выполнение подчиняется согласованности внутри нитей.
Для каждой нити 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. Согласованность happens-before недостаточна
Согласованность happens-before является необходимым, но не достаточным набором ограничений. Простое применение согласованности happens-before позволило бы недопустимые действия — те, которые нарушают требования, установленные для программ. Например, согласованность happens-before позволяет значениям появляться «из ничего». Это можно увидеть, подробно рассмотрев трассировку в таблице 17.4.8-A.
Таблица 17.4.8-A. Согласованность happens-before недостаточна
| Поток 1 | Поток 2 |
|---|---|
r1 = x; | r2 = y; |
if (r1 != 0) y = 1; | if (r2 != 0) x = 1; |
Код, показанный в таблице 17.4.8-A, синхронизирован правильно. Это может показаться неожиданным, поскольку он не выполняет никаких действий синхронизации. Однако помните, что программа синхронизирована правильно, если при её выполнении в последовательно-согласованном режиме нет гонок данных. Если этот код выполняется последовательно-согласованным образом, каждое действие произойдёт в порядке программы, и ни одно из записей не произойдёт. Поскольку записей не происходит, гонок данных быть не может: программа синхронизирована правильно.
Поскольку эта программа синхронизирована правильно, единственные разрешенные действия — последовательно-согласованные. Однако существует выполнение этой программы, которое согласовано по 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.4.5-A требует, чтобы некоторые чтения видели записи, которые происходят позже в порядке выполнения. Поскольку чтения идут первыми в каждом потоке, первым действием в порядке выполнения должно быть чтение. Если это чтение не может увидеть запись, которая происходит позже, оно не может увидеть никакого значения, кроме начального значения переменной, которую оно считывает. Это явно не отражает всех возможных действий.
Мы называем проблему, когда чтения могут видеть будущие записи, причинностью, из-за проблем, возникающих в таких случаях, как тот, который можно найти в таблице 17.4.8-A. В этом случае чтения вызывают записи, а записи вызывают чтения. Нет «первопричины» для действий. Таким образом, нашей модели памяти нужен согласованный способ определения, какие чтения могут видеть ранние записи.
Примеры, такие как тот, который можно найти в таблице 17.4.8-A, показывают, что спецификация должна быть внимательна при указании того, может ли чтение увидеть запись, которая происходит позже в выполнении (имея в виду, что если чтение видит запись, которая происходит позже в выполнении, это означает, что запись фактически выполняется раньше).
Модель памяти принимает на вход заданное выполнение и программу и определяет, является ли это выполнение законным выполнением программы. Она делает это, постепенно строя набор «зафиксированных» действий, которые отражают, какие действия были выполнены программой. Обычно следующее действие, которое будет зафиксировано, отражает следующее действие, которое может быть выполнено последовательно-согласованным выполнением. Однако, чтобы отразить чтения, которые должны видеть последующие записи, мы разрешаем некоторым действиям фиксироваться раньше, чем другие действия, которые предшествуют им по happens-before.
Очевидно, что некоторые действия могут быть зафиксированы раньше, а некоторые — нет. Если, например, одна из записей в таблице 17.4.8-A была зафиксирована до чтения этой переменной, чтение могло бы увидеть запись, и мог бы произойти результат «из ничего». Неформально, мы разрешаем действие зафиксировать раньше, если знаем, что действие может произойти без предположения о том, что произошла гонка данных. В таблице 17.4.8-A мы не можем выполнить ни одну запись раньше, потому что записи не могут произойти, пока чтения не увидят результат гонки данных.
Для программ, которые всегда завершаются за конечный период времени, их поведение (неформально) можно понять просто на основе допустимых выполнений. Для программ, которые могут не завершиться за ограниченное время, возникают более тонкие вопросы.
Наблюдаемое поведение программы определяется конечными наборами внешних действий, которые может выполнить программа. Например, программа, которая просто бесконечно печатает "Hello", описывается набором поведений, включающих в себя поведение печати "Hello" i раз для любого неотрицательного целого числа 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, фиксацию 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.29) в объявлении поля, изменения поля 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 final полями, которые по причинам совместимости должны быть разрешены для изменения методами System.setIn, System.setOut и System.setErr. Мы называем эти поля защищёнными от записи для отличия от обычных полей final.
Компилятор должен по-разному обрабатывать эти поля по сравнению с другими полями final. Например, чтение обычного поля final «иммунно» к синхронизации: барьер, связанный с блокировкой или чтением `volatile`, не должен влиять на то, какое значение читается из поля final. Поскольку значение защищённых от записи полей может изменяться, события синхронизации должны влиять на них. Таким образом, семантика предписывает обрабатывать эти поля как обычные поля, которые нельзя изменить кодом пользователя, если этот код пользователя не находится в классе System.
Одним из соображений для реализаций виртуальной машины Java является то, что каждое поле и каждый элемент массива считаются отдельными; обновления одного поля или элемента не должны взаимодействовать с чтением или обновлениями любого другого поля или элемента. В частности, два потока, которые обновляют смежные элементы массива байтов по отдельности, не должны взаимодействовать или пересекаться и не нуждаются в синхронизации для обеспечения последовательной согласованности.
Некоторые процессоры не обеспечивают возможность записи в один байт. Будет незаконно реализовывать обновления массивов байтов на таком процессоре, просто читая весь слово, обновляя нужный байт и затем записывая всё слово обратно в память. Эта проблема иногда известна как разрыв слова, и на процессорах, которые не могут легко обновить отдельный байт изолированно, потребуется другой подход.
Пример 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 свободна выполнять записи в значения long и double атомарно или в двух частях.
Реализации виртуальной машины Java рекомендуются избегать разделения 64-битных значений, где это возможно. Программистам рекомендуется объявлять общие 64-битные значения как volatile или правильно синхронизировать свои программы, чтобы избежать возможных осложнений.
© Oracle and/or its affiliates. All rights reserved.
Licensed under the Oracle Technology Network License Agreement.