Теория и практика Java: Исправление модели памяти Java, часть 2

Как изменилась модель памяти Java в результате деятельности JSR 133?

Экспертная группа JSR 133, которая работает уже почти три года, недавно выпустила рекомендации для общего пользования о том, что делать с с моделью памяти Java. В Части 1 этой серии автор Брайан Гетц рассказал подробно о некоторых серьезных изъянах, которые были найдены в оригинальной модели памяти JMM, что привело к невероятному усложнению семантики для концептов, которые должны были быть простыми. В этом месяце он покажет, как семантика volatile и final изменится под новой моделью памяти JMM, какие изменения приведут их семантику в соответствие с интуицией большинства разработчиков. Некоторые из этих изменений уже присутствуют в JDK 1.4; другие будут ждать до выхода JDK 1.5. Поделитесь своими мыслями об этой статье с автором и другими читателями в соответствующем форуме.

Брайан Гетц, главный консультант, Quiotix

Брайан Гетц (Brian Goetz) - консультант по ПО и последние 15 лет работал профессиональными разработчиком ПО. Сейчас он является главным консультантом в фирме Quiotix, занимающейся разработкой ПО и консалтингом и находящейся в Лос-Альтос, Калифорния. Следите за публикациями Брайана в популярных промышленных изданиях. Вы можете связаться с Брайаном по адресу brian@quiotix.com



02.03.2007

Написание параллельного кода тяжело начинать, и язык не должен усложнять это. Хотя платформа Java включала в себя поддержку многопоточности с самого начала, в том числе межплатформенную модель памяти, которая была создана, чтобы гарантировать принцип "Пишем один раз, запускаем везде" для правильно синхронизированных программ, оригинальная модель памяти имела несколько дыр. И хотя многие платформы Java предоставляли более серьезные гарантии, чем это требовалось моделью памяти JMM, дыры в модели памяти JMM подрывали способность легко писать параллельные программы Java, которые могли бы работать на любой платформе. Поэтому в мае 2001 г. была сформирована экспертная группа JSR 133, и перед ней была поставлена задача совершенствования модели памяти Java. В прошлом месяце я уже говорил о некоторых из этих проблем; в этом месяце я расскажу о том, как они были решены.

Еще раз в вопросу о доступности

Один из ключевых концептов, необходимых для понимания модели памяти JMM - это видимость - как вы узнаете, что если поток A исполняет someVariable (некоторую переменную) = 3, другие потоки увидят значение 3, записанное там потоком A? Существует достаточное количество причин, почему другой поток не сможет мгновенно увидеть значение 3 для someVariable: это может быть, потому что компилятор перераспределил инструкции, чтобы исполнить их более эффективно, или someVariable была кэширована в регистре, или значение записано в кэш на обработчике записи, но не была еще сброшена в основную память, или есть старое (или устаревшее) значение в кэше обработчика чтения. Именно модель памяти определяет, когда поток может надежно "видеть" записи в переменную, сделанные другими потоками. В частности модель памяти определяет семантику volatile, synchronized и final, которая гарантирует доступность операций памяти по потокам.

Не пропустите остальные части этой серии

Часть 1, "Что такое модель памяти Java и какие проблем были с ней изначально?" (февраль 2004 г.)

Когда поток выходит из блока synchronized как часть освобождения соответствующего монитора, модель памяти Java требует, чтобы локальный кэш процессора был сброшен в основную память. (На самом деле модель памяти не говорит о кэшах, она говорит об абстракции, локальной памяти, которая включает кэши, регистры, аппаратные средства и другие средства оптимизации программы в процессе компилирования). Аналогичным образом как часть захвата монитора при вхождении в блок synchronized, локальные кэши объявляются недействительными таким образом, что последующие считывания пойдут напрямую в основную память, а не в локальный кэш. Этот процесс гарантирует, что когда переменная записана одним потоком во время блока synchronized, защищенного данным монитором, и читается другим потоком во время блока synchronized, защищенного тем же монитором, запись в переменную будет видна считывающему потоку. Модель памяти JMM не гарантирует этого в отсутствии синхронизации. Именно поэтому синхронизация (или ее более молодой собрат, volatile) должны использоваться всякий раз, когда несколько потоков обращаются к одним и тем же переменным.


Новые гарантии для volatile

Изначальная семантика volatile гарантировала только то, что чтение и запись полей volatile будут производиться напрямую в основную память, а не в регистры или локальный кэш процессора, и что действия над volatile-переменными от имени потока совершаются в том порядке, в котором запрашивает поток. Другими словами, это значит, что старая модель памяти обещала только видимость переменной для считывания или записи, но совсем не гарантировала видимость записи в другие переменные. Хотя это было легче осуществить эффективно, оказалось это менее полезно, чем изначально задумывалось.

