Пользователь
0,0
рейтинг
28 мая 2012 в 07:57

Разработка → А как же всё-таки работает многопоточность? Часть I: синхронизация tutorial

картинка для привлечения внимания(пост из серии «я склонировал себе исходники hotspot, давайте посмотрим на них вместе»)
Все, кто сталкивается с многопоточными проблемами (будь то производительность или непонятные гейзенбаги), неизбежно сталкиваются в процессе их решения с терминами вроде «inflation», «contention», «membar», «biased locking», «thread parking» и тому подобным. А вот все ли действительно знают, что за этими терминами скрывается? К сожалению, как показывает практика, не все.

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

Перед прочтением глубокого описания полезно убедиться в том, что вы в достаточной мере разбираетесь в Java Memory Model. Изучить её можно, например, по слайдам Сергея Walrus Куксенко или по моему раннему топику. Также отличным материалом является вот эта презентация, начиная со слайда #38.

Теоретический минимум

Как известно, каждый объект в java имеет свой монитор, и потому, в отличие от того же C++, нет необходимости guard-ить доступ к объектам отдельными mutex-ами. Для достижения эффектов взаимного исключения и синхронизации потоков используют следующие операции:
  • monitorenter: захват монитора. В один момент времени монитором может владеть лишь один поток. Если на момент попытки захвата монитор занят, поток, пытающийся его захватить, будет ждать до тех пор, пока он не освободится. При этом, потоков в очереди может быть несколько.
  • monitorexit: освобождение монитора
  • wait: перемещение текущего потока в так называемый wait set монитора и ожидание того, как произойдёт notify. Выход из метода wait может оказаться и ложным. После того, как поток, владеющий монитором, сделал wait, монитором может завладеть любой другой поток.
  • notify(all): пробуждается один (или все) потоки, которые сейчас находятся в wait set монитора. Чтобы получить управление, пробуждённый поток должен успешно захватить монитор (monitorenter)


Прежде, чем продолжить, определим важное понятие:

contention — ситуация, когда несколько сущностей одновременно пытаются владеть одним и тем же ресурсом, который предназначен для монопольного использования


От того, есть ли contention на владение монитором, очень сильно зависит то, как производится его захват. Монитор может находиться в следующих состояниях:

  • init: монитор только что создан, и пока никем не был захвачен
  • biased: (умная оптимизация, появившаяся далеко не сразу) Монитор «зарезервирован» под первый поток, который его захватил. В дальнейшем для захвата этому потоку не нужны дорогие операции, и захват происходит очень быстро. Когда захват пытается произвести другой поток, либо монитор пере-резервируется для него (rebias), либо монитор переходит в состояние thin (revoke bias). Также есть дополнительные оптимизации, которые действуют сразу на все экземпляры класса объекта, монитор которого пытаются захватить (bulk revoke/rebias)
  • thin: монитор пытаются захватить несколько потоков, но contention нет (т.е. они захватывают его не одновременно, либо с очень маленьким нахлёстом). В таком случае захват выполняется с помощью сравнительно дешёвых CAS. Если возникает contention, то монитор переходит в состояние inflated
  • fat/inflated: синхронизация производится на уровне операционной системы. Поток паркуется и спит до тех пор, пока не настанет его очередь захватить монитор. Даже если забыть про стоимость смены контекста, то когда поток получит управление, зависит ещё и от системного шедулера, и потому времени может пройти существенно больше, чем хотелось бы. При исчезновении contention монитор может вернуться в состояние thin


На этом абстрактные рассуждения заканчиваются, и мы погружаемся в то, как оно реализовано в hotspot.

Заголовки объектов

Внутри виртуальной машины заголовки объектов в общем случае содержат два поля: mark word и указатель на класс объекта. В частных случаях туда может что-то добавляться: например, длина массива. Хранятся эти заголовки в так называемых oop-ах (Ordinary Object Pointer), и посмотреть на их структуру можно в файле hotspot/src/share/vm/oops/oop.hpp. Мы же более детально изучим то, что из себя представляет mark word, который описывают в файле markOop.hpp, находящемся в той же папке. (Не обращайте внимание на наследование от oopDesc: оно есть лишь по историческим причинам) По-хорошему, его стоит вдумчиво почитать, уделив внимание подробным комментариям, но для тех, кому это не очень интересно, ниже краткое описание того, что же в этом mark word содержится и в каких случаях. Можно ещё посмотреть вот эту презентацию начиная с 90-го слайда.

