🧩 Раздел 2 · Вопрос #3

Что такое Singleton

Некоторые объекты в системе должны существовать в единственном числе:


🟢 Junior Level

Singleton (Одиночка) — это порождающий паттерн проектирования, который гарантирует, что у класса есть ровно один экземпляр во время работы программы, и предоставляет к нему глобальную точку доступа.

Зачем он нужен

Некоторые объекты в системе должны существовать в единственном числе:

  • Глобальный логгер приложения.
  • Менеджер конфигурационных настроек (например, файл application.properties).
  • Пул соединений с базой данных или аппаратный драйвер принтера.

Если каждый компонент будет создавать свой экземпляр, это приведет к перерасходу памяти, рассинхронизации состояния и конфликтам при доступе к ресурсам.

Аналогия из жизни: Президент страны или директор компании. В государстве может быть только один действующий президент. В любой момент времени обращение «господин Президент» указывает на одного конкретного человека, а не на толпу двойников.

Как устроен классический Singleton

  1. Приватный конструктор: исключает создание объекта извне через оператор new.
  2. Приватное статическое поле: хранит ссылку на единственный экземпляр.
  3. Публичный статический метод (обычно getInstance()): точка доступа, возвращающая этот экземпляр.
public class ConfigurationManager {
    // 1. Статическое поле хранит единственный экземпляр
    private static final ConfigurationManager INSTANCE = new ConfigurationManager();

    // 2. Приватный конструктор запрещает вызов new ConfigurationManager() извне
    private ConfigurationManager() {
        System.out.println("Конфигурация загружена в память.");
    }

    // 3. Глобальная точка доступа
    public static ConfigurationManager getInstance() {
        return INSTANCE;
    }

    public String getProperty(String key) {
        return "value_for_" + key;
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        ConfigurationManager config1 = ConfigurationManager.getInstance();
        ConfigurationManager config2 = ConfigurationManager.getInstance();

        // Ссылки указывают на один и тот же адрес в куче
        System.out.println(config1 == config2); // true
    }
}

Singleton против статического класса (Static Class / Utility Class)

Критерий Singleton Класс со статическими методами
Реализация интерфейсов Может реализовывать интерфейсы (implements MyService) Не может реализовывать интерфейсы статической логикой
Ленивая загрузка Поддерживает отложенную инициализацию (Lazy loading) Инициализируется сразу при первом обращении к любому члену
Передача в методы Можно передать как объект в параметры метода или конструктора Нельзя передать как объект (нет ссылки на экземпляр)
Полиморфизм / ООП Полноценный объект в куче с полиморфным поведением Процедурный набор функций, ООП отсутствует

🟡 Middle Level

Варианты реализации Singleton в Java

1. Eager Initialization (Ранняя инициализация)

Экземпляр создается загрузчиком классов в момент загрузки класса JVM.

public class EagerSingleton {
    private static final EagerSingleton INSTANCE = new EagerSingleton();
    private EagerSingleton() {}
    public static EagerSingleton getInstance() {
        return INSTANCE;
    }
}
  • Плюсы: Предельная простота, потокобезопасность гарантирована загрузчиком классов JVM.
  • Минусы: Объект создается даже если он никогда не понадобится в текущей сессии (тратит память и ресурсы CPU при старте).

2. Lazy Initialization (Непотокобезопасная)

// ❌ АНТИПАТТЕРН в многопоточной среде!
public class UnsafeLazySingleton {
    private static UnsafeLazySingleton instance;
    private UnsafeLazySingleton() {}

    public static UnsafeLazySingleton getInstance() {
        if (instance == null) {
            instance = new UnsafeLazySingleton(); // Гонка потоков!
        }
        return instance;
    }
}
  • Проблема: Если два потока одновременно зайдут в блок if (instance == null), оба создадут по отдельному объекту. Нарушается инвариант единственности.

3. Synchronized Accessor (Синхронизированный метод)

public class SynchronizedSingleton {
    private static SynchronizedSingleton instance;
    private SynchronizedSingleton() {}

