Що таке double-checked locking?
4. VarHandle (Java 9+) для екстремальної продуктивності 5. Не використовуйте DCL без розуміння JMM 6. DCL корисний для ледачого кешування (не тільки Singleton)
🟢 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;
}
}
Як працює:
- Перший потік: instance = null → synchronized → створює
- Другий потік: instance = null → чекає блокування → бачить, що вже створено
- Третій потік: instance != null → одразу повертає (без synchronized!)
Навіщо volatile: Без нього інший потік може отримати частково створений об’єкт.
Коли НЕ використовувати DCL
- Не розумієте JMM — використовуйте Bill Pugh Singleton
- Bill Pugh достатньо — простіший і надійніший
- Не 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
- DCL у Java < 5 не працював
- До JSR-133 (2004) Memory Model була зламана
- volatile не гарантував happens-before
- Забули volatile
- Компілюється, працює в тестах
- У production → випадкові баги
- Неправильна локальна змінна
// ❌ Беззмістовно 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
- volatile ОБОВ’ЯЗКОВИЙ у DCL
- Local variable оптимізація для продуктивності
- Bill Pugh пріоритетніший за DCL для Singleton
- VarHandle (Java 9+) для екстремальної продуктивності
- Не використовуйте DCL без розуміння JMM
- 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. Які антипатерни ви знаєте]] — антипатерни проектування