Содержимое mark words

Состояние Тег Содержимое
unlocked, thin, unbiased 01
Identity hashcode
age
0
locked, thin, unbiased 00
указатель на место нахождения mark word
inflated 10
указатель на раздутый монитор
biased 01
id потока-владельца
epoch
age
1
marked for GC 11  

Тут мы видим несколько новых значений. Во-первых, identity hash code — тот хеш-код объекта, который возвращается при вызове System.identityHashCode. Во-вторых, age — сколько сборок мусора пережил объект. И ещё есть epoch, которая указывает число bulk revocation или bulk rebias для класса этого объекта. К тому, зачем это нужно, мы подойдём позже.
Вы заметили, что в случае biased не хватило места одновременно и для identity hash code и для threadID + epoch? А это так, и отсюда есть интересное следствие: в hotspot вызов System.identityHashCode приведёт к revoke bias объекта.
Далее, когда монитор занят, в mark word хранится указатель на то место, где хранится настоящий mark word. В стеке каждого потока есть несколько «секций», в которых хранятся разные вещи. Нас интересует та, где хранятся lock record'ы. Туда мы и копируем mark word объекта при легковесной блокировке. Потому, кстати, thin-locked объекты называют stack locked. Раздутый монитор может храниться как у потока, который его раздул, так и в глобальном пуле толстых мониторов.

Пора перейти к коду.

Простенький пример использования synchronized

Начнём с такого класса:
1
2
3
4
5
6
7
public class SynchronizedSample {
    void doSomething() {
        synchronized(this) {
            // Do something
        }
    }
}

и посмотрим, во что он скомпилируется:

javac SynchronizedSample.java && javap -c SynchronizedSample

Я не стану приводить полный листинг, а обойдусь лишь телом метода doSomething, снабдив его комментариями.

void doSomething();
  Code:
   0:	aload_0 // Толкаем в стек this
   1:	dup // Дублируем верхушку стека (this)
   2:	astore_1 // Записываем ссылку с верхушки стека (this) в переменную 1
   3:	monitorenter // Овладеваем монитором объекта, лежащего на вершине стека (this)
   4:	aload_1 // Снова толкаем this в стек
   5:	monitorexit // Отпускаем монитор
   6:	goto	14 // Пропускаем "catch-блок"
   9:	astore_2 // (Сюда мы попадаем, если случилось исключение) Записываем исключение в переменную 2
   10:	aload_1 // Толкаем this на верхушку стека
   11:	monitorexit // Отпускаем монитор
   12:	aload_2 // Толкаем исключение на вершину стека
   13:	athrow // Швыряем исключение
   14:	return // Конец
  Exception table:
   from   to  target type
     4     6     9   any // Если происходит исключение при отпускании монитора, идём в "catch-блок"
     9    12     9   any // Отпустить монитор важно, пробуем пока не получится


Здесь нас интересуют инструкции monitorenter и monitorexit. Можно, конечно, поискать, что они делают, в Яндекпоисковой системе на ваше усмотрение, но это чревато дезинформацией, да и не по-пацански как-то. К тому же, у меня как раз под рукой есть исходники OpenJDK, с которыми вообще можно весело поразвлекаться. В этих исходниках довольно просто узреть, что происходит с байт-кодом в режиме интерпретации. Есть лишь один нюанс: Лёша TheShade Шипилёв сказал, что
В общем случае код VM'ного хелпера для какого-нибудь действия может по содержанию отличаться от вклеенного JIT'ом. Вплоть до того, что некоторые оптимизации со стороны JIT'а могут просто не портированы в интерпретатор

Также Лёша порекомендовал взять в зубы PrintAssembly и смотреть сразу на скомпилированный и за-JIT-нутый код, но я решил начать с пути меньшего сопротивления, а потом уже посмотреть, как же оно на самом делетм

monitorenter


Исходники интерпретатора лежат в папочке hotspot/src/share/vm/interpreter, и их там много. Перечитывать всё на данном этапе не очень целесообразно, потому с помощью grep найдём места, в которых, вероятно, происходит то, что нам нужно. В первую очередь стоит глянуть на происходящие в bytecodes.hpp и bytecodes.cpp объявления:
./bytecodes.hpp:235:  _monitorenter = 194, // 0xc2
./bytecodes.cpp:489:  def(_monitorenter, "monitorenter", "b", NULL, T_VOID, -1, true);

