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

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

4. VarHandle (Java 9+) для экстремальной производительности 5. Не используйте DCL без понимания JMM 6. DCL полезен для lazy кэширования (не только Singleton)

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

Double-Checked Locking (DCL) — оптимизация подхода с synchronized-методом. Вместо блокировки при КАЖДОМ вызове, блокируем только при первом создании объекта.

Проблема: Обычный synchronized добавляет ~10-50ns на вызов. В hot-path с миллионами вызовов это секунды накладных расходов.

Решение: Проверить дважды — сначала без блокировки, потом с блокировкой.

Пример:

public class Singleton {
    private static volatile Singleton instance;
    
    public static Singleton getInstance() {
        // 1-я проверка (быстрая)
        if (instance == null) {
            // Блокировка только если нужно создать
            synchronized (Singleton.class) {
                // 2-я проверка (безопасная)
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Как работает:

  1. Первый поток: instance = null → synchronized → создаёт
  2. Второй поток: instance = null → ждёт блокировку → видит, что уже создан
  3. Третий поток: instance != null → сразу возвращает (без synchronized!)

Зачем volatile: Без него другой поток может получить частично созданный объект.

Когда НЕ использовать DCL

  1. Не понимаете JMM — используйте Bill Pugh Singleton
  2. Bill Pugh достаточно — проще и надёжнее
  3. Не hot-path — synchronized-метод достаточно быстр

🟡 Middle Level

Зачем две проверки?

// ❌ Одна проверка = медленно
public static synchronized Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}
// synchronized вызывается КАЖДЫЙ раз!

// ✅ DCL = быстро после инициализации
public static Singleton getInstance() {
    if (instance == null) {  // Быстрая проверка
        synchronized (Singleton.class) {
            if (instance == null) {  // Точная проверка
                instance = new Singleton();
            }
        }
    }
    return instance;
}
// synchronized только при создании!

Почему volatile обязателен?

// ❌ Без volatile — ОПАСНО!
private static Singleton instance;

// instance = new Singleton() состоит из:
// 1. Выделить память
// 2. Вызвать конструктор
// 3. Присвоить ссылку instance

// Процессор может переупорядочить: 1 → 3 → 2
// Поток B видит instance != null (шаг 3)
// Но конструктор не выполнен (шаг 2)!
// → NullPointerException или corrupted state

// ✅ С volatile — безопасно
private static volatile Singleton instance;
// volatile запрещает переупорядочивание!

Local Variable Optimization

public static Singleton getInstance() {
    Singleton local = instance;  // Читаем volatile один раз
    if (local == null) {
        synchronized (Singleton.class) {
            local = instance;
            if (local == null) {
                instance = local = new Singleton();
            }
        }
    }
    return local;
}

// Без оптимизации: instance читается 2-3 раза (volatile = медленно)
// С оптимизацией: instance читается 1 раз → быстрее на ~25%

Сравнение подходов

Подход Скорость Безопасность Сложность
Synchronized Медленно Простая
DCL без volatile Быстро Средняя
DCL + volatile Быстро Высокая
Bill Pugh Быстро Простая
Enum Быстро Минимальная

🔴 Senior Level

JSR-133 — спецификация Java Memory Model (2004), гарантирующая корректную работу volatile. Instruction Reordering — процессор/компилятор меняет порядок инструкций для оптимизации.

Java Memory Model Deep Dive

Instruction Reordering:

Без volatile JIT/процессор может сделать:

Thread A:
  1. obj = allocate()         // Память выделена, поля = null/0
  2. instance = obj           // Ссылка опубликована! 
  3. invokeConstructor(obj)   // Конструктор выполняется

Thread B (вмешивается после шага 2):
  if (instance != null) {
    instance.doWork();  // Объект ещё не инициализирован!
  }

Memory Barriers от volatile:

volatile запись вставляет:
  StoreStore barrier — все записи ДО volatile завершатся
  StoreLoad barrier — все записи ПОСЛЕ volatile увидят актуальные данные

volatile чтение вставляет:
  LoadLoad barrier — все чтения ПОСЛЕ volatile увидят актуальные данные
  LoadStore barrier — все записи ПОСЛЕ volatile не будут переупорядочены

Happens-Before:

Запись в volatile happens-before чтение из volatile
→ Все записи в конструкторе ВИДНЫ потоку, читающему instance

VarHandle Alternative (Java 9+)

public class VarHandleDCL {
    private static final VarHandle HANDLE = MethodHandles.lookup()
        .findStaticVarHandle(VarHandleDCL.class, "instance", VarHandleDCL.class);
    
    private static VarHandleDCL instance;
    
    public static VarHandleDCL getInstance() {
        // getAcquire — weaker than volatile read
        VarHandleDCL local = (VarHandleDCL) HANDLE.getAcquire();
        if (local == null) {
            synchronized (VarHandleDCL.class) {
                local = (VarHandleDCL) HANDLE.getAcquire();
                if (local == null) {
                    local = new VarHandleDCL();
                    // setRelease — weaker than volatile write
                    HANDLE.setRelease(null, local);
                }
            }
        }
        return local;
    }
}

// getAcquire/setRelease = Ordered Access
// Дешевле volatile, но достаточно для DCL

DCL не только для Singleton

// Кэширование тяжёлых вычислений
public class ExpensiveCalculator {
    private volatile int cachedHash;
    private volatile boolean hashComputed;
    
    public int hashCode() {
        if (!hashComputed) {
            synchronized (this) {
                if (!hashComputed) {
                    cachedHash = computeExpensiveHash();
                    hashComputed = true;
                }
            }
        }
        return cachedHash;
    }
}

// String.hashCode() использует тот же принцип!

Performance Benchmark

10M вызовов getInstance():

Synchronized:           150ms  (lock каждый раз)
DCL без volatile:       12ms   (быстро, но сломано!)
DCL + volatile:         18ms   (volatile read overhead)
DCL + volatile + local: 15ms   (оптимизация)
Bill Pugh:              12ms   (class loading magic)
VarHandle DCL:          13ms   (weaker barriers)

Вывод: Bill Pugh — лучший баланс простоты и скорости

Common Pitfalls

  1. DCL в Java < 5 не работал
    • До JSR-133 (2004) Memory Model была сломана
    • volatile не гарантивал happens-before
  2. Забыли volatile
    • Компилируется, работает в тестах
    • В production → случайные баги
  3. Неправильная локальная переменная
    // ❌ Бессмысленно
    if (instance == null) {
        Singleton local = new Singleton();
        synchronized (...) { instance = local; }
    }
       
    // ✅ Правильно
    Singleton local = instance;  // Читаем СНАЧАЛА
    if (local == null) { ... }
    

Production Experience

Реальный сценарий: DCL баг после 3 лет работы

  • Приложение работало без проблем
  • Миграция на новый сервер (другой CPU) → NPE
  • Причина: ARM процессор → другой reordering
  • Решение: добавили volatile → проблема ушла
  • Урок: DCL без volatile = undefined behavior

Best Practices

  1. volatile ОБЯЗАТЕЛЕН в DCL
  2. Local variable оптимизация для производительности
  3. Bill Pugh предпочтительнее DCL для Singleton
  4. VarHandle (Java 9+) для экстремальной производительности
  5. Не используйте DCL без понимания JMM
  6. DCL полезен для lazy кэширования (не только Singleton)

Резюме для Senior

  • DCL = оптимизация для ленивой инициализации
  • volatile критичен — запрещает Instruction Reordering
  • Memory Barriers: StoreStore/LoadLoad обеспечивают happens-before
  • Local variable: снижает volatile reads на 25%
  • VarHandle: weaker barriers → быстрее volatile
  • До Java 5 DCL не работал (сломанная Memory Model)
  • String.hashCode() использует DCL принцип
  • Без volatile — undefined behavior, особенно на ARM

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

Обязательно знать:

  • DCL — оптимизация: блокировка только при создании объекта, не при каждом вызове
  • volatile ОБЯЗАТЕЛЕН — запрещает instruction reordering (memory → publish → constructor)
  • Memory Barriers: StoreStore/LoadLoad обеспечивают happens-before гарантию
  • Local variable optimization снижает volatile reads на 25%
  • До Java 5 (JSR-133) DCL не работал — Memory Model была сломана
  • DCL применяется не только для Singleton, но и для ленивого кэширования
  • VarHandle (Java 9+): getAcquire/setRelease — дешевле volatile

Частые уточняющие вопросы:

  • Что произойдёт без volatile? — Reordering: поток увидит ссылку до выполнения конструктора → NPE или corrupted state
  • Почему две проверки? — Первая (без sync) для скорости, вторая (в sync) для потокобезопасности
  • DCL только для Singleton? — Нет, для любого lazy кэширования (пример: String.hashCode())
  • Почему Bill Pugh предпочтительнее DCL? — Проще, надёжнее, не требует volatile, JIT оптимизирует

Красные флаги (НЕ говорить):

  • “DCL без volatile работает — я проверял” — undefined behavior, проявляется на других CPU/JDK
  • “Synchronized на методе достаточно быстрый” — 150ms vs 15ms на 10M вызовов
  • “Я не использую volatile, у меня нет проблем” — баг может проявиться при миграции на другой CPU
  • “DCL — это только для Singleton” — применяется для любого lazy инициализации

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

  • [[4. Как реализовать потокобезопасный Singleton]] — DCL как способ реализации
  • [[3. Что такое Singleton]] — общий контекст Singleton
  • [[6. Каковы проблемы с Singleton]] — проблемы Singleton
  • [[2. Какие категории паттернов существуют]] — Creational паттерны
  • [[16. Какие антипаттерны вы знаете]] — антипаттерны проектирования