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

Что такое модель памяти Java (JMM), и какие проблемы с ней возникали изначально?

Экспертная группа JSR 133, которая работает почти уже три года, недавно выпустила рекомендации о том, что делать с моделью памяти Java Memory Model (JMM). В оригинальной JMM были найдены некоторые серьезные недостатки, что привело к невероятному усложнению семантики для концептов, которые должны были быть простыми, например volatile, final и synchronized. В этом выпуске Теории и практики Java Брайан Гетц покажет, как семантика volatile и final будет усилена, для внесения исправлений в модель памяти JMM. Некоторые из этих изменений были уже интегрированы в пакет JDK 1.4; остальные ждут своей очереди, чтобы быть включёнными в JDK 1.5.

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

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



01.03.2007

Платформа Java интегрировала разделение на потоки и многопроцессорную обработку данных в язык в гораздо большей степени, чем предыдущие языки программирования. Поддержка этим языком платформонезависимой параллельности и многопоточности была амбициозным и новаторским решением, и, может быть, поэтому совсем не удивительно, что проблема оказалась немного сложнее, чем архитекторы Java думали изначально. В основе проблем с синхронизацией и безопасностью потоков лежат интуитивно непознаваемые тонкости модели памяти Java (JMM), изначально определённые в Главе 17 спецификации языка Java (Java Language Specification), и заново определённые экспертной группой JSR 133.

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

Что такое модель памяти, и зачем она мне нужна?

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

Не пропустите другие статьи из этой серии

Часть 2, "Как изменится модель памяти JMM после работы экспертной группы JSR 133" (март 2004 г.)

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

В отличие от Java, у языков типа C и C++ нет явно заданных моделей памяти. Программы на C вместо этого наследуют модель памяти процессора выполняющего программу (хотя компилятор для данной архитектуры возможно действительно знает что-нибудь о модели памяти базового процессора, и некоторая ответственность за совместимость падает на компилятор). Это значит, что параллельные программы на C могут выполняться правильно на процессорах с одной архитектурой, а на процессорах с другой архитектурой работать не будут. Хотя модель памяти JMM может поначалу сбивать с толку, от нее может быть существенная выгода: программа, которая правильно синхронизирована в соответствии с JMM, должна правильно работать на любой платформе с поддержкой Java.


Недостатки оригинальной модели памяти Java

В то время, как модель памяти JMM, заданная в главе 17 спецификации Java Language Specification, была амбициозной попыткой определить единообразную, межплатформенную модель памяти, у нее есть несколько незаметных, но существенных недостатков. Семантика synchronized и volatile была довольно такой запутанной, настолько, что многие хорошо подготовленные разработчики предпочитали иногда игнорировать правила, потому что написание соответствующим образом синхронизированного кода под старой моделью памяти было затруднительно.

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

Термин переупорядочивание используется для описания нескольких классов реальных и очевидных перераспределений операций с памятью:

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

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


Цели экспертной группы JSR 133

Экспертная группа JSR 133, собранная для исправления модели памяти Java (JMM), ставит перед собой следующие цели:

  • Сохранить существующие гарантии безопасности, в том числе безопасность типов.
  • Предоставить безопасность от случайных значений. Это значит, что значения переменной не создаются "непонятно откуда". То есть чтобы поток видел, что переменная имеет значение X, какой-то поток должен был действительно присвоить значение X этой переменной в прошлом.
  • Семантика "правильно синхронизированных" программ должна быть не только простой и интуитивно-понятной, но и практически осуществимой. Так, "правильная синхронизация" должная быть определена и формально, и наглядно (и эти два определения должны быть согласованы друг с другом!).
  • Программисты должны иметь возможность создавать многопоточные программы с уверенностью, что они будут надежными и выверенными. Конечно, не существует волшебной палочки, которая может сделать написание параллельных приложений лёгким, но цель состоит в том, чтобы освободить авторов приложений от необходимости разбираться во всех тонкостях модели памяти.
  • Должны быть в наличии высокопроизводительные реализации JVM, работающие на широком спектре популярных архитектур аппаратных средств. Современные процессоры существенно разнятся в своих моделях памяти; модель памяти JMM должна быть совместима с как можно большим количеством архитектур без потери в производительности.
  • Нужно обеспечить такой вид синхронизации, который позволял нам публиковать объект и делать его видимым без синхронизации. Этот новый вид гарантии безопасности называется безопасностью инициализации.
  • Существующий код должен быть подвержен минимальным изменениям.

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

По прошествии трех лет активной деятельности экспертной группы JSR 133 стало ясно, что эти проблемы гораздо коварнее, чем кто-либо полагал. Такова судьба всех первопроходцев! Окончательная формальная семантика гораздо более запутана, чем изначально ожидалось, и на самом деле абсолютно отлична от той, которую мы рисовали в своем воображении, но неформальная семантика ясна и интуитивно понятна. Она будет описана в Части 2 этой статьи.


