Синхронизация в Java часто воспринимается как простая языковая конструкция — достаточно использовать ключевое слово synchronized, и код начинает «просто работать».

На практике же на уровне JVM происходит цепочка событий, которую можно проследить до Mark Word — восьмибайтового служебного поля заголовка каждого Java-объекта.

Современные JVM (HotSpot, OpenJ9, GraalVM) не используют фиксированную модель блокировок. Вместо этого они динамически выбирают стратегию синхронизации, исходя из реального поведения потоков и истории использования объекта.

Эта статья предназначена для Java‑разработчиков, которые уже знакомы с многопоточностью и synchronized, но хотят разобраться, как именно JVM управляет блокировками, какие состояния проходит объект и какую роль в этом играет Mark Word.


Структура Java-объекта: заголовок и Mark Word

Когда вы создаёте new Object() в Java, JVM выделяет в памяти не только область под данные объекта, но и служебный заголовок.

Одной из ключевых частей этого заголовка является Mark Word, используемый JVM для хранения метаинформации об объекте:

// Визуализация объекта в памяти (64-bit, compressed oops):
Object obj = new Object();
// Адрес в памяти: 0x000000076ab0c410
// Содержимое:
// [0x0000000000000001]  ← Mark Word (8 байт)
// [0x00000007c0060c00]  ← Указатель на класс (4/8 байт)
// [данные объекта...]    ← Поля вашего класса

Mark Word — это служебное поле заголовка объекта, которое JVM использует для следующих задач:

  • синхронизация (самое важное для нас)

  • сборка мусора (отметка и возраст объекта)

  • хранение identity hash-кода (при первом вызове System.identityHashCode());

  • смещённая блокировка (устаревшая оптимизация)

Концептуально Mark Word можно представить как машинное слово, в котором отдельные биты кодируют состояние объекта. В 64-битных JVM это, как правило, 64-битное значение, часть которого отводится под состояние синхронизации.

Для целей синхронизации в Mark Word кодируются несколько логических состояний, из которых для синхронизации значимы следующие:

  • Неблокированный (unlocked) — объект не находится под монитором; самое частое состояние.

  • Лёгкая (thin) блокировка — один поток вошёл в synchronized без конкуренции.

  • Тяжёлая (inflated) блокировка — используется при конкуренции между потоками или при вызове wait()/notify().

  • Отмечен для сборки мусора (marked for GC) - временное состояние во время работы сборщика мусора.

  • Хэш сохранён (hash stored) - после вызова System.identityHashCode(). Обычный hashCode() может быть переопределён и не затрагивать Mark Word.

  • Смещённый заголовок (displaced header) - временное состояние при переходе между другими состояниями.

Ранее применялось еще и смещённая блокировка (biased locking) — историческая оптимизация, существовавшая в HotSpot до Java 18. Она позволяла «привязать» объект к конкретному потоку, но была удалена из OpenJDK из-за высокой стоимости отмены смещения в многопоточных сценариях.

Важное уточнение: Конкретные битовые паттерны (например, 01 для unlocked) зависят от реализации JVM. HotSpot, OpenJ9 и GraalVM могут использовать разные комбинации битов для одних и тех же концептуальных состояний.

Примерный вид записи в Mark Word

Ниже приведены иллюстративные примеры значений Mark Word, полученные на HotSpot JVM, конкретные значения могут отличаться в других реализациях и конфигурациях.

1. НЕБЛОКИРОВАННЫЙ (01) — "свободен для аренды"
Пример: 0x0000000000000001
                        ↑↑ последние биты = 01

2. ЛЁГКАЯ БЛОКИРОВКА (00) — "занято, но ключ простой"
Пример: 0x000070000d65f468
                        ↑↑ последние биты = 00
                           Mark Word (hex): 0x70000d65f468
                           Последний байт (hex): 0x68
                           Последний байт (bin): 01101000
                           Последние 2 бита: 0

3. ТЯЖЁЛАЯ БЛОКИРОВКА (10) — "занято, очередь у двери"
Пример: 0x00007f8c8400d1a2
                        ↑↑ последние биты = 10
                           Mark Word (hex): 0x7f8c8400d1a2
                           Последние 2 бита (dec): 2
                           Последние 2 бита (bin): 10

Как JVM выбирает состояние блокировки

Концептуально, детали различаются между JVM (приведён упрощённый псевдокод, иллюстрирующий общую логику выбора стратегии блокировки. Это не реальный код JVM):

State selectLockingStrategy(Object obj, Thread thread) {

    // Проверяем, не помечен ли объект для GC
    if (isMarkedForGC(obj)) return State.GC_MARKED;
    
    // Проверяем, есть ли сохранённый хэш
    if (hasIdentityHash(obj)) return State.HASH_STORED;
    
    // Смотрим историю использования объекта
    LockingProfile profile = getProfile(obj);
    
    if (profile.isSingleThreaded()) {

        // Один поток → тонкая блокировка
        return attemptThinLock(obj, thread);

    } else if (profile.hasWaiters()) {

        // Есть wait() → тяжёлая блокировка
        return inflateToHeavyLock(obj);

    } else if (profile.isHighlyContended()) {

        // Много конкурентов → тяжёлая с оптимизациями
        return createAdaptiveLock(obj);

    }
    
    // Дефолт: начинаем с тонкой
    return attemptThinLock(obj, thread);
}