Как легко можно догадаться, в .hpp определяется человеческая enum-константа для байт-кода 0xc2, а в .cpp эта операция регистрируется с помощью метода def. Рассказывать о нём отдельно в рамках этой статьи смысла особого нет: достаточно будет пояснить, что регистрируется команда monitorenter, представляющая собой один байт-код без параметров (b), ничего не возвращающая, вытаскивающая из стека одно значение и способная спровоцировать блокировку или вызов safepoint (о последних позднее).

Следующим представляет интерес файл bytecodeInterpreter.cpp. В нём есть замечательный метод BytecodeInterpreter::run(interpreterState istate), который занимает всего-навсего около 2200 строк, и в общем и целом крутится в цикле, пока тело обрабатываемого метода не закончится. (На самом деле, ещё большой кусок занимается другими полезными делами типа инициализации метода, блокировки, если метод synchronized, и тому подобного). Наконец, начиная со строки 1667 описывается то, что происходит при встрече операции monitorenter. В первую очередь, в стеке потока находится свободный монитор (если таких нет, то он запрашивается у интерпретатора с помощью istate->set_msg(more_monitors)), и туда помещается unlocked-копия mark word. После этого с помощью CAS мы пытаемся записать в mark word объекта указатель на эту копию, которую называют displaced header.
CAS — Compare-and-swap — атомарно сравнивает *dest и compare_value, и если они равны, *dest и exchange_value местами. Возвращается изначальное значение *dest. (При этом гарантируется двусторонний membar, но о них в следующей статье)

Если CAS удался, то победа (а вместе с ней и монитор) наша, и на этом можно закончить (тег содержится в самом указателе на displaced header — оптимизация). Если нет, то идём дальше, но сначала обратим внимание на важный момент: мы никак не проверили, а не biased ли этот монитор. Вспомнив Лёшины предостережения, поймём, что натолкнулись на оптимизацию, не дошедшую до интерпретатора. К слову сказать, при обработке synchronized-методов всё проверяется нормально, но это будет несколько позже.

Если CAS не удался, то мы проверяем, не являемся ли мы уже владельцами монитора (рекурсивный захват); и если да, то успех снова за нами, единственное, что мы делаем — это записываем в displaced header у себя на стеке NULL (дальше узнаем, зачем это нужно). В противном случае мы делаем следующий вызов:

CALL_VM(InterpreterRuntime::monitorenter(THREAD, entry), handle_exception);


Макрос CALL_VM производит всякие технические операции типа создания фреймов и выполняет переданную функцию. В нашем случае эта функция — InterpreterRuntime::monitorenter, которая находится в новом файлике interpreterRuntime.cpp. Метод monitorenter, в зависимости от того, установлено ли UseBiasedLocking, вызывает либо ObjectSynchronizer::fast_enter, либо ObjectSynchronizer::slow_enter. Начнём с первого.

fast_enter


Сначала пара слов относительно целесообразности такой оптимизации. Некоторые токийские учёные из IBM Research Labs каким-то неизвестным мне методом подсчитали статистику и обнаружили, что на самом-то деле в большинстве случаев синхронизация uncontended. Более того, было предложено даже более сильное утверждение: мониторы большинства объектов на протяжении всей жизни захватываются лишь одним потоком. Так и родилась идея biased locking: кто первый встал, того и тапочки. Подробнее почитать про biased locking можно, например, на этих слайдах (хоть они и слегка устарели), а мы вернёмся в исходники hotspot. Сейчас нас интересует файл src/share/vm/runtime/synchronizer.cpp, начиная со строки 169. Сначала мы должны попытаться сделать rebias на себя, а если не выйдет — сделать revoke и перейти к обычному thin slow_enter. Оптимистичные попытки происходят в методе BiasedLocking::revoke_and_rebias, находящемся в файле biasedLocking.cpp. Опишем их поподробнее:
  • Rebias — смена потока, который резервирует право быстро захватывать монитор. Допустимо в простых случаях: например, когда монитор на самом деле ещё некто не успел захватить (anonymously biased), либо если bias сохранился с прошлой эпохи (число bulk revocation-ов для конкретного класса, см. ниже), что, по сути, аналогично тому, что монитор никто не за-bias-ил. Кроме того, rebiasing можно запретить совсем при вызове fast_enter (attempt_rebias = false).
  • Revoke — отключение biased locking для монитора. Используется в большинстве случаев, особенно когда attempt_rebias установлено в false. Если монитором сейчас владеет другой живой поток, то rebias потребует, чтобы этот поток остановился на safepoint'е, после чего он пробежится по стеку этого потока и исправит хранящийся там там заголовок монитора на unbiased.
  • Bulk rebias — rebias всех живых и аллоцированных позднее экземпляров данного класса. Выполняется, когда число revocation'ов или rebiasing'ов для данного класса за эту эпоху превышает BiasedLockingBulkRebiasThreshold. Провоцирует смену эпохи, выполняется на глобальном safepoint.
  • Bulk revoke — revoke всех живых и аллоцированных позднее экземпляров данного класса. Выполняется, когда число revocation'ов или rebiasing'ов для данного класса за эту эпоху превышает BiasedLockingBulkRevokeThreshold. Провоцирует смену эпохи, выполняется на глобальном safepoint.