    public static synchronized SynchronizedSingleton getInstance() {
        if (instance == null) {
            instance = new SynchronizedSingleton();
        }
        return instance;
    }
}
  • Плюсы: Потокобезопасно, ленивая инициализация.
  • Минусы: Чудовищные накладные расходы. Метод getInstance() синхронизируется при каждом вызове, даже когда объект уже давно создан. Это создает узкое горлышко (lock contention) в многопоточном hot-path.

4. Double-Checked Locking (DCL с volatile)

Оптимизация, выполняющая синхронизацию только один раз — при создании.

public class DoubleCheckedSingleton {
    // volatile критически необходим для предотвращения переупорядочивания инструкций!
    private static volatile DoubleCheckedSingleton instance;

    private DoubleCheckedSingleton() {}

    public static DoubleCheckedSingleton getInstance() {
        DoubleCheckedSingleton local = instance; // Чтение в локальную переменную (микро-оптимизация)
        if (local == null) {
            synchronized (DoubleCheckedSingleton.class) {
                local = instance;
                if (local == null) {
                    instance = local = new DoubleCheckedSingleton();
                }
            }
        }
        return local;
    }
}

5. Bill Pugh Singleton (Initialization-on-demand Holder)

Элегантный способ ленивой и потокобезопасной инициализации без явных блокировок и synchronized.

public class BillPughSingleton {
    private BillPughSingleton() {}

    // Вложенный static-класс загружается JVM ТОЛЬКО при первом обращении к нему
    private static class Holder {
        private static final BillPughSingleton INSTANCE = new BillPughSingleton();
    }

    public static BillPughSingleton getInstance() {
        return Holder.INSTANCE;
    }
}
  • Механика: Класс BillPughSingleton может быть загружен в JVM, но внутренний класс Holder не загружается в память и не инициализируется до тех пор, пока кто-то не вызовет метод getInstance(). Инициализация статических полей класса гарантированно атомарна и потокобезопасна спецификацией JVM (JLS §12.4.2).

6. Enum Singleton (Рекомендация Джошуа Блоха)

public enum EnumSingleton {
    INSTANCE;

    private int state;

    public void doBusinessLogic() {
        System.out.println("Processing business logic");
    }
}
  • Плюсы: Защищен от атак через рефлексию, сериализацию и множественные ClassLoader. Признан Джошуа Блохом лучшим способом реализации классического Singleton в Java.

Как можно сломать Singleton и как защититься

Вектор атаки Как ломает Singleton Способ защиты
Рефлексия (Reflection API) Constructor.setAccessible(true) позволяет вызвать приватный конструктор и создать второй объект. В конструкторе бросать исключение при повторном вызове, либо использовать enum.
Сериализация (Serialization) При десериализации через ObjectInputStream.readObject() создается новый экземпляр в обход конструктора. Реализовать метод protected Object readResolve() { return getInstance(); } или использовать enum.
Клонирование (Cloning) Если класс наследует Cloneable, вызов super.clone() создает копию объекта. Переопределить clone() и бросать CloneNotSupportedException.
Несколько ClassLoader Разные загрузчики классов в контейнерах (Tomcat, OSGi) загружают класс изолированно, создавая дубликат. Использовать общий ClassLoader родительского уровня или Enum.
// Защита конструктора от Reflection API и сериализации:
public class RobustSingleton implements Serializable {
    private static final long serialVersionUID = 1L;
    private static volatile RobustSingleton instance;

    private RobustSingleton() {
        if (instance != null) {
            throw new IllegalStateException("Экземпляр Singleton уже создан! Рефлексия запрещена.");
        }
    }

    public static RobustSingleton getInstance() {
        // DCL реализация...
        return instance;
    }

    // Защита от создания дубликата при десериализации
    protected Object readResolve() {
        return getInstance();
    }
}

Singleton в Spring Framework

В корпоративных приложениях разработчики практически никогда не пишут Singleton вручную:

