Содержание

пятница, 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-битной виртуальной машины. Данная атомарность гарантирует, что любой поток в любой момент времени зачитает из поля либо значение по умолчанию, либо полное значение, записанное туда в некий момент времени, и никогда не найдет там какого-то мусора.

Ссылики

14 комментариев:

  1. кто курил jls chapter 17 тот эти цифры не забудет)

    ОтветитьУдалить
  2. Добавил абзац "Reflection" сразу после "Публикация объектов".

    ОтветитьУдалить
  3. А ты, Артем, нас не обманываешь с final? Там разве вложенность не гарантируется только до тех пор, пока поля являются final? Типа та же мапа - ее клиент увидит, а вот наполнение ее содержимого может зареордериться и все, не будет там двух.

    ОтветитьУдалить
  4. Неа, не обманываю. Об этом и на конференции говорили и книжка "Java Concurrency In Practice" очень похожий пример приводит: (Listing 3.11. Immutable Class Built Out of Mutable Underlying Objects) и говорит, что "Immutable objects are always thread safe"

    ОтветитьУдалить
  5. Я вот только одно не понял. Есть поток1. Он модифицирует там какие-нибудь объекты. Потом поток1 создаёт поток2, который тоже ссылается на те же объекты. Вопрос: поток2 увидит их в консистентном состоянии после старта или нет? Никаких volatile или final не используем. После старта потока2, поток1 больше ничего не модифицирует. Спасибо.

    ОтветитьУдалить
  6. Так как поток2 создается именно потоком1, то он все увидит в консистентном состоянии, так как здесь присутствует одно из условий happens-before "Вызов метода start() экземпляра класса Thread happens-before начало метода run() экземпляра того же треда". Откуда сомнения?

    ОтветитьУдалить
  7. Ок, спасибо! А вот ещё один вопрос: если у меня есть указатель на immutable объект и этот указатель постоянно меняют(и читают) разные потоки, то должен ли этот указатель иметь модификатор volatile или в силу того, что он указывает на immutable объект - не должен?

    ОтветитьУдалить
  8. Должен, так как immutable фича работает только вкупе с final указателем.

    ОтветитьУдалить
  9. так зачем тогда вообще придерживаться правила immutable, если volatile нам и так гарантирует, что объект будет полностью инициализирован прежде чем другой поток получит на него ссылку? Если Вам не сложно оставьте свою аську / скайп. Как приятно пообщаться со знающим человеком. Моё мыло: man4j#ya.ru

    ОтветитьУдалить
  10. >если у меня есть указатель на immutable объект и этот указатель постоянно меняют(и читают) разные потоки, то должен ли этот указатель иметь модификатор volatile или в силу того, что он указывает на immutable объект - не должен?

    Это зависит от того, что именно вы хотите от этого указателя. Пусть MyMutableObject содержит не-final поля. Тогда такой код некорректен:

    Thread (1..N):

    sharedObjectRef = new MyMutableObject(....);

    Thread(N+1):
    MyMutableObject localRef = sharedObjectRef;
    if(localRef!=null){
    //...read localRef fields...
    }

    почему? потому что присвоение ссылки на созданный объект (sharedObjectRef = new MyMutableObject(....)) может стать видимо _до_ того как станут видимы присвоения значений все поля этого объекта внутри конструктора MyMutableObject. Поэтому чтение полей sharedObjectRef в потоке Thread(N+1) может давать совершенно различные варианты -- могут быть прочитаны значения по-умолчанию (0/false/null) может быть прочитана "смесь" -- часть полей из одного объекта, часть -- из другого, часть -- по-умолчанию. Другими словами -- бардак полный.

    А вот если объект MyImmutableObject содержит только final-поля, и корректно инициализуется (this не убегает из конструктора), то такой код будет работать корректно. Корректно в данном случае означает, что каждое чтение sharedObjectRef в Thread(N+1) будет давать либо значение по умолчанию (null), либо присвоенное в одном из потоков Thread(1..N), причем в последнем случае все поля объекта _гарантированно будут инициализированы_ -- конструктор гарантированно завершится _до_ того, как ссылка на созданный объект будет видима через sharedObjectRef. Причем никаких особых модификаторов типа volatile на sharedObjectRef здесь не нужно.

    Первый вариант -- с MyMutableObject -- можно "починить", если сделать sharedObjectRef volatile -- тогда гарантии будут те же, что и во втором случае (и даже жесче). Но за это "жесче" придется платить -- такая штука будет медленнее.

    ОтветитьУдалить
  11. Я тут немного наврал. "часть из одного объекта, часть из другого" прочитана быть не может точно. Может быть прочитана часть полей инициализированной, часть неинициализированной, содержащей значения по-умолчанию. Те поля объекта, которые сами ссылки -- могут тоже указывать на частично инициализированный объект. То есть все равно бардак, но не такой сильный, как я описал :)

    ОтветитьУдалить
  12. Неее... Но это фигня получается, когда код будет работать "корректно", но другие потоки будут не сразу видеть объект на который ссылается sharedObjectRef.

    ОтветитьУдалить
  13. Это не фигня получается, а надо просто понимать, чего хочешь от кода. В некоторых условиях это может оказаться вполне приемлемым. Тем более, что это "не сразу" реально на Интелловских процах может практически не отличаться от "сразу" -- но при этом быть заметно дешевле.

    ОтветитьУдалить