Напомню тем, кто знал, но забылтм, что такое safepoint:
safepoint — состояние виртуальной машины, в котором исполнение потоков остановлено в безопасных местах. Это позволяет проводить интрузивные операции, вроде revoke bias у монитора, которым поток в данный момент владеет, деоптимизации или взятия thread dump.


Параметр attempt_rebias в нашем случае всегда true, однако иногда он может оказаться и false: например, в случае, когда вызов идёт из VM Thread.

Как вы можете догадаться, bulk-операции — хитрые оптимизации, которые упрощают передачу большого числа объектов между потоками. Если бы не было этой оптимизации, то было бы опасно включать UseBiasedLocking по умолчанию, поскольку тогда большой класс приложений вечно бы занимался revocation'ами и rebiasing'ами.

Если быстрым путём захватить поток не удалось (т.е, был сделан revoke bias), мы переходим к захвату thin-лока.

slow_enter


Метод, который нас теперь интересует, находится в файле src/share/vm/runtime/synchronizer.cpp. Тут у нас есть несколько вариантов развития событий.

  1. Случай первый, «хороший»: монитор объекта в данный момент свободен. Тогда пытаемся его занять с помощью CAS. CAS — это уже архитектурно-зависимая (и как правило нативно поддерживаемая процессором) инструкция, поэтому тут идти глубже особого смысла нет. (Хотя Руслан #дайтеемуужеинвайт cheremin и был возмущён тем, что я «не добрался до беготни электронов в истоках-стоках МОП-транзисторов»). Если CAS удаётся, то победа: монитор захвачен. Если не удаётся, то мы наполняемся печалью и переходим к случаю третьему.

  2. Случай второй, тоже «хороший»: монитор объекта не свободен, но занят тем же потоком, который сейчас пытается его захватить. Это рекурсивный захват. В таком случае для простоты мы записываем в displaced header у себя в стеке NULL, потому что ранее мы уже захватывали этот монитор и уже имеем о нём информацию у себя в стеке.

  3. Случай третий, «плохой» inflate: Итак, все попытки проявить хитрость с крахом провалились, и нам ничего не остаётся делать, кроме как надуться, показать мерзкому пользователю язык и упасть с segfault. Ха-ха. Шутка. Впрочем, насчёт надуться я сказал не просто так: в случае, когда приходится поступать «по-честному», прибегая к примитивам уровня ОС, говорят, что monitor was inflated. В коде это поведение описано в методе ObjectSynchronizer::inflate, куда заглядывать особо внимательно мы не будем: по сути, метод потоко-безопасно и с учётом некоторых технических тонкостей устанавливает монитору флаг, что он раздут.

    После раздувания монитора необходимо в него зайти. Метод ObjectMonitor::enter делает именно это, применяет все мыслимые и немыслимые хитрости, чтобы избежать парковки потока. В число этих хитростей входят, как вы уже могли догадаться, попытки захватить с помощью spin loop'а, с помощью однократных CAS-ов и прочих «халявных методов». Кстати, кажется, я нашёл небольшое несоответствие комментариев с происходящим. вот мы один раз пытаемся войти в монитор spin loop'ом, утверждая, что это делаем лишь однажды:

    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    // Try one round of spinning *before* enqueueing Self
    // and before going through the awkward and expensive state
    // transitions.  The following spin is strictly optional ...
    // Note that if we acquire the monitor from an initial spin
    // we forgo posting JVMTI events and firing DTRACE probes.
    if (Knob_SpinEarly && TrySpin (Self) > 0) {
    	assert (_owner == Self      , "invariant") ;
    	assert (_recursions == 0    , "invariant") ;
    	assert (((oop)(object()))->mark() == markOopDesc::encode(this), "invariant") ;
    	Self->_Stalled = 0 ;
    	return ;
    }


    А вот чуть дальше, в вызываемом методе enterI делаем это снова, опять говоря про лишь один раз:

    485
    486
    487
    488
    489
    490
    491
    492
    493
    494
    495
    496
    497
    // We try one round of spinning *before* enqueueing Self.
    //
    // If the _owner is ready but OFFPROC we could use a YieldTo()
    // operation to donate the remainder of this thread's quantum
    // to the owner.  This has subtle but beneficial affinity
    // effects.
    
    if (TrySpin (Self) > 0) {
    	assert (_owner == Self        , "invariant") ;
    	assert (_succ != Self         , "invariant") ;
    	assert (_Responsible != Self  , "invariant") ;
    	return ;
    }


    Мда, парковка на уровне операционной системы — это настолько страшно, что мы готовы почти на всё, чтобы её избежать. Давайте разберёмся, что же в ней такого ужасного.

    Парковка потоков заднимпроходом

    Должен заметить, что мы сейчас подошли к коду, который писали очень давно, и это заметно. Есть много дубликации, переинжениринга и прочих приятностей. Впрочем, наличие комментариев типа «убрать этот костыль» и «объединить эти с тем» слегка успокаивают.

    Итак, что же такое парковка потоков? Все наверняка слышали, что у каждого монитора есть так называемый Entry List (не путать с Waitset) Так вот: он действительно есть, хотя он и является на самом деле очередью. После всех провалившихся попыток дёшево войти в монитор, мы добавляем себя именно в эту очередь, после чего паркуемся:

    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    // park self
    if (_Responsible == Self || (SyncFlags & 1)) {
    	TEVENT (Inflated enter - park TIMED) ;
    	Self->_ParkEvent->park ((jlong) RecheckInterval) ;
    	// Increase the RecheckInterval, but clamp the value.
    	RecheckInterval *= 8 ;
    	if (RecheckInterval > 1000) RecheckInterval = 1000 ;
    } else {
    	TEVENT (Inflated enter - park UNTIMED) ;
    	Self->_ParkEvent->park() ;
    }


    Прежде чем перейти непосредственно к парковке, обратим внимание на то, что тут она может быть timed или не timed, в зависимости от того, является ли текущий поток ответственным. Ответственных потоков всегда не более одного, и они нужны для того, чтобы избежать так называемого stranding'a: печальки, когда монитор освободился, но все потоки в wait set по-прежнему запаркованы и ждут чуда. Когда есть ответственный, он автоматически просыпается время от времени (чем больше раз произошёл futile wakeup — пробуждение, после которого захватить лок не удалось — тем больше время парковки. Обратите внимание, что оно не превышает 1000 мсек) и пытается войти в монитор. Остальные потоки могут ждать пробуждения хоть целую вечность.

    Теперь настала пора перейти к самой сути парковки. Как вы уже поняли, это что-то, по семантике похожее на знакомые каждому java-разработчику wait/notify, однако происходит на уровне операционной системы. Например, в linux и bsd, как и можно было ожидать, используются POSIX threads, у которых для ожидания освобождения монитора вызываются pthread_cond_timedwait (или pthread_cond_wait). Эти методы меняют статус линуксового потока на WAITING и просят шедулер системы разбудить их, когда произойдёт некоторое событие (но не позже, чем через какой-то промежуток времени, если данный поток ответственный).

    Thread scheduling на уровне ОС

    Что ж, самое время забраться в ядро linux и посмотреть, как там работает шедулер. Исходники linux, как известно, лежат в git, и склонировать шедулер можно так:

    git clone git://git.kernel.org/pub/scm/linux/kernel/git/rostedt/linux-rt.git


    Отправимся в папку… Ладно, ладно, шучу. Особенности устройства шедулера в линуксе с точностью до строк в исходном коде — это уже слишком глубоко для безобидной статьи, которая, позвольте напомнить, начиналась с простенького synchronized-блока :) В целях понижения хардкорности расскажу в общих чертах, как вообще работают шедулеры; как в linux, так и в альтернативных системах. Если кто-то вдруг не понимает, зачем он вообще нужен, то поясню: шедулер — такая сущность, которая делит процессорное время между потоками. Одно из основных понятий — quantum — есмь время, которое выделяется потоку на то, чтобы выполнить то, что ему нужно. В linux это время сильно зависит от многих факторов, но обычно умещается в 10-200 тиков, где тик как правило равен 1 мс. В windows это тоже много от чего зависит, но это может быть 2-15 тиков, где тик — от 10 до 15 мс.

    Конечно, не стоит думать, что поток будет исполняться ровно столько времени, сколько ему отведено. Он может сам решить, что сделал всё, что хотел (например, заблокироваться на каком-нибудь I/O-вызове), либо его может насильно выдернуть раньше времени шедулер, отдав остаток его кванта кому-нибудь другому. А может и наоборот решить продлить квант по какой-нибудь причине. Кроме того, у потоков есть приоритет, который, в общем-то, понятно, что делает.

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

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

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


