что такое монитор как монитор реализован в java
BestProg
Синхронизация. Монитор. Общие понятия. Ключевое слово synchronized
Содержание
Поиск на других ресурсах:
1. Понятие синхронизации между потоками. Необходимость применения синхронизации. Монитор
Бывают случаи, когда два или более параллельно-выполняемых потока пытаются обратиться к общему ресурсу. Если ресурс может быть изменен в результате выполнения одного из потоков, то другие потоки должны дождаться пока изменения в потоке будут завершены. В противном случае, потоки получат ресурс, данные которого будут ошибочными.
Гарантировать одновременное использование общего ресурса только одним потоком может так называемая синхронизация. Значит, синхронизация — это процесс, который упорядочивает доступ из разных потоков к общему ресурсу.
Синхронизация базируется на использовании мониторов. Монитор — это объект, который используется для взаимоисключающей блокировки. Взаимоисключающая блокировка позволяет владеть монитором только одному объекту-потоку. Каждый объект-поток имеет собственный, неявно связанный с ним, монитор.
Поток выполнения (который представлен объектом) может завладеть монитором в случае, если он запросил блокировку и монитор свободен на данный момент. После того, как объект вошел в монитор, все остальные объекты-потоки, пытающиеся войти в монитор, приостанавливаются и ожидают до тех пор, пока первый объект не выйдет из монитора.
Монитором может обладать только один поток. Если поток (объект) обладает монитором, то он при необходимости может повторно войти в него.
В языке Java синхронизация применяется к целым методам или фрагментам кода. Исходя из этого существует два способа синхронизации программного кода:
Модификатор доступа synchronized применяется при объявлении синхронизированного метода и имеет следующую общую форму:
3. Оператор synchronized() < >. Общая форма
Общая форма оператора synchronized () следующая:
4. Пример, демонстрирующий синхронизированный доступ к общему методу из трех разных потоков. Применение модификатора доступа synchronized
В примере демонстрируется необходимость применения модификатора доступа synchronized с целью упорядочения доступа к ресурсу из разных потоков.
Результат выполнения программы
Если в вышеприведенном примере перед методом Get() класса Array5 убрать ключевое слово synchronized
то последовательного выполнения потоков не будет. В этом случае программа после каждого запуска будет выдавать разный (хаотический) результат, например следующий
5. Пример использования оператора synchronized() <> для синхронизированного доступа к общему ресурсу
После внесенных изменений, сокращенный код программы будет следующий:
А как же всё-таки работает многопоточность? Часть I: синхронизация
(пост из серии «я склонировал себе исходники hotspot, давайте посмотрим на них вместе»)
Все, кто сталкивается с многопоточными проблемами (будь то производительность или непонятные гейзенбаги), неизбежно сталкиваются в процессе их решения с терминами вроде «inflation», «contention», «membar», «biased locking», «thread parking» и тому подобным. А вот все ли действительно знают, что за этими терминами скрывается? К сожалению, как показывает практика, не все.
В надежде исправить ситуацию, я решил написать цикл статей на эту тему. Каждая из них будет построена по принципу «сначала кратко опишем, что должно происходить в теории, а потом отправимся в исходники и посмотрим, как это происходит там». Таким образом, первая часть во многом применима не только к Java, а потому и разработчики под другие платформы могут найти для себя что-то полезное.
Перед прочтением глубокого описания полезно убедиться в том, что вы в достаточной мере разбираетесь в Java Memory Model. Изучить её можно, например, по слайдам Сергея Walrus Куксенко или по моему раннему топику. Также отличным материалом является вот эта презентация, начиная со слайда #38.
Теоретический минимум
Прежде, чем продолжить, определим важное понятие:
contention — ситуация, когда несколько сущностей одновременно пытаются владеть одним и тем же ресурсом, который предназначен для монопольного использования
От того, есть ли contention на владение монитором, очень сильно зависит то, как производится его захват. Монитор может находиться в следующих состояниях:
На этом абстрактные рассуждения заканчиваются, и мы погружаемся в то, как оно реализовано в hotspot.
Заголовки объектов
Содержимое mark words
Вы заметили, что в случае biased не хватило места одновременно и для identity hash code и для threadID + epoch? А это так, и отсюда есть интересное следствие: в hotspot вызов System.identityHashCode приведёт к revoke bias объекта.
Далее, когда монитор занят, в mark word хранится указатель на то место, где хранится настоящий mark word. В стеке каждого потока есть несколько «секций», в которых хранятся разные вещи. Нас интересует та, где хранятся lock record’ы. Туда мы и копируем mark word объекта при легковесной блокировке. Потому, кстати, thin-locked объекты называют stack locked. Раздутый монитор может храниться как у потока, который его раздул, так и в глобальном пуле толстых мониторов.
Пора перейти к коду.
Простенький пример использования synchronized
Начнём с такого класса:
и посмотрим, во что он скомпилируется:
В общем случае код VM’ного хелпера для какого-нибудь действия может по содержанию отличаться от вклеенного JIT’ом. Вплоть до того, что некоторые оптимизации со стороны JIT’а могут просто не портированы в интерпретатор
Также Лёша порекомендовал взять в зубы PrintAssembly и смотреть сразу на скомпилированный и за-JIT-нутый код, но я решил начать с пути меньшего сопротивления, а потом уже посмотреть, как же оно на самом деле тм
monitorenter
Если CAS не удался, то мы проверяем, не являемся ли мы уже владельцами монитора (рекурсивный захват); и если да, то успех снова за нами, единственное, что мы делаем — это записываем в displaced header у себя на стеке NULL (дальше узнаем, зачем это нужно). В противном случае мы делаем следующий вызов:
fast_enter
safepoint — состояние виртуальной машины, в котором исполнение потоков остановлено в безопасных местах. Это позволяет проводить интрузивные операции, вроде revoke bias у монитора, которым поток в данный момент владеет, деоптимизации или взятия thread dump.
Как вы можете догадаться, bulk-операции — хитрые оптимизации, которые упрощают передачу большого числа объектов между потоками. Если бы не было этой оптимизации, то было бы опасно включать UseBiasedLocking по умолчанию, поскольку тогда большой класс приложений вечно бы занимался revocation’ами и rebiasing’ами.
Если быстрым путём захватить поток не удалось (т.е, был сделан revoke bias), мы переходим к захвату thin-лока.
slow_enter
После раздувания монитора необходимо в него зайти. Метод ObjectMonitor::enter делает именно это, применяет все мыслимые и немыслимые хитрости, чтобы избежать парковки потока. В число этих хитростей входят, как вы уже могли догадаться, попытки захватить с помощью spin loop’а, с помощью однократных CAS-ов и прочих «халявных методов». Кстати, кажется, я нашёл небольшое несоответствие комментариев с происходящим. вот мы один раз пытаемся войти в монитор spin loop’ом, утверждая, что это делаем лишь однажды:
А вот чуть дальше, в вызываемом методе enterI делаем это снова, опять говоря про лишь один раз:
Мда, парковка на уровне операционной системы — это настолько страшно, что мы готовы почти на всё, чтобы её избежать. Давайте разберёмся, что же в ней такого ужасного.
Парковка потоков задним про ходом
Должен заметить, что мы сейчас подошли к коду, который писали очень давно, и это заметно. Есть много дубликации, переинжениринга и прочих приятностей. Впрочем, наличие комментариев типа «убрать этот костыль» и «объединить эти с тем» слегка успокаивают.
Итак, что же такое парковка потоков? Все наверняка слышали, что у каждого монитора есть так называемый Entry List (не путать с Waitset) Так вот: он действительно есть, хотя он и является на самом деле очередью. После всех провалившихся попыток дёшево войти в монитор, мы добавляем себя именно в эту очередь, после чего паркуемся:
Прежде чем перейти непосредственно к парковке, обратим внимание на то, что тут она может быть timed или не timed, в зависимости от того, является ли текущий поток ответственным. Ответственных потоков всегда не более одного, и они нужны для того, чтобы избежать так называемого stranding’a: печальки, когда монитор освободился, но все потоки в wait set по-прежнему запаркованы и ждут чуда. Когда есть ответственный, он автоматически просыпается время от времени (чем больше раз произошёл futile wakeup — пробуждение, после которого захватить лок не удалось — тем больше время парковки. Обратите внимание, что оно не превышает 1000 мсек) и пытается войти в монитор. Остальные потоки могут ждать пробуждения хоть целую вечность.
Thread scheduling на уровне ОС
Что ж, самое время забраться в ядро linux и посмотреть, как там работает шедулер. Исходники linux, как известно, лежат в git, и склонировать шедулер можно так:
Конечно, не стоит думать, что поток будет исполняться ровно столько времени, сколько ему отведено. Он может сам решить, что сделал всё, что хотел (например, заблокироваться на каком-нибудь I/O-вызове), либо его может насильно выдернуть раньше времени шедулер, отдав остаток его кванта кому-нибудь другому. А может и наоборот решить продлить квант по какой-нибудь причине. Кроме того, у потоков есть приоритет, который, в общем-то, понятно, что делает.
Теперь вы, должно быть, поняли, что парковка — дорого. Мало того, что это системный вызов, так ещё и оказывается, что шедулер может распарковать поток заметно позже, чем вам бы хотелось, поскольку в системе может быть ещё куча потоков, которые шедулер решит исполнять вместо вашего.
Но и это, кстати, ещё не всё: когда процессору на исполнение отдаётся другой поток, происходит смена контекста — тоже довольно дорогая операция, которая может занимать до десятка микросекунд. Более того: каким бы невероятным это не могло казаться, разные потоки как правило интересуют разные данные, потому в кеше может оказаться что-то, что этому потоку совершенно не нужно.
Если смена контекста происходит часто, а потоки работают небольшой промежуток времени, может оказаться, что процессор загружен техническими операциями. Такое может быть, если высок contention, но все воюющие хотят владеть монитором лишь непродолжительный промежуток времени.
monitorexit
В случае с biased locking мы, в общем-то, ничего и не делаем. Мы обнаруживаем, что в displaced header хранится NULL, и просто выходим. Отсюда интересный момент: при попытке отпустить не занятый в данный момент biased lock интерпретатор не выкенет IllegalMonitorStateException (но за такими вещами следит верификатор байт-кода).
В третьем случае мы отпускаем лок и выставляем мембары, после чего смотрим, нет ли сейчас какого-нибудь распаркованного потока, который готов прямо сейчас забрать лок. Такое возможно, если он проснулся и пытается захватить монитор с помощью, например, TrySpin (см. выше). Если такой обнаруживается, то наша работа на этом завершена. Также она завершена, если очередь потоков, которые хотят получить лок, пуста.
Собственно, если очень сильно не вдаваться в детали, то это всё, что можно сказать об освобождении монитора.
Если бы мы написали наш изначальный java-код вот так вот:
то байт-код у такого метода заметно короче:
Также, после окончания выполнения тела метода идёт выход из монитора, если метод synchronized.
Wait и notify
Первый добавляет себя в wait set (на самом деле очередь) и паркуется до тех пор, пока ему не пора будет просыпаться (прошло время, которое просили подождать; произошло прерывание или кто-то вызвал notify).
Notify же вытаскивает из wait set один поток и добавляет его, в зависимости от политик, в какое-то место в очереди тех, кто хочет захватить монитор. NotifyAll отличается лишь тем, что вытаскивает из wait set всех.
Memory effects
Прямо перед выходом из wait выставляется явный fence, чтобы прогарантировать HB.
Замечание от Майора О. aka Disclaimer
Stay tuned
В следующих сериях, в первую очередь, необходимо рассказать о memory barriers, которые крайне важны для обеспечения happens-before в JMM. Их очень удобно рассматривать на примере volatile полей, что я в дальнейшем и сделаю. Также стоит обратить внимание на final-поля и безопасную публикацию, но их уже осветили TheShade и cheremin в своих статьях, потому их и можно почитать интересующимся почитать (только осторожно). И, наконец, можно ждать наполненный PrintAssembly рассказ о том, как оно всё отличается, когда в дело вступает JIT.
And one more thing ©
Желающим повторить путешествие: я пользовался ревизией 144f8a1a43cb из jdk7u. Если ваша ревизия отличается, то могут отличаться и номера строк — К.О.
Biased locking включается не сразу после запуска виртуальной машины, а спустя BiasedLockingStartupDelay миллисекунд (4000 по умолчанию). Это сделано, поскольку иначе в процессе запуска и инициализации виртуальной машины, загрузки классов и всего прочего появилось бы огромное число safepoints, вызванных постоянным revoke bias у живых объектов.
Большое спасибо доблестным TheShade, artyushov, cheremin и AlexeyTokar за то, что они (вы|про)читали статью перед публикацией, убедившись тем самым, что я не принесу в массы вместо света какую-то бредовню, наполненную тупыми шутками и очепатками.