synchronized и мониторы: как JVM управляет блокировками
Что такое monitor
Каждый объект в Java потенциально может быть монитором — структурой для взаимного исключения. Когда поток входит в synchronized блок, он пытается захватить монитор, ассоциированный с объектом. Монитор хранит owner-поток, счётчик реентрантности и очереди ожидающих потоков (entry list и wait set).
synchronized — это не библиотечный примитив, а часть байткода: инструкции monitorenter и monitorexit. Для синхронизированных методов JVM просто помечает метод флагом ACC_SYNCHRONIZED, и захват/отпускание монитора делает сама JVM при входе/выходе.
Markword и три состояния блокировки
Информация о блокировке живёт в заголовке объекта — markword (обычно 8 байт на 64-битной JVM). У современных HotSpot есть несколько состояний lock:
- Neutral / unlocked — объект свободен, в markword лежит hashcode и биты состояния.
- Biased locking — оптимизация под случай, когда объект почти всегда захватывается одним и тем же потоком (типично для однопоточного кода). В markword записывается thread id, и повторный захват — это просто проверка id без CAS. Начиная с Java 15 biased locking отключён по умолчанию и в Java 18+ удалён — оптимизация не оправдала себя на современных многоядерных нагрузках.
- Thin lock (lightweight) — при конкуренции без реальных потоков в очереди захват идёт через CAS: поток пытается атомарно записать указатель на свой lock record в markword. Это дёшево, без похода в ядро ОС.
- Fat lock (heavyweight) — если на объект претендуют несколько потоков одновременно, thin lock «раздувается» в полноценный ObjectMonitor с очередями в куче, и неудачники уходят в блокировку через OS-примитивы (futex на Linux). Это дорого: контекст-свитч, поход в ядро.
Именно поэтому synchronized в современной JVM почти бесплатен при низкой конкуренции — деградация в дорогой fat lock происходит только при реальной борьбе за ресурс.
Реентрантность
Монитор считает, сколько раз владелец-поток вошёл в блок повторно (recursion count). Поэтому один и тот же поток может свободно войти в несколько вложенных synchronized блоков на одном объекте — блокировка не заклинит сама себя. Выход из внешнего блока произойдёт только когда счётчик дойдёт до нуля.
public synchronized void outer() {
inner(); // тот же поток, тот же монитор — ок
}
public synchronized void inner() {
// ...
}wait/notify и связь с монитором
Object.wait(), notify(), notifyAll() работают только с монитором объекта, на котором вызваны, и обязаны вызываться внутри synchronized-блока на этом же объекте — иначе IllegalMonitorStateException. wait() отпускает монитор и кладёт поток в wait set; notify() перемещает один поток из wait set обратно в entry list, но монитор он получит не сразу, а только когда текущий владелец выйдет из блока.
notify() без цикла проверки условия у ждущего потока. Между notify() и реальным пробуждением условие может измениться (другой поток успел вклиниться), поэтому wait() всегда нужно оборачивать в while(!condition) wait();, а не в if.synchronized vs ReentrantLock
ReentrantLock из java.util.concurrent.locks даёт то, что не умеет synchronized: tryLock() с таймаутом, прерываемое ожидание, справедливый режим (fair), несколько условий (Condition) на один лок вместо одного wait set. Плата — явный lock()/unlock(), обязательно в try/finally, где легко забыть unlock и получить вечную блокировку.
Lock lock = new ReentrantLock();
lock.lock();
try {
// критическая секция
} finally {
lock.unlock();
}На практике: если нужна простая взаимоисключающая секция — бери synchronized, JIT и JVM оптимизируют его не хуже. Если нужны таймауты, честность очереди или несколько условий ожидания — ReentrantLock.