monitorexit


В случае с biased locking мы, в общем-то, ничего и не делаем. Мы обнаруживаем, что в displaced header хранится NULL, и просто выходим. Отсюда интересный момент: при попытке отпустить не занятый в данный момент biased lock интерпретатор не выкенет IllegalMonitorStateException (но за такими вещами следит верификатор байт-кода).

В случае с unbiased locking мы делаем вызов к InterpreterRuntime::monitorexit. После нескольких проверок (например, что монитор действительно заблокирован: в противном случае швыряется IllegalMonitorStateException) вызывается ObjectSynchronizer::slow_exit, который только и делает, что вызывает fast_exit. Если будете читать исходники, не обращайте внимание на комментарий про fast path. В этом методе возможны следующие варианты развития событий: монитор находится в состоянии stack-locked, inflating или inflated. В первом случае всё просто: возвращаем заголовок объекта в то состояние, в котором оно было до блокировки, и выходим. Во втором случае дожидаемся того, как кто-то закончит раздувать монитор и перейдём к случаю третьему.

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

Если же такого потока нет, то, в зависимости от политик (выставляются с помощью Knob_QMode. Я, честно сказать, ни нашёл ни одного места, где его значение меняется с 0, выставленного по умолчанию. Знающие людитм, однако, подсказывают, что это, скорее всего, остатки отладки и тюнинга), выбираем, кого будить первого. Это могут оказаться потоки, которые просыпались недавно, или наоборот те, что просыпались более давно. После ещё небольшой цепочки вызовов мы попадаем в платформо-зависимый код os::PlatformEvent::unpark(), который и говорит нужному потоку сигнал. Например, в linux и bsd используется pthread_cond_signal.