Синхронизация и видимость

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


Проблема № 1: Отсутствие неизменяемых объектов

Одним из наиболее удивительных неудобств модели памяти JMM является тот факт, что неизменяемые объекты, чья неизменяемость должна была быть гарантирована при помощи ключевого слова final, могут вдруг изменить свое значение. (Напоминание службы сервиса: если вы сделаете все поля объекта final, это совсем не обязательно сделает объект неизменяемым, все поля должны также быть примитивного типа или являться ссылками на неизменяемые объекты). Неизменяемые объекты, например String, не должны требовать синхронизации. Однако из-за потенциальных задержек в распространении изменений при записи в память из одного потока в другой существует возможное "состояние гонки", которое позволило бы потоку сначала видеть одно значение неизменяемого объекта, а затем чуть позже видеть другое значение.

Как это может происходить? Рассмотрим реализацию String в пакете Sun 1.4 JDK, где имеется три основных важных поля final: ссылка на массив символов, длина (length) и смещение (offset) массива символов, которая описывает начало представленной строки. String была реализована таким способом вместо того, чтобы иметь только массив символов. Поэтому массив символов может быть в совместном пользовании множественных объектов String и StringBuffer без необходимости копировать текст в новый массив каждый раз, когда создается String. Например, String.substring() создает новую строку, которая делит тот же самый массив символов с оригинальным String и просто отличается по полям lenght и offset.

Представьте себе, что вы исполняете следующий код:

String s1 = "/usr/tmp";
String s2 = s1.substring(4);   // contains "/tmp"

Строка s2 будет иметь смещение 4 и длину 4, но будет пользоваться тем же самым массивом символов, содержащим "/usr/tmp", вместе с s1. Перед запуском конструктора String, конструктор для Object инициализирует все поля, в том числе final-поля length and offset, с их значениями по умолчанию. При запуске конструктора String в эти поля устанавливаются желаемые величины. Но под старой моделью памяти в отсутствии синхронизации возможно, что какой-нибудь поток будет временно видеть поле offset с значением по умолчанию 0, и только позже увидит правильное значение 4. В результате значение s2 меняется от "/usr" до "/tmp". Это не то, что имелось ввиду, и возможно будет работать не на всех JVM или платформах, но спецификация старой модели памяти это допускала.


Проблема № 2: Переупорядочивание volatile и не-volatile в памяти

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

Чтобы обеспечить хорошую производительность в отсутствии синхронизации, компилятору, среде выполнения и кэшу, как правило, позволяется переупорядочивать обычные операции памяти, до тех пор, пока исполняемый в данный момент поток не может распознать разницу. (Это называют within-thread as-if-serial semantics - внутрипоточная квазипоследовательная семантика). Чтение и запись volatile, однако, полностью распределены в определенном порядке по потокам; компилятор и кэш не могут перераспределить между собой чтение и запись. К сожалению модель памяти JMM не позволяла перераспределять чтение и запись volatile относительно чтения и записи обычных переменных. Это значит, что мы не можем использовать метки (flags) volatile как индикаторы того, какие операции завершены. Проанализируйте следующий код, в котором идея состоит в том, что поле 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-переменная initialized выполняет функции блокировки, чтобы показывать, что набор других операций был завершен. Это хорошая идея, но под старой моделью памяти Java она не работала, потому что эта модель позволяла операциям записи не-volatile (например запись в поле configOptions, а также запись в поля Map, на которые ссылаются configOptions) быть перераспределенными с операциями записи volatile. Поэтому другой поток может видеть initialized как истинные, но еще не иметь единообразного или текущего представления поля configOptions или объектов, на которые оно ссылается. Старая семантика volatile только обещала доступность записываемой или считываемой переменной для, и не было никаких обещаний относительно других переменных. Хотя этот подход легче эффективно реализовывать, он оказался менее полезным, чем изначальная задумка.


Резюме

Как определено в Главе 17 спецификации Java Language Specification, модель памяти JMM имеет несколько серьезных изъянов, которые позволяют некоторым интуитивно непонятным и нежелательным вещам происходить с программами, выглядящим вполне разумными. Если слишком трудно написать параллельные классы должным образом, то многие параллельные классы гарантированно не будут работать как ожидалось, и это и является недостатком платформы. К счастью, стало возможным создать модель памяти, которая была бы более согласованной с интуицией большинства разработчиков и одновременно не нарушала бы никакой код, который был соответствующим образом синхронизирован под старой моделью памяти. Работа экспертной группы JSR 133 была направлена именно на это. В следующем месяце мы рассмотрим детали новой модели памяти (большинство из которых были уже встроены в JDK 1.4).

Ресурсы

Комментарии

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=199276
ArticleTitle=Теория и практика Java: Исправление модели памяти Java, Часть 1
publish-date=03012007