🧩 Раздел 2 · Вопрос #5

Что такое double-checked locking

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


🟢 Junior Level

Double-Checked Locking (DCL, блокировка с двойной проверкой) — это идиома многопоточного программирования, предназначенная для оптимизации ленивой инициализации тяжелых ресурсов (чаще всего Singleton).

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


В чем проблема наивных подходов?

1. Синхронизированный метод — слишком медленно

// ❌ Медленно: блокировка при КАЖДОМ обращении
public static synchronized Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}

Здесь монитор класса блокируется каждый раз, даже когда объект уже давно создан и требуется просто вернуть ссылку. В многопоточном приложении с миллионами обращений это создает критическое узкое место (lock contention).

2. Простая ленивая инициализация — потоконебезопасно

// ❌ Опасно: состояние гонки (Race Condition)
public static Singleton getInstance() {
    if (instance == null) { // Потоки А и B одновременно проверят и зайдут внутрь
        instance = new Singleton(); // Будет создано два разных объекта!
    }
    return instance;
}

Решение: Double-Checked Locking

Проверка условия выполняется дважды:

  1. Первая проверка (без блокировки): если объект уже создан, сразу возвращаем его без захвата монитора.
  2. Вторая проверка (внутри synchronized): если объект еще не создан, блокируем монитор и повторно проверяем условие, чтобы исключить создание дубликата другим потоком, который мог успеть инициализировать ресурс во время ожидания блокировки.
public class DclSingleton {
    // ⚠️ Модификатор volatile КРИТИЧЕСКИ ОБЯЗАТЕЛЕН!
    private static volatile DclSingleton instance;

    private DclSingleton() {}

    public static DclSingleton getInstance() {
        // 1-я проверка (быстрая, без синхронизации)
        if (instance == null) {
            synchronized (DclSingleton.class) {
                // 2-я проверка (потокобезопасная)
                if (instance == null) {
                    instance = new DclSingleton();
                }
            }
        }
        return instance;
    }
}

🟡 Middle Level

Почему необходимы ровно две проверки?

Рассмотрим сценарий работы двух потоков (Поток 1 и Поток 2):

Поток 1                          Поток 2
  │                                │
  ├─ 1-я проверка: instance == null│
  │  (успешно, идет к lock)        ├─ 1-я проверка: instance == null
  │                                │  (тоже успешно!)
  ├─ Захватывает synchronized      │
  │                                ├─ Пытается войти в synchronized
  ├─ 2-я проверка: instance == null│  (ЖДЕТ освобождения монитора)
  │  (успешно, создает объект)     │
  ├─ instance = new DclSingleton() │
  ├─ Выходит из synchronized ──────┼─ Захватывает освобожденный монитор
  │                                ├─ 2-я проверка: instance == null
  │                                │  (FALSE! Объект уже создан Потоком 1)
  │                                ├─ Выходит без создания дубликата
  • Если убрать 1-ю проверку: получим обычный synchronized-метод со снижением производительности при каждом вызове.
  • Если убрать 2-ю проверку: Поток 2 после захвата монитора создаст второй экземпляр, нарушив контракт Singleton.

Почему volatile жизненно необходим?

Создание объекта в Java instance = new DclSingleton() на уровне байткода и процессора состоит из трех независимых операций:

1. allocate_memory(DclSingleton)  -> выделение памяти под объект
2. invokespecial <init>            -> вызов конструктора (инициализация полей)
3. instance = address              -> запись адреса памяти в переменную instance

Проблема переупорядочивания инструкций (Instruction Reordering)

Компилятор JIT и суперскалярный CPU в целях оптимизации конвейера команд имеют право изменить порядок выполнения на: 1 -> 3 -> 2.

Поток 1 (Создатель)                       Поток 2 (Читатель)
  │                                         │
  ├─ 1. Выделил память                      │
  ├─ 3. Записал ссылку в instance           │
  │     (ссылка НЕ null, но объект          ├─ Вызывает getInstance()
  │      еще не инициализирован!)           ├─ 1-я проверка: instance != null (TRUE!)
  │                                         ├─ Возвращает сырую ссылку Потоку 2
  │                                         ├─ Поток 2 вызывает instance.getData()
  │                                         │  ❌ NPE или испорченное состояние!
  ├─ 2. Вызов конструктора (поздно!)        │

Модификатор volatile запрещает компилятору и аппаратуре такое переупорядочивание, гарантируя, что запись ссылки произойдет строго после завершения работы конструктора.