Собственно, если очень сильно не вдаваться в детали, то это всё, что можно сказать об освобождении монитора.

NB: synchronized-методы


Если бы мы написали наш изначальный java-код вот так вот:
synchronized void doSomething() {
    // Do something
}

то байт-код у такого метода заметно короче:

synchronized void doSomething();
  Code:
   0:	return


В bytecodeInterpreter.cpp synchronized-методы обрабатываются, начиная со строки 767. Там есть проверка if (METHOD->is_synchronized()). Внутри у этого условия находится огромная куча if-ов, связанных с biased locking. Это как раз то, чего внезапно не оказалось при обработке операции monitorenter. В общем и целом, происходит то, что мы раньше обсуждали, однако тут всё же есть быстрый (без CAS) захват biased монитора потоком-владельцем.

Также, после окончания выполнения тела метода идёт выход из монитора, если метод synchronized.

Wait и notify


Обработка этих методов в итоге попадает в synchronizer.cpp, начиная с 377 строки. Монитор в wait/notify в интерпретаторе обязательно должен быть inflated, потому первое, что эти методы делают — inflate'ят его. После того вызывают у него методы wait или notify.

Первый добавляет себя в wait set (на самом деле очередь) и паркуется до тех пор, пока ему не пора будет просыпаться (прошло время, которое просили подождать; произошло прерывание или кто-то вызвал notify).

Notify же вытаскивает из wait set один поток и добавляет его, в зависимости от политик, в какое-то место в очереди тех, кто хочет захватить монитор. NotifyAll отличается лишь тем, что вытаскивает из wait set всех.

Memory effects


