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

Потокобезопасный Singleton с double-checked locking

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

Условие

Реализуйте потокобезопасный Singleton с использованием double-checked locking.

Код с ошибкой

public class Singleton {
    private static Singleton instance;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Вопросы

  1. Есть ли проблема в этом коде?
  2. Как её исправить?
  3. Почему нужен volatile?
  4. Предложите альтернативные решения.

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

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

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

Потокобезопасный Singleton с double-checked locking

1. Есть ли проблема в исходном коде?

ДА, ЕСТЬ СЕРЬЁЗНАЯ ПРОБЛЕМА! Код содержит классическую ошибку инициализации, связанную с переупорядочением инструкций компилятором и процессором.

Проблема в следующем:

При выполнении instance = new Singleton(); происходит несколько шагов:

  1. Выделение памяти для объекта
  2. Инициализация полей объекта (конструктор)
  3. Присваивание ссылки переменной instance

Однако из-за оптимизаций компилятора и CPU, шаги 2 и 3 могут быть переупорядочены. Это означает, что instance может быть установлена на объект ДО завершения его инициализации.

Сценарий критической ошибки:

Поток 1:
  1. Вошёл в synchronized блок
  2. Выделил память для объекта
  3. Установил instance (ОШИБКА: ещё не инициализирован!)
  4. Инициализирует объект

Поток 2:
  1. Проверка первого if: instance != null (ИСТИНА!)
  2. Возвращает недоинициализированный объект
  3. Попытка использовать объект -> NullPointerException или другие ошибки

2. Правильное исправление с volatile

Использование ключевого слова volatile обязательно:

public class Singleton {
    // КЛЮЧЕВОЙ МОМЕНТ: volatile предотвращает переупорядочение
    private static volatile Singleton instance;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) { // Первая проверка (без блокировки)
            synchronized (Singleton.class) {
                if (instance == null) { // Вторая проверка (с блокировкой)
                    instance = new Singleton(); // Теперь безопасно!
                }
            }
        }
        return instance;
    }
}

3. Почему нужен volatile?

volative гарантирует следующее:

1. Видимость памяти (Memory Visibility)

Без volatile, другие потоки могут видеть instance = null, даже если он был уже инициализирован.

// БЕЗ VOLATILE - ОПАСНО
Thread 1:
    instance = new Singleton(); // Может остаться в кэше CPU

Thread 2:
    if (instance == null) { // Может видеть null из своего кэша!
        // ...
    }

2. Предотвращение переупорядочения (Reordering Prevention)

Учитывая Java Memory Model, volatile блокирует переупорядочение инструкций вокруг присваивания.

БЕЗ VOLATILE:
    new Singleton() может выполниться как:
    1. Выделить память
    2. Установить instance (РАНЬШЕ инициализации!)
    3. Инициализировать объект

С VOLATILE:
    Гарантирует правильный порядок:
    1. Выделить память
    2. Инициализировать объект
    3. Установить instance (и сделать видимым)

Детальное объяснение с примером:

public class SingletonWithoutVolatile {
    private static Singleton instance; // БЕЗ volatile
    private String name;
    
    private SingletonWithoutVolatile() {
        this.name = "Initialized"; // Может выполниться ПОСЛЕ присваивания instance
    }
    
    public static SingletonWithoutVolatile getInstance() {
        if (instance == null) {
            synchronized (SingletonWithoutVolatile.class) {
                if (instance == null) {
                    instance = new SingletonWithoutVolatile();
                }
            }
        }
        return instance; // Может вернуть объект с name = null!
    }
}

public class SingletonWithVolatile {
    private static volatile Singleton instance; // С volatile
    private String name;
    
    private SingletonWithVolatile() {
        this.name = "Initialized"; // Гарантировано завершится ДО присваивания
    }
    
    public static SingletonWithVolatile getInstance() {
        if (instance == null) {
            synchronized (SingletonWithVolatile.class) {
                if (instance == null) {
                    instance = new SingletonWithVolatile();
                }
            }
        }
        return instance; // Гарантированно инициализированный объект
    }
}