Оптимизация локальной переменной (Local Variable Optimization)

В высоконагруженном коде чтение volatile-переменной обходится дороже обычного чтения из стека или регистра, так как требует обращения к протоколу когерентности кэшей (MESI):

public class OptimizedDclSingleton {
    private static volatile OptimizedDclSingleton instance;

    public static OptimizedDclSingleton getInstance() {
        // Однократное чтение volatile-поля в локальную переменную стека
        OptimizedDclSingleton local = instance;
        if (local == null) {
            synchronized (OptimizedDclSingleton.class) {
                local = instance;
                if (local == null) {
                    instance = local = new OptimizedDclSingleton();
                }
            }
        }
        return local; // Возврат из локальной переменной
    }
}
  • Эффект: в 99.99% вызовов, когда синглтон уже инициализирован, обращение к volatile-переменной instance выполняется строго один раз вместо двух. Это ускоряет выполнение метода на ~25–30% в цикле вызовов.

Сравнительный анализ альтернатив

Подход Ленивая загрузка Потокобезопасность Оверхед после старта Сложность реализации
Synchronized метод ✅ Да ✅ Да 🔴 Высокий (lock contention) Минимальная
DCL без volatile ✅ Да ❌ НЕТ (UB) 🟢 Нулевой Ошибочная
DCL с volatile ✅ Да ✅ Да 🟢 Минимальный Высокая
Bill Pugh (Holder) ✅ Да ✅ Да 🟢 Нулевой (JLS 12.4.2) Низкая
Enum Singleton ❌ Нет (Eager) ✅ Да 🟢 Нулевой Минимальная

🔴 Senior Level

Аппаратная модель памяти: x86 (TSO) против ARM64 и JSR-133

До выхода спецификации JSR-133 (Java 5) в 2004 году модель памяти Java (JMM) содержала пробелы: ключевое слово volatile обеспечивало видимость, но не запрещало переупорядочивание обычных чтений/записей вокруг volatile. Из-за этого DCL до Java 5 был фундаментально сломан на уровне языка.

Почему DCL без volatile мог годами «работать» на серверах Intel?

  • x86-64 (Intel / AMD): использует модель памяти Total Store Order (TSO). На аппаратном уровне x86 не переупорядочивает инструкции StoreStore (запись после записи) и LoadLoad (чтение после чтения). Процессор аппаратно не позволяет ссылке на память опубликоваться раньше, чем запишутся данные инициализированного объекта. Из-за этого баг DCL без volatile оставался незамеченным на x86-серверах.
  • ARM64 (Apple Silicon M-series, AWS Graviton, Ampere): использует слабую модель памяти (Weak Memory Ordering). Процессор свободно переупорядочивает чтение и запись в память, если между ними нет явных барьеров памяти. DCL без volatile на ARM64 гарантированно приводит к чтению полуинициализированных объектов и аварийным сбоям.

Барьеры памяти JMM для volatile

При записи в volatile поле JVM вставляет:

  • StoreStore барьер перед записью (все предшествующие записи завершены).
  • StoreLoad барьер после записи (самый тяжелый барьер, предотвращает чтение до завершения записи). При чтении из volatile вставляются барьеры LoadLoad и LoadStore.

Альтернатива на VarHandle (Java 9+)

Начиная с Java 9, семантику Acquire/Release можно выразить через VarHandle, избегая жесткого барьера полного последовательного порядка (Sequential Consistency), накладываемого volatile:

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

public class VarHandleDcl {
    private static final VarHandle INSTANCE_HANDLE;
    private static VarHandleDcl instance;

