Питання 5 · Розділ 2

Що таке double-checked locking?

4. VarHandle (Java 9+) для екстремальної продуктивності 5. Не використовуйте DCL без розуміння JMM 6. DCL корисний для ледачого кешування (не тільки 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 корисний для ледачого кешування (не тільки 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? — Ні, для будь-якого ледачого кешування (приклад: String.hashCode())
  • Чому Bill Pugh пріоритетніший за DCL? — Простіший, надійніший, не потребує volatile, JIT оптимізує

Червоні прапорці (НЕ говорити):

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

Пов’язані теми:

  • [[4. Як реалізувати потокобезпечний Singleton]] — DCL як спосіб реалізації
  • [[3. Що таке Singleton]] — загальний контекст Singleton
  • [[6. Які проблеми є у Singleton]] — проблеми Singleton
  • [[2. Які категорії патернів існують]] — Creational патерни
  • [[16. Які антипатерни ви знаєте]] — антипатерни проектування