← Назад к вопросам

Для чего нужен Double Check?

2.0 Middle🔥 121 комментариев
#Многопоточность

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI28 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Для чего нужен Double Check?

Double-checked locking (двойная проверка) — это паттерн оптимизации для ленивой инициализации объектов в многопоточной среде. Он решает проблему между производительностью и потокобезопасностью, минимизируя затраты на синхронизацию после первого создания объекта.

Основная проблема

Рассмотрим наивный подход к ленивой инициализации:

public class Logger {
    private static Logger instance;
    
    // ❌ НЕПРАВИЛЬНО - не потокобезопасно
    public static Logger getInstance() {
        if (instance == null) {  // Потокобезопасность нарушена!
            instance = new Logger();
        }
        return instance;
    }
}

// Проблема в многопоточной среде:
// Поток 1: Проверяет instance == null -> true, начинает создание
// Поток 2: Проверяет instance == null -> true, начинает создание
// Результат: Два экземпляра Logger вместо одного!

Решение 1: Синхронизация всего метода (неоптимально)

public class Logger {
    private static Logger instance;
    
    // ✅ Потокобезопасно, но МЕДЛЕННО
    public synchronized static Logger getInstance() {
        if (instance == null) {
            instance = new Logger();
        }
        return instance;
    }
}

// Проблема: synchronized блокирует КАЖДЫЙ вызов!
// После первой инициализации:
// - instance != null
// - Но все равно нужно взять lock для проверки
// - В высоконагруженной системе это огромный overhead

// Performance impact:
// 100 потоков вызывают getInstance():
// 1 поток: создает объект, берет lock
// 99 потоков: ждут lock, проверяют instance != null, отпускают lock
// Результат: конкуренция (contention) на lock

Решение 2: Double-checked locking (оптимально)

Это двойная проверка: без lock и с lock:

public class Logger {
    private static volatile Logger instance;  // ✅ VOLATILE - критично!
    
    // ✅ ПРАВИЛЬНО - Double-checked locking
    public static Logger getInstance() {
        // Первая проверка БЕЗ lock (быстро)
        if (instance == null) {
            synchronized (Logger.class) {  // Lock только при необходимости
                // Вторая проверка С lock (потокобезопасно)
                if (instance == null) {
                    instance = new Logger();
                }
            }
        }
        return instance;
    }
}

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

Сценарий 1: instance уже инициализирован
┌─────────────────────────────────────────────┐
│ Поток A: instance = Logger(...)             │ (только ОДИН раз)
├─────────────────────────────────────────────┤
│ Поток B: if (instance == null) → FALSE      │ (без lock!)
│ Поток C: if (instance == null) → FALSE      │ (без lock!)
│ Поток D: if (instance == null) → FALSE      │ (без lock!)
└─────────────────────────────────────────────┘
Все читают уже инициализированный объект БЕЗ затрат на synchronization

Сценарий 2: instance ещё не инициализирован (только один раз)
┌─────────────────────────────────────────────┐
│ Поток A: if (instance == null) → TRUE       │
│ Поток A: synchronized(this) { берет lock }  │
│ Поток A: if (instance == null) → TRUE       │ (вторая проверка)
│ Поток A: instance = new Logger()            │
│ Поток A: { отпускает lock }                 │
├─────────────────────────────────────────────┤
│ Поток B: if (instance == null) → FALSE      │ (уже создан)
│ Поток B: возвращает объект БЕЗ lock        │
└─────────────────────────────────────────────┘

Почему volatile НЕОБХОДИМ?

Вот самый важный момент! Без volatile double-checked locking НЕ работает надежно:

// ❌ ОПАСНО - БЕЗ volatile
public class UnsafeLogger {
    private static Logger instance;  // БЕЗ volatile!
    
    public static Logger getInstance() {
        if (instance == null) {
            synchronized (UnsafeLogger.class) {
                if (instance == null) {
                    // Проблема с переупорядочением инструкций (instruction reordering)
                    Logger temp = new Logger();  // 1. Создание объекта
                    instance = temp;              // 2. Присвоение ссылки
                    
                    // JVM может переупорядочить как:
                    // 1. instance = null (выделение памяти)
                    // 2. (инициализация fields)
                    // 3. instance указывает на объект
                    
                    // Поток B может видеть instance != null, но объект еще не инициализирован!
                }
            }
        }
        return instance;  // Может быть не полностью инициализирован!
    }
}

// ✅ ПРАВИЛЬНО - С volatile
public class SafeLogger {
    private static volatile Logger instance;  // volatile гарантирует порядок!
    
    public static Logger getInstance() {
        if (instance == null) {
            synchronized (SafeLogger.class) {
                if (instance == null) {
                    instance = new Logger();
                    // volatile гарантирует, что все изменения видны другим потокам
                }
            }
        }
        return instance;
    }
}