4. Альтернативные решения

Альтернатива 1: Eager Initialization (Инициализация при загрузке класса)

Преимущество: Самое простое, абсолютно безопасное

public class SingletonEager {
    // Инициализируется при загрузке класса
    public static final SingletonEager INSTANCE = new SingletonEager();
    
    private SingletonEager() {}
}

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

Минус: INSTANCE создаётся сразу, даже если не использется

Альтернатива 2: Lazy Initialization с Static Inner Class

Рекомендуется! Комбинирует ленивую инициализацию и безопасность

public class SingletonLazy {
    private SingletonLazy() {}
    
    // Инициализируется только при первом обращении
    private static class SingletonHolder {
        private static final SingletonLazy INSTANCE = new SingletonLazy();
    }
    
    public static SingletonLazy getInstance() {
        return SingletonHolder.INSTANCE; // Загрузка класса-holder происходит в синхронизированном блоке
    }
}

Почему это работает безопасно:

  • Class loader синхронизирует инициализацию классов
  • SingletonHolder создаётся только при первом обращении к getInstance()
  • Полностью безопасно и эффективно

Альтернатива 3: Enum Singleton (Лучший способ!)

Рекомендуется больше всего! Защищает от рефлексии и сериализации

public enum SingletonEnum {
    INSTANCE; // Единственный экземпляр
    
    private String name = "Initialized";
    
    public String getName() {
        return name;
    }
}

// Использование
public class Main {
    public static void main(String[] args) {
        SingletonEnum singleton = SingletonEnum.INSTANCE;
        System.out.println(singleton.getName());
    }
}

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

  • Абсолютно потокобезопасен (гарантировано JVM)
  • Защита от рефлексии (нельзя создать через reflection)
  • Защита от сериализации/десериализации
  • Простой синтаксис
  • Поддерживает наследование и методы

Пример, почему Enum лучше:

public class DangerousReflection {
    public static void main(String[] args) throws Exception {
        // ❌ Можем создать второй экземпляр обычного Singleton через рефлексию
        Constructor<SingletonLazy> constructor = SingletonLazy.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        SingletonLazy fake = constructor.newInstance(); // Второй экземпляр!
        
        // ✅ С Enum это невозможно
        // Constructor<SingletonEnum> constructor = SingletonEnum.class.getDeclaredConstructor();
        // java.lang.IllegalArgumentException: Cannot reflectively create enum objects
    }
}

Альтернатива 4: Spring's @Component Singleton

В Spring приложениях часто используется встроенный механизм:

@Component
public class SingletonService {
    // Spring гарантирует, что будет только один экземпляр (scope = singleton по умолчанию)
    
    public void doSomething() {
        System.out.println("Работа синглтона");
    }
}

@Service
public class SingletonDao {
    // @Service является @Component, поэтому также singleton
}

Таблица сравнения всех вариантов

ПодходПотокобезопасностьЛенивая инициализацияРефлексия защитаСложностьРекомендация
Double-checked locking⚠️ С volatile✓ Да✗ НетВысокаяНе рекомендуется
Eager initialization✓ Да✗ Нет✗ НетНизкаяЕсли нужна простота
Static inner class✓ Да✓ Да✗ НетСредняяХороший выбор
Enum singleton✓ Да✗ Нет*✓ ДаНизкаяЛУЧШИЙ ВЫБОР
Spring @Component✓ Да✓ Да**N/AНизкаяДля Spring приложений

*Enum создаётся при загрузке класса, но это обычно не критично **Spring создаёт lazy по умолчанию

Заключение

  1. Исходный код ОПАСЕН — не используй без volatile
  2. С volatile оно работает, но сложно для понимания
  3. Лучший выбор — Enum, если нет особых требований
  4. Static inner class — хороший компромисс между простотой и ленивой инициализацией
  5. В Spring — используй @Component и не парься о синглтоне