Те, кто знаком с JMM, знают, что освобождение монитора happens-before захват того же самого монитора. В случае thin это гарантируется CAS-ами; в случае inflated это гарантируется явными вызовами OrderAccess::fence();. Если же монитор biased, то значит, что им пользуется только один поток: у него исполнение гарантируется в program order и без того. При revoke HB появляется либо во время monitorexit, если поток был жив, (который оказывается уже либо thin, либо inflated), либо при enter (который тоже оказывается либо thin, либо inflated).

Прямо перед выходом из wait выставляется явный fence, чтобы прогарантировать HB.

Замечание от Майора О. aka Disclaimer

На самом делетм, всё происходит не так, как мы думаем. Например, когда JIT компилирует наш код в нативный. Или когда мы работаем с другими виртуальными машинами. Впрочем, никто не может прогарантировать, что и в простом случае «интерпретатор в hotspot» всё действительно так, как я тут написал.

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 у живых объектов.

На всех safepoint вызывается метод ObjectSynchronizer::deflate_idle_monitors, по названию которого очень легко осознать, что он делает.

Большое спасибо доблестным TheShade, artyushov, cheremin и AlexeyTokar за то, что они (вы|про)читали статью перед публикацией, убедившись тем самым, что я не принесу в массы вместо света какую-то бредовню, наполненную тупыми шутками и очепатками.
Глеб Смирнов @gvsmirnov
карма
119,5
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (20)

  • 0
    А можно уточнить — монитор он один на всех? То есть если у меня штук 20-30 ядер с соответствующим параллелизмом, несколько групп тредов, каждая из которых окучивает свой независимый набор данных, то блокировка в одной группе тормознёт остальные?
    • 0
      Там в начале написано, что для каждого объекта свой монитор. Так что всё хорошо :)
  • +1
    Отлично, просто отлично. Жду продолжения!
  • +1
    Спасибо за статью и ссылки на презентации, очень полезная информация!
  • +7
    Исходники интерпретатора лежат в папочке hotspot/src/share/vm/interpreter…
    Следующим представляет интерес файл bytecodeInterpreter.cpp.

    Это сишный интерпретатор для экспериментов и быстрого портирования на новые архитектуры. В production он не используется. На практике работает ассемблерный так называемый Template Interpreter. Его исходники лежат в src/cpu/x86/vm/templateTable* и src/cpu/x86/vm/templateInterpreter*.
    • НЛО прилетело и опубликовало эту надпись здесь
    • 0
      Welp, fuck. Спасибо.иЭто, наверное, хорошо объясняет недопроверку при захвате biased монитора. Много ещё важных отличий?
      • +1
        Да, в сишном интерпретаторе много что может отсутствовать. И работает он раза в 2-3 медленнее, чем template interpreter. А вообще внутренности можно изучать по исходникам C1 компилятора — они простые для понимания, но вместе с тем это живой и актуальный код. Например, захват монитора — C1_MacroAssembler::lock_object(), освобождение — C1_MacroAssembler::unlock_object().
        • 0
          Ясненько, понятненько. Теперь буду смотреть туда. Спасибо за привнесённый свет :)
        • +2
          Только С2! Только хардкор! ;)))
  • +4
    Классные у вас статьи, очень интересно.
    И оформлено красиво :)
  • +1
    Великолепно! Вы молодец! Еще одна прекрасная статья о внутренней кухне Java!
    Обязательно продолжайте нас радовать такого рода статьями!
  • +3
    Дико плюсую! Чем больше узнаю про многопоточность, тем меньше стараюсь ее использовать =).
  • 0
    Идеальное оформление поста — заношу подобное в избранное и потом привожу в пример :)
    • 0
      Спасибо. Возможно, стоит по умолчанию сделать более заметными и отличными друг от друга заголовки h1..7?
      • 0
        Может и стоит ) По любым предложениям можно мне в ЛС.
  • 0
    Почему для wait() и notify() нужно владеть монитором? Это входит в семантику монитора? Или защита на уровне семантики языка от ошибок синхронизации?
    • 0
      Если я правильно понял, что вы имеете в виду, то и то и то. Если монитором владеть было бы не обязательно, то могли бы возникать races на тему того, что выполнится «раньше»: wait или notify.
  • 0
    Одна из лучших статей, за последний месяц.
  • 0
    Уж Герман близится, а полночи все нет! Где обещанное продолжение?!

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