Что такое 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
Проверка условия выполняется дважды:
- Первая проверка (без блокировки): если объект уже создан, сразу возвращаем его без захвата монитора.
- Вторая проверка (внутри
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):
- Поле
hash— это примитивint(32 бита). Запись 32-битного примитива в Java атомарна (нет разрыва слова / word tearing). - Вычисление детерминировано: если два потока одновременно посчитают хэш, они запишут абсолютно одинаковое число.
- Это допустимо только для иммутабельных примитивов, но категорически неприменимо для ссылок на составные объекты!
🎯 Шпаргалка для интервью
30-секундный ответ (Elevator Pitch)
«Double-Checked Locking (DCL) — это идиома многопоточного программирования для ленивой инициализации объектов с минимальным оверхедом на синхронизацию. Первая проверка выполняется без блокировок ради высокой производительности в hot-path, а вторая — внутри блока
synchronizedдля предотвращения дублирования объекта параллельными потоками. Модификаторvolatileкритически обязателен, так как без него JIT-компилятор и CPU могут переупорядочить инструкции (выделение памяти → публикация ссылки → выполнение конструктора), в результате чего параллельный поток получит ссылку на полуинициализированный объект».
3–4 каверзных вопроса с ответами
- Что произойдет, если в DCL убрать
volatile? Будет ли код компилироваться и когда он упадет?- Ответ: Код успешно скомпилируется и даже пройдет большинство тестов. Однако в многопоточной среде произойдет состояние гонки из-за Out-of-Order Execution: поток-читатель на первой проверке увидит
instance != null, когда конструктор еще не завершил инициализацию полей. Это приведет к непредсказуемымNullPointerExceptionили чтению поврежденного состояния. На архитектуре ARM64 (например, AWS Graviton) сбой воспроизводится мгновенно из-за слабой модели памяти.
- Ответ: Код успешно скомпилируется и даже пройдет большинство тестов. Однако в многопоточной среде произойдет состояние гонки из-за Out-of-Order Execution: поток-читатель на первой проверке увидит
- Зачем сохранять
volatile-переменную в локальную переменнуюlocal = instance?- Ответ: Это оптимизация локальной переменной (Local Variable Optimization). Чтение
volatile-поля требует соблюдения когерентности процессорных кэшей и стоит дороже чтения из стека. Сохранение в локальную переменную сокращает количество чтенийvolatileдо одного в 99.9% сценариев, снижая оверхед на 25–30%.
- Ответ: Это оптимизация локальной переменной (Local Variable Optimization). Чтение
- Почему в JDK до версии 1.5 паттерн DCL не работал даже с ключевым словом
volatile?- Ответ: До спецификации JSR-133 (Java 5) в Java Memory Model отсутствовали строгие правила для
volatileкасательно переупорядочивания с обычными переменными. Барьеры памяти гарантировали только синхронизацию кэша, но не предотвращали вынос записи ссылки за пределы выполнения конструктора.
- Ответ: До спецификации JSR-133 (Java 5) в Java Memory Model отсутствовали строгие правила для
- Почему
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-областью видимости).
Связанные темы
- Как реализовать потокобезопасный Singleton — обзор всех способов реализации
- Что такое Singleton — фундаментальные принципы паттерна
- Каковы проблемы с Singleton — архитектурные антипаттерны
- Какие категории паттернов существуют — порождающие шаблоны проектирования
- Какие антипаттерны вы знаете — типичные ошибки в архитектуре ПО