Жизненный цикл объекта при синхронизации

Рассмотрим, как меняется состояние Mark Word в зависимости от наличия конкуренции между потоками.

Сценарий 1: Один поток, нет конкуренции

// Простейший случай — один поток синхронизируется на объекте
Object lock = new Object();

// До синхронизации:
// Mark Word = 0x0000000000000001 (неблокированный)

synchronized(lock) {
    // 1. JVM проверяет последние биты: 01
    // 2. Пытается сделать CAS (сравнить-и-заменить):
    //    Было: 0x0000000000000001
    //    Стало: 0x000070000d65f468
    //                 ↑ указатель на стек потока | 00
    // 3. Успех! Поток вошёл в блок
    
    counter++; // Критическая секция
}

// После выхода:
// Mark Word снова = 0x0000000000000001
// (лёгкая блокировка снимается)

Что важно: В этом сценарии не создаётся Monitor! 
Всё работает через быструю CAS-операцию.

Сценарий 2: Появляется конкуренция

// Два потока хотят один объект
Object sharedLock = new Object();

Thread t1 = new Thread(() -> {
    synchronized(sharedLock) {
        Thread.sleep(100); // Держит блокировку
    }
});

Thread t2 = new Thread(() -> {
    // Пытается войти, пока t1 внутри
    synchronized(sharedLock) {
        // Здесь произойдёт инфляция — переход от легковесной блокировки (состоящей лишь из флагов в Mark Word)
        // к использованию полноценного объекта-монитора, способного управлять очередями.
    }
});

t1.start();
t2.start();

При появлении конкуренции JVM вынуждена перейти от лёгкой блокировки к полноценному монитору. Процесс инфляции (упрощённо):

T2 видит: Mark Word = [...][00] (лёгкая блокировка от T1)

T2 ждёт немного ("спин"), пробует снова — не получается

JVM создаёт Monitor — полноценный объект-диспетчер

Mark Word меняется на: [указатель на Monitor][10]

T2 встаёт в очередь Monitor'а

Универсальная концепция Monitor'а

Концептуально монитор — это абстрактный механизм, обеспечивающий:

  • владельца (owner);

  • счётчик рекурсивных входов;

  • очереди ожидания.

Именно этот механизм и реализуют по-разному в HotSpot (ObjectMonitor), OpenJ9 (J9Monitor) и других JVM. Хотя реализации разные, примерное наполнение едино для всех JVM:

УНИВЕРСАЛЬНАЯ КОНЦЕПЦИЯ (работает везде):
[Объект] → [Монитор] → {
    - Владелец (owner)
    - Счетчик рекурсий (recursions)
    - Очередь для synchronized (entry queue)
    - Очередь для wait() (wait set)
}

Конкретные реализации отличаются между JVM:

  • языком реализации (C++ в HotSpot, C/Java-подобные структуры в других);

  • расположением в памяти (нативная/Java-куча);

  • алгоритмами управления очередями (LIFO/FIFO/адаптивные);

  • названием структуры (ObjectMonitor, J9Monitor и т.д.).

Как это посмотреть в реальном коде

Пример наблюдения изменений Mark Word с помощью библиотеки JOL (Java Object Layout).

import org.openjdk.jol.info.ClassLayout;

public class LockDemo {
    public static void main(String[] args) throws Exception {
        Object obj = new Object();
        
        System.out.println("=== 1. НОВЫЙ ОБЪЕКТ ===");
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        // Mark Word: 0x0000000000000001 (неблокированный)
        
        synchronized(obj) {
            System.out.println("\n=== 2. В SYNCHRONIZED (1 поток) ===");
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
            // Mark Word: 0x000070000d65f468 (лёгкая блокировка)
        }
        
        // Создаём конкуренцию
        Thread t1 = new Thread(() -> {
            synchronized(obj) {
                try { Thread.sleep(500); } catch (Exception e) {}
            }
        });
        
        t1.start();
        Thread.sleep(100); // Даём t1 захватить блокировку
        
        synchronized(obj) {
            System.out.println("\n=== 3. ПРИ КОНКУРЕНЦИИ ===");
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
            // Mark Word: 0x00007f8c8400d1a2 (тяжёлая блокировка)
        }
    }
}

Вывод:

=== 1. НОВЫЙ ОБЪЕКТ ===
java.lang.Object object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable)
  8   4        (object header: class)    0xf80001e5

=== 2. В SYNCHRONIZED (1 поток) ===
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x000070000d65f468 (thin lock: ...)
  8   4        (object header: class)    0xf80001e5

=== 3. ПРИ КОНКУРЕНЦИИ ===
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x00007f8c8400d1a2 (inflated)
  8   4        (object header: class)    0xf80001e5


Понимание устройства Mark Word и жизненного цикла блокировок позволяет лучше интерпретировать поведение synchronized в реальных многопоточных сценариях и осознанно подходить к вопросам производительности и конкуренции потоков.

Описанные механизмы являются концептуальными и могут отличаться в деталях реализации между различными JVM.


Материал подготовлен автором telegram-канала о изучении Java.