    static {
        try {
            INSTANCE_HANDLE = MethodHandles.lookup()
                .findStaticVarHandle(VarHandleDcl.class, "instance", VarHandleDcl.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    private VarHandleDcl() {}

    public static VarHandleDcl getInstance() {
        // Однонаправленный Acquire-барьер (дешевле Seq-Cst чтения volatile)
        VarHandleDcl local = (VarHandleDcl) INSTANCE_HANDLE.getAcquire();
        if (local == null) {
            synchronized (VarHandleDcl.class) {
                local = (VarHandleDcl) INSTANCE_HANDLE.getAcquire();
                if (local == null) {
                    local = new VarHandleDcl();
                    // Однонаправленный Release-барьер (дешевле StoreLoad барьера)
                    INSTANCE_HANDLE.setRelease(local);
                }
            }
        }
        return local;
    }
}

DCL за пределами Singleton: кэширование и ленивые вычисления

Идиома DCL применяется везде, где требуется атомарное однократное вычисление ресурса:

public class HeavyReportCache {
    private volatile byte[] cachedPdf;

    public byte[] getReportPdf() {
        byte[] local = cachedPdf;
        if (local == null) {
            synchronized (this) {
                local = cachedPdf;
                if (local == null) {
                    cachedPdf = local = generateExpensivePdfReport();
                }
            }
        }
        return local;
    }

    private byte[] generateExpensivePdfReport() {
        // Тяжелые вычисления
        return new byte[1024];
    }
}

Исключение из правил: почему String.hashCode() не использует volatile?

В классе java.lang.String поле hash кэшируется лениво без volatile и без synchronized:

// Исходный код java.lang.String
public int hashCode() {
    int h = hash;
    if (h == 0 && !hashIsZero) {
        h = isLatin1() ? StringLatin1.hashCode(value) : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;
        } else {
            hash = h;
        }
    }
    return h;
}

Почему это безопасно (Benign Data Race):

  1. Поле hash — это примитив int (32 бита). Запись 32-битного примитива в Java атомарна (нет разрыва слова / word tearing).
  2. Вычисление детерминировано: если два потока одновременно посчитают хэш, они запишут абсолютно одинаковое число.
  3. Это допустимо только для иммутабельных примитивов, но категорически неприменимо для ссылок на составные объекты!

🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«Double-Checked Locking (DCL) — это идиома многопоточного программирования для ленивой инициализации объектов с минимальным оверхедом на синхронизацию. Первая проверка выполняется без блокировок ради высокой производительности в hot-path, а вторая — внутри блока synchronized для предотвращения дублирования объекта параллельными потоками. Модификатор volatile критически обязателен, так как без него JIT-компилятор и CPU могут переупорядочить инструкции (выделение памяти → публикация ссылки → выполнение конструктора), в результате чего параллельный поток получит ссылку на полуинициализированный объект».


3–4 каверзных вопроса с ответами

  1. Что произойдет, если в DCL убрать volatile? Будет ли код компилироваться и когда он упадет?
    • Ответ: Код успешно скомпилируется и даже пройдет большинство тестов. Однако в многопоточной среде произойдет состояние гонки из-за Out-of-Order Execution: поток-читатель на первой проверке увидит instance != null, когда конструктор еще не завершил инициализацию полей. Это приведет к непредсказуемым NullPointerException или чтению поврежденного состояния. На архитектуре ARM64 (например, AWS Graviton) сбой воспроизводится мгновенно из-за слабой модели памяти.
  2. Зачем сохранять volatile-переменную в локальную переменную local = instance?
    • Ответ: Это оптимизация локальной переменной (Local Variable Optimization). Чтение volatile-поля требует соблюдения когерентности процессорных кэшей и стоит дороже чтения из стека. Сохранение в локальную переменную сокращает количество чтений volatile до одного в 99.9% сценариев, снижая оверхед на 25–30%.
  3. Почему в JDK до версии 1.5 паттерн DCL не работал даже с ключевым словом volatile?
    • Ответ: До спецификации JSR-133 (Java 5) в Java Memory Model отсутствовали строгие правила для volatile касательно переупорядочивания с обычными переменными. Барьеры памяти гарантировали только синхронизацию кэша, но не предотвращали вынос записи ссылки за пределы выполнения конструктора.
  4. Почему String.hashCode() может кэшировать значение без volatile и DCL, а Singleton так делать не может?
    • Ответ: Для String.hashCode() допустима «доброкачественная гонка данных» (benign race condition), так как кэшируется примитив int (атомарная 32-битная запись), а результат вычисления детерминирован и не зависит от инициализации полей объекта в куче. В случае со сложным объектом без volatile другой поток видит ссылку на незаполненные поля в куче.

Типичные ошибки и Red Flags

  • 🚩 Использование DCL без volatile — фундаментальное непонимание JMM и процессорных архитектур.
  • 🚩 Утверждение «на моем компьютере без volatile все работает» — незнание разницы между строгой моделью памяти x86 (TSO) и слабой моделью ARM64 / PowerPC.
  • 🚩 Удаление второй проверки внутри synchronized — приводит к созданию дубликатов Singleton при конкурентном первом обращении.
  • 🚩 Ручное написание DCL в Spring Boot приложениях вместо использования DI-контейнера (@Component / @Service с singleton-областью видимости).

Связанные темы