@Service // По умолчанию все Spring-бины имеют Scope Singleton
public class OrderService {
    // Spring сам управляет единственным экземпляром внутри ApplicationContext
}

[!IMPORTANT] Принципиальная разница:

  • GoF Singleton: один экземпляр на всю JVM / ClassLoader, жесткая статическая связь (Class.getInstance()).
  • Spring Singleton Scope: один экземпляр на один экземпляр ApplicationContext. При этом сам класс имеет обычный публичный конструктор, легко мокается в unit-тестах и внедряется через Dependency Injection.

🔴 Senior Level

Проблема Double-Checked Locking и модель памяти Java (JMM)

Почему в DCL поле instance обязано быть объявлено со спецификатором volatile?

Операция конструирования объекта instance = new DoubleCheckedSingleton() в байткоде JVM не является атомарной. Она состоит из трех фундаментальных шагов:

  1. Выделение памяти под объект в куче (allocate).
  2. Вызов конструктора объекта (invokespecial <init>) — инициализация полей.
  3. Запись адреса выделенной памяти в статическую ссылку instance.
[Порядок без volatile]:
1. allocate()
3. instance = addr;  <-- ПЕРЕУПОРЯДОЧИВАНИЕ (Instruction Reordering)! Ссылка уже не null!
2. ctor()            <-- Конструктор еще не закончил работу!

Поток 2 вызывает getInstance():
-> видит (instance != null)
-> возвращает ссылку на ПОЛУИНИЦИАЛИЗИРОВАННЫЙ ОБЪЕКТ!
-> NPE или повреждение данных в бизнес-логике!

Роль volatile: Спецификация JMM (JSR-133, начиная с Java 5) гарантирует, что запись в volatile-переменную формирует отношение happens-before перед любым последующим чтением этой переменной. Компилятор и CPU вставляют аппаратный барьер памяти (Memory Barrier / Fence), запрещая вынос записи ссылки (3) перед вызовом конструктора (2).

Зачем нужна локальная переменная DoubleCheckedSingleton local = instance;?

Чтение volatile-переменной обходится дороже обычного чтения из регистров, так как требует обращения к L1/L2/L3 кэшу с барьером когерентности кэшей CPU. Использование локальной переменной гарантирует, что в случае, если объект уже инициализирован (99.999% вызовов), чтение из volatile поля происходит ровно один раз.


Singleton как архитектурный антипаттерн

В чистой архитектуре классический GoF Singleton часто называют антипаттерном по следующим причинам:

  1. Нарушение Single Responsibility Principle (SRP): Класс не только выполняет свою бизнес-логику, но и принудительно контролирует свой жизненный цикл и многопоточный доступ.
  2. Скрытые зависимости (Hidden Dependencies) и нарушение DIP: Когда класс внутри своих методов вызывает DatabasePool.getInstance(), эта зависимость скрыта от внешнего мира. Сигнатура конструктора класса лжет о том, что ему требуется для работы.
  3. Убийца модульного тестирования (Unit Testing):
    • Невозможно заменить зависимость моком (Mockito.mock()), так как статический метод невозможно переопределить стандартными средствами.
    • Singleton накапливает глобальное состояние. Тест A модифицирует состояние синглтона, после чего независимый тест B падает из-за эффекта побочных действий (State Bleeding).
  4. Утечки памяти в серверах приложений (Metaspace Leaks): В серверах приложений (Tomcat, WildFly) каждое веб-приложение загружается собственным WebappClassLoader. Если класс Singleton загружен системным загрузчиком, а ссылка на него хранит ресурсы веб-приложения, сборщик мусора не сможет выгрузить ClassLoader приложения при горячем перезапуске (Redeploy). Это приводит к java.lang.OutOfMemoryError: Metaspace.

Singleton в распределенной архитектуре (Distributed Systems)