Полный пример

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class DatabaseConnection {
    private static volatile DatabaseConnection instance;
    private String connectionString;
    
    // Приватный конструктор (предотвращает прямое инстанцирование)
    private DatabaseConnection() {
        // Дорогостоящая инициализация
        this.connectionString = "jdbc:mysql://localhost:3306/mydb";
        System.out.println("Database connection initialized");
    }
    
    // Double-checked locking
    public static DatabaseConnection getInstance() {
        // Первая проверка (без lock)
        if (instance == null) {
            // Вторая проверка (с lock)
            synchronized (DatabaseConnection.class) {
                if (instance == null) {
                    instance = new DatabaseConnection();
                }
            }
        }
        return instance;
    }
    
    public void executeQuery(String sql) {
        System.out.println("Executing: " + sql);
    }
    
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(10);
        
        // 10 потоков пытаются получить Singleton
        for (int i = 0; i < 10; i++) {
            executor.submit(() -> {
                try {
                    DatabaseConnection db = DatabaseConnection.getInstance();
                    System.out.println("Got instance: " + System.identityHashCode(db));
                    db.executeQuery("SELECT * FROM users");
                } finally {
                    latch.countDown();
                }
            });
        }
        
        latch.await();
        executor.shutdown();
        
        // Все потоки получат ТОТ ЖЕ экземпляр
    }
}

// Вывод:
// Database connection initialized  (только ОДИН раз)
// Got instance: <hash1>
// Got instance: <hash1>
// Got instance: <hash1>
// ... (все имеют одинаковый hash)

Производительность: Сравнение подходов

public class PerformanceComparison {
    static class NaiveApproach {
        // ❌ Не потокобезопасно
        private static Object instance;
        public static Object get() {
            if (instance == null) {
                instance = new Object();
            }
            return instance;
        }
    }
    
    static class SynchronizedMethod {
        // ✅ Потокобезопасно, но медленно
        private static Object instance;
        public static synchronized Object get() {
            if (instance == null) {
                instance = new Object();
            }
            return instance;
        }
    }
    
    static class DoubleChecked {
        // ✅ Потокобезопасно и быстро
        private static volatile Object instance;
        public static Object get() {
            if (instance == null) {
                synchronized (DoubleChecked.class) {
                    if (instance == null) {
                        instance = new Object();
                    }
                }
            }
            return instance;
        }
    }
    
    // Бенчмарк (после инициализации)
    public static void benchmark() throws Exception {
        // Подготовка
        SynchronizedMethod.get();
        DoubleChecked.get();
        
        // Тест 1: Synchronized method (100 млн вызовов)
        long start = System.nanoTime();
        for (int i = 0; i < 100_000_000; i++) {
            SynchronizedMethod.get();
        }
        long syncTime = (System.nanoTime() - start) / 1_000_000;
        
        // Тест 2: Double-checked locking (100 млн вызовов)
        start = System.nanoTime();
        for (int i = 0; i < 100_000_000; i++) {
            DoubleChecked.get();
        }
        long doubleCheckedTime = (System.nanoTime() - start) / 1_000_000;
        
        System.out.println("Synchronized method: " + syncTime + "ms");
        System.out.println("Double-checked: " + doubleCheckedTime + "ms");
        System.out.println("Speedup: " + (double) syncTime / doubleCheckedTime + "x");
    }
}

// Результаты (примерные):
// Synchronized method: 8000ms
// Double-checked: 100ms
// Speedup: 80x

Современные альтернативы

1. Enum Singleton (рекомендуется)

public enum Logger {
    INSTANCE;
    
    Logger() {
        System.out.println("Logger initialized");
        // инициализация
    }
    
    public void log(String message) {
        System.out.println(message);
    }
}

// Использование
public class Main {
    public static void main(String[] args) {
        Logger.INSTANCE.log("Hello");
    }
}

Преимущества:

  • Потокобезопасно из коробки
  • Сериализация работает правильно
  • Отражение не может нарушить Singleton
  • Никакого boilerplate кода

2. Holder Pattern

public class Logger {
    private Logger() {}
    
    // Инициализируется только при первом доступе
    private static class LoggerHolder {
        static final Logger INSTANCE = new Logger();
    }
    
    public static Logger getInstance() {
        return LoggerHolder.INSTANCE;
    }
}

3. Spring (@Bean с Singleton scope)

@Configuration
public class AppConfig {
    @Bean
    public Logger logger() {
        return new Logger();
    }
}

Когда использовать Double-checked Locking

  1. Ленивая инициализация критична — экономия ресурсов при старте
  2. Высоконагруженные системы — тысячи вызовов getInstance()
  3. Кэширование дорогостоящих объектов — DB connections, thread pools
  4. Legacy code без фреймворков — Spring, Guice не доступны

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

  1. В Spring приложениях — используй @Singleton бины
  2. Когда ранняя инициализация ОК — просто создай static field
  3. Простые Singleton — используй Enum
  4. Для классов с потокобезопасными операциями — может быть избыточно

Заключение

Double-checked locking — это оптимизация производительности для ленивой инициализации в многопоточной среде. Ключевые моменты:

  1. Две проверки: без lock (быстро) и с lock (безопасно)
  2. volatile ОБЯЗАТЕЛЕН для правильной работы
  3. Производительность улучшается в 10-100 раз по сравнению с полной синхронизацией
  4. Современные альтернативы лучше: Enum, Holder Pattern, Spring
  5. Используй только если знаешь, зачем тебе это нужно

В 99% случаев в современной Java разработке существуют лучшие решения. Но понимание double-checked locking глубоко показывает твое знание многопоточности и Java memory model.

Для чего нужен Double Check? | PrepBro