Содержание

пятница, 3 июня 2011 г.

Java Memory Model вкратце

Мне часто приходится объяснять почему тот или иной код иногда может не работать. Чтобы доходчиво объяснить почему это происходит, и что надо сделать, чтобы он заработал, желательно понимать основные принципы модели памяти Java. Когда заходит речь о Java Memory Model (JMM), я считаю бесчеловечным отправлять человека читать спецификацию, так как там черт ногу сломит. На самом деле JMM очень хорошо описана в книге Brian Goetz "Java Concurrency in Practice", но, во-первых, её надо иметь, а, во-вторых, там тоже нужно прочитать не одну страничку. Поэтому я и решил описать вкратце JMM, чтобы всегда можно было сюда сослаться.


Две основные вещи, которые вводят энтропию в многопоточный код - это reordering и visibility.

Видимость (visibility)

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

Reordering

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

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

Еще одна причина reordering, может заключаться в том, что процессор может решить поменять порядок выполнения операций, если, например, сочтет что такая последовательность выполнится быстрее.

На практике это может иметь очень печальные последствия. После всего вышесказанного, думаю, проблема в коде ниже не нуждается в пояснениях:
Тут еще важно отметить, что для выполнения операций в рамках одного потока, спецификация JVM разрешает делать только такой reordering, который приводит к абсолютно тем же результатам, если бы все операции выполнялись в порядке указанном в исходном коде с точки зрения потока, в котором этот код выполняется. Т.е. в одном потоке reordering никогда не проявляется.

Happend-before

В Java Memory Model введена такая абстракция как happens-before. Она обозначает, что если операция X связана отношением happens-before с операцией Y, то весь код следуемый за операцией Y, выполняемый в одном потоке, видит все изменения, сделанные другим потоком, до операции X.

Список операций связанных отношением happens-before:

  • В рамках одного поток любая операция happens-before любой операцией следующей за ней в исходном коде
  • Освобождение лока (unlock) happens-before захват того же лока (lock)
  • Выход из synhronized блока/метода happens-before вход в synhronized блок/метод на том же мониторе
  • Запись volatile поля happens-before чтение того же самого volatile поля
  • Завершение метода run экземпляра класса Thread happens-before выход из метода join() или возвращение false методом isAlive() экземпляром того же треда
  • Вызов метода start() экземпляра класса Thread happens-before начало метода run() экземпляра того же треда
  • Завершение конструктора happens-before начало метода finalize() этого класса
  • Вызов метода interrupt() на потоке happens-before когда поток обнаружил, что данный метод был вызван либо путем выбрасывания исключения InterruptedException, либо с помощью методов isInterrupted() или interrupted()

Связь happens-before транзитивна, т.е. если X happens-before Y, а Y happens-before Z, то X happens-before Z.

Очень важный момент: как освобождение/захват монитора, так и записать/чтение в volatile переменную связаны отношением happens-before, только если операции проводятся над одним и тем же экземпляром объекта.

Так же важно понимать, что в отношении happens-before участвуют только два потока, о видимости и reordering остальных потоков ничего сказать нельзя, пока в каждом из них не наступит отношение happens-before с к другим потоком.

Еще в отношении happens-before есть очень большой дополнительный бонус: данное отношение дает не только видимость volatile полей или результатов операций защищенных монитором или локом, но и видимость вообще всего-всего, что делалось до события hapens-before.


Так на рисунке выше ThreadB гарантированно увидит изменение в поле y, сделанное ThreadA, хотя он не является volatile и запись в него происходит вне synchronized блока.

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

Отношение happens-before так же накладывает сильные ограничения на reordering. С точки зрения потока Y все операцие произошедшие до точки happens-before в потоке X он может рассматривать как операции свершившиеся в своем собственном потоке. Т.е. никакого логического reordering по сравнению с прямым порядком в исходном коде с точки зрения потока Y быть не может.

Если взглянуть внимательнее на границу happens-before с точки зрения reordering для потока Y, то никакие операции располагающиеся выше границы happens-before в потоке X, не могут выполнится ниже границы happens-before в результате reordering, однако, операциям, находящимся ниже границы, разрешено выполнение до неё. Более наглядно это изображено на рисунке.


Публикация объектов


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

Однако, есть еще один способ добиться безопасной публикации объектов: если ссылка на объект, все поля которого являются final, становится видимой любому потоку, то данный поток видит все final поля, инициализированные во время создания объекта. Более того он будет видеть все значения достижимые из final полей. Рассмотрим пример ниже:
Если кроме как ссылок final на ваши объекты никто не ссылается, то не зависимо от уровня вложенности, поток, который видит ссылку на опубликованный объект, увидит все значения достижимые через final поля, которые были выставлены в конструкторе. Так в примере выше, любой поток, успешно зашедший в метод number (а это значит, что он увидел ссылку на опубликованный объект), то он всегда вернет значение 2. Конечно при условии, что после конструктора содержимое всех объектов больше не модифицируется.

Данное замечательное свойство делает антипаттерн double-check locking работоспособным, если singleton в конструкторе инициализирует только final поля.

Так же данное свойство решает проблему предыдущей модели памяти, где повсеместо используемые все из себя immutable строки, строго говоря не всегда работали.

Еще очень важный момент, который стоит упомянуть, это то, что вышеописанное верно только для объектов, во время конструирования которых, ссылка на объект не покидет конструктор, прежде чем он завершен. Обычно это происходит, когда вы передаете ссылку на экземпляр вашего класса в какой-нибудь listener прямо в конструкторе объекта. Или ваш объект является экземпляром Thread и вы вызываете метод start в конструкторе.

Как же данное свойство может быть реализовано JVM? Я думаю, что JVM гарантирует, что в момент записи ссылки на объект в основную память, все значения, достижимые из final полей выставленных во время конструирования объектов, уже синхронизированы с основной памятью. Таким образом, любой поток, читая ссылку на объект обязательно прочитает и все значения выставленные описанным способом во время конструирования объекта.

Reflection

На сколько я слышал от одного из сотрудников Oracle на стенде JavaSE на прошедшей javaone в Москве, изменение final поля через reflection и последующем его последующее чтение происходит через барьер памяти, поэтому и в этом случае можно не беспокоится о безопасном доступе к final полям из других потоках. На самом деле для меня это звучит немного странно и непонятно. Руслан, который слышал об этом эффекте вместе со мной, похоже тоже об этом долго у думал и в результате родил следующий пост.

Статическая инициализация

Статическая инициализация обладает очень полезным свойством для многопоточных программ: все значения, выставленные при объявлении статических полей или в статическом инициализаторе, видны любому потоку, получившему доступ к этому классу, без какой либо необходимости в синхронизации. JVM гарантирует, что класс будет загружен только одним потоком, после чего все статические поля и становятся доступны для всех остальных потоков. Именно за счет этих свойств и работает знаменитый паттерн получения singleton через внутренний статический класс.

Атомарность записи-чтения полей

JMM гарантирует атомарность записи-чтения всех не long/double полей. А volatile - абсолютно всех полей. Поля, представляющие ссылки на объекты, тоже всегда пишутся-читаются атомарно. Понятно дело, вместе с этим спецификация не запрещает иметь атомарность записи чтения long\double полей для 64-битной виртуальной машины. Данная атомарность гарантирует, что любой поток в любой момент времени зачитает из поля либо значение по умолчанию, либо полное значение, записанное туда в некий момент времени, и никогда не найдет там какого-то мусора.

Ссылики