В современной микросервисной архитектуре понятие «один экземпляр на систему» теряет физический смысл на уровне JVM:

                [Клиентские запросы / Балансировщик]
                                │
        ┌───────────────────────┼───────────────────────┐
        ▼                       ▼                       ▼
 ┌──────────────┐        ┌──────────────┐        ┌──────────────┐
 │ Pod 1 (JVM)  │        │ Pod 2 (JVM)  │        │ Pod 3 (JVM)  │
 │  Singleton   │        │  Singleton   │        │  Singleton   │
 └──────────────┘        └──────────────┘        └──────────────┘
        │                       │                       │
        └───────────────────────┼───────────────────────┘
                                ▼
         [Распределенный координатор: Redis / ZooKeeper]
  • Если сервис масштабирован в Kubernetes до 5 реплик, в кластере будет 5 независимых экземпляров Singleton.
  • Если требуется глобальная уникальность выполнения действия (например, запуск фоновой генерации отчетов раз в сутки), GoF Singleton бесполезен. Решение — распределенные блокировки (Distributed Lock):
    • ShedLock для периодических задач.
    • Redlock (Redis) или ZooKeeper Recipes для лидерства (Leader Election).

🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«Singleton гарантирует существование единственного экземпляра класса в JVM и предоставляет к нему глобальный доступ через приватный конструктор и статический метод getInstance(). Лучший потокобезопасный способ с ленивой инициализацией без синхронизации — Bill Pugh Singleton (Holder class), а наиболее надежный от атак рефлексии и сериализации — Enum Singleton. Однако в промышленной разработке классический Singleton считается антипаттерном из-за скрытых зависимостей и проблем с unit-тестами. В Spring его заменяет Singleton Bean Scope, управляемый IoC-контейнером через Dependency Injection».


3–4 каверзных вопроса с ответами

  1. Почему реализация DCL без volatile не работает на многоядерных процессорах?
    • Ответ: Из-за спекулятивного переупорядочивания инструкций компилятором и процессором. Выделение памяти под объект и присвоение ссылки могут произойти до того, как завершится выполнение конструктора объекта. Второй поток увидит ненулевую ссылку и начнет работать с наполовину созданным объектом, получив неконсистентные данные.
  2. Каким образом Enum Singleton защищен от создания второго экземпляра через Reflection API?
    • Ответ: Защита реализована на уровне спецификации JVM и метода Constructor.newInstance(). Если рефлексивный вызов пытается создать класс с модификатором ENUM, метод newInstance() выбрасывает безусловное исключение IllegalArgumentException("Cannot reflectively create enum objects").
  3. Что произойдет с Singleton при десериализации и как это исправить?
    • Ответ: Механизм десериализации JVM создает новый объект в обход стандартных конструкторов. Чтобы вернуть ссылку на существующий синглтон, класс обязан определить специальный приватный или защищенный метод readResolve(), возвращающий getInstance().
  4. Чем отличается Singleton в понимании GoF от Singleton в Spring Framework?
    • Ответ: Singleton GoF гарантирует один экземпляр на весь ClassLoader за счет статического поля и приватного конструктора (жесткая связь). В Spring бин со scope singleton создается в единственном числе в рамках одного ApplicationContext, при этом сам класс остается обычным POJO с открытым конструктором, что обеспечивает свободное тестирование и слабую связность через DI.

Типичные ошибки и Red Flags

  • 🚩 Использование наивной ленивой инициализации без синхронизации в многопоточной среде — прямой путь к race condition.
  • 🚩 Отсутствие volatile в реализации Double-Checked Locking — непонимание принципов Java Memory Model и механизма instruction reordering.
  • 🚩 Хранение мутабельного разделяемого состояния в Singleton без потокобезопасных структур (например, обычный HashMap или примитивный счетчик int), что приводит к порче данных и ConcurrentModificationException.
  • 🚩 Написание синглтонов вручную в кодовой базе на Spring Boot вместо использования штатного механизма бинов и Dependency Injection.
  • 🚩 Уверенность, что Singleton решает проблему синхронизации в распределенном кластере микросервисов.

Связанные темы