Хотя чтение и запись volatile-переменных не могут быть перераспределены с чтением и записью других volatile-переменных, их все же можно перераспределить с чтением и записью не volatile-переменных. Из Части 1 вы узнали, что код Листинга 1 был недостаточен (под старой моделью памяти), чтобы гарантировать, что правильное значение configOptions и все переменные, доступные косвенно через configOptions (например элементы Map), будут доступны потоку B, потому что инициализация configOptions могла быть перераспределена с инициализацией volatile-переменной initialized.

Листинг 1. Использование volatile-переменной как переменной "блокировки"
Map configOptions;
char[] configText;
volatile boolean initialized = false;

// In Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// In Thread B
while (!initialized) 
  sleep();
// use configOptions

К сожалению эта ситуация является типичным случаем применения volatile - использования поля volatile в качестве "блокировки", чтобы показать, что набор общедоступных переменных был инициализирован. Экспертная группа JSR 133 решила, что было бы более разумно для чтения и записи volatile не быть перераспределенными никакими другими операциями с памятью, для поддержки этого и других подобных случаев использования. Под новой моделью памяти, когда поток A пишет в volatile-переменную V, а поток B осуществляет чтение из V, любые значения переменных, которые были видимы A, в то время как писалось V, теперь гарантированно видимы для B. Результатом этого стала более практически пригодная семантика volatile, за счет немного более высоких потерь в производительности при доступе к полям volatile.


Что происходит перед чем?

Действия, например чтение и запись переменных, распределяются по потоку в соответствии с тем, что называется "программным порядком", т. е. порядком, в котором они должны происходить в соответствии с семантикой программы. (Компилятор действительно может свободно и довольно бесцеремонно обращаться с программным порядком внутри потока, до тех пор пока сохраняется квазипоследовательная семантика). Действия в других потоках совсем необязательно распределяются относительно друг друга - если вы начнете два потока и каждый из них выполните без синхронизации на любых общих мониторах или затрагивая любые общие переменные volatile, вы практически ничего не сможете предсказать об относительном порядке, в котором действия в одном потоке будут выполняться (или становиться видимыми третьему потоку) относительно действий в другом потоке.

Дополнительные гарантии переупорядочивания создаются, когда поток запускается, один поток присоединяется к другому, поток запрашивает или запускает монитор (входит или выходит из блока synchronized), или поток получает доступ к переменной volatile. Модель памяти Java описывает гарантии переупорядочивания, которые даются, когда программа использует синхронизацию или переменные volatile, чтобы координировать действия в многопоточной среде. Новая модель памяти JMM, если сказать проще, определяет порядок, который называется происходит-прежде (happens-before), который является частичным распределением всех действий внутри программы следующим образом:

  • Каждое действие в потоке происходит-прежде каждого действия в этом потоке, которое идет позже в программном порядке
  • Разблокировка монитора происходит-прежде каждой последующей блокировки того же монитора
  • Запись в поле volatile происходит-прежде каждого последующего считывания того же самого volatile
  • Вызов Thread.start() на поток происходит-прежде любых других действий в запущенном потоке
  • Все действия в потоке происходят-прежде чем любой другой поток успешно возвращается из Thread.join() на этом потоке

Именно третье из этих правил, которое касается чтения и записи переменных volatile, является новым и решает проблему с примером в Листинге 1. Так как запись volatile-переменной initialized происходит после инициализации configOptions, использование configOptions происходит после чтения initialized, а чтение initialized происходит после записи initialized, вы можете заключить, что инициализация configOptions потоком A происходит перед использованием configOptions потоком B. Поэтому configOptions и доступные через нее переменные будут видимы потоку B.

Рисунок 1. Использование синхронизации для гарантирования видимости записи в память по всем потокам
Использование синхронизации для гарантирования видимости записи в память по всем потокам

Соперничество за данные

Говорят, что программа находится в состоянии соперничества за данные и следовательно она "несинхронизирована должным образом", кода существует переменная, которая считывается более чем одним потоком, записывается по крайней мере одним потоком, и запись и считывание не находятся в отношениях "происходит-прежде".

Решает ли это проблему блокировки с двойной проверкой?

Одним из предлагаемых способов решения проблемы блокировки с двойной проверкой является превращение поля, которое содержит лениво инициализируемый экземпляр поля volatile. (См. в Ресурсах описание проблемы блокировки с двойной проверкой и объяснение, почему предлагаемые алгоритмические способы решения не работают). Под старой моделью памяти блокировка с двойной проверкой не была потокобезопасной, потому что записи в поле volatile могли быть перераспределены с записями в другие не volatile поля (например поля вновь сконструированного объекта), и поэтому ссылки на экземпляры volatile могли все же содержать ссылку на неполностью сконструированный объект.

Под новой моделью памяти это "исправление" проблемы блокировки с двойной проверкой дается с помощью идиомы потокобезопасности. Но это совсем не значит, что вы должны использовать эту идиому! Вся суть блокировки с двойной проверкой была в том, что она должна была быть оптимизацией качества работы, направленной на устранение синхронизации стандартного кода, во многом потому, что синхронизация была относительно дорогой в самых ранних пакетах JDK. Не только несостязательная синхронизация стала намного дешевле с тех пор, но и новые изменения в семантике volatile сделали ее относительно дороже старой семантики на некоторых платформах. (Фактически, каждое считывание или запись поля volatile является как будто "полусинхронизацей" - считывание volatile имеет такую же семантику памяти как запрос монитора, а запись volatile имеет такую же семантику как запуск монитора). Поэтому если цель блокировки с двойной проверкой состоит в том, чтобы предложить улучшенную производительность через более простой синхронизированный подход, эта "исправленная" версия также не очень поможет.

Вместо блокировки с двойной проверкой используйте идиому Initialize-on-demand Holder Class, которая обеспечивает ленивую инициализацию, потокобезопасность, и работает быстрее и не так запутанно как блокировка с двойной проверкой:

Листинг 2. Идиома Initialize-On-Demand Holder Class
private static class LazySomethingHolder {
  public static Something something = new Something();
}

...

public static Something getInstance() {
  return LazySomethingHolder.something;
}

Эта идиома основывает свою поточную безопасность на том факте, что операции, которые являются частью инициализации класса, например статические инициализаторы, гарантированно будут видимы всем потокам, которые используют этот класс, а ленивая инициализация является следствием того факта, что внутренний класс не загружается, пока какой-нибудь поток не сошлется на одно из ее полей или методов.


Безопасность инициализации

Новая модель памяти JMM также старается предоставить новые гарантии безопасности инициализации. То есть, если объект соответствующим образом сконструирован (то есть ссылка на объект не опубликована, пока конструирование не завершено), тогда все потоки будут видеть значения его полей final, которые были установлены конструктором, независимо от того, используется ли синхронизация, чтобы передать ссылку из одного потока другому. Более того, переменные, которые могут быть доступны через поле final должным образом сконструированного объекта, например поля объекта, на который ссылается поле final, также гарантированно видимы другим потокам. Это значит, что если поле final содержит ссылку на, скажем, LinkedList, кроме видимости правильного значения ссылки другим потокам, содержимое этого LinkedList во время конструирования будет доступно другим потокам без синхронизации. Результат этого - значительное усиление значения final, то есть поля final могут быть безопасно доступны без синхронизации, и компиляторы могут предположить, что поля final не изменятся, и следовательно смогут оптимизировать множественные выборки.

Final значит final

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

Под новой моделью памяти есть что-то похожее на отношения по схеме происходит-прежде между записью поля final в конструкторе и начальной загрузкой общедоступной ссылки на этот объект в другом потоке. Когда конструирование завершается, все записи в полях final (и переменных, доступных косвенно через эти поля final) становятся "заблокированными", и любой поток, который содержит ссылку на этот объект, после блокировки будет гарантированно видеть все заблокированные поля для всех заблокированных значений. Записи, которые инициализируют поля final, не будут перераспределяться с операциями, следующими за блокировкой, связанной с конструктором.


Резюме

Экспертная группа JSR 133 значительно усилила семантику volatile, так чтобы метки volatile могли быть использованы как надежные индикаторы того, что состояние программы было изменено другим потоком. В результате придания volatile большей "тяжеловесности," производительность при использование volatile стала почти такой же, как производительность при синхронизации в некоторых случаях, однако производительность все еще довольно низкая на большинстве платформ. Экспертная группа JSR 133 также значительно усилила семантику final. Если ссылку объекта нельзя пропускать во время конструирования, то как только конструирование завершено и поток публикует ссылку на объект, поля final объекта гарантированно будут видимыми, правильными и постоянными для всех потоков без синхронизации.

Эти изменения значительно усилили полезные свойства неизменяемых объектов в параллельных программах; неизменяемые объекты наконец стали в своей основе потокобезопасными (как это всегда и предполагалось), даже если соперничество за данные используется для того, чтобы передавать ссылки на неизменяемые объекты между потоками.

Единственное предостережение относительно безопасности инициализации состоит в том, что ссылка не должна "избегать" своего конструктора, т. е. конструктор не должен публиковать, прямо или косвенно, ссылку на объект, который конструируется. Это включает в себя публикацию ссылок на нестатические внутренние классы, и в общем устраняет запуск потоков изнутри конструктора. Для получения более детальной информации о безопасном конструировании см. Ресурсы.

Ресурсы

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=199689
ArticleTitle=Теория и практика Java: Исправление модели памяти Java, часть 2
publish-date=03022007