Паттерн Singleton в Java
Реализация паттерна Singleton в Java: eager, lazy и enum-подходы с учётом потокобезопасности.
Паттерн Singleton ограничивает класс единственным экземпляром и предоставляет к нему глобальную точку доступа. Фасад для логирования, реестр конфигурации, кэш в памяти процесса — всё это типичные кандидаты. В Java паттерн имеет несколько стандартных форм, каждая из которых по-своему решает вопросы потокобезопасности и ленивой инициализации. Есть и один подход, который незаметно избавляет от большинства проблем.
Сразу предостережение. «Singleton» печально известен тем, что его легко применять там, где не надо — каждый синглтон, по сути, является глобальной переменной, а глобальные переменные делают код сложнее для тестирования и понимания. Большинство современных Java-приложений предпочитают внедрение зависимостей: фреймворк сам создаёт единственный экземпляр и передаёт его нужным компонентам, и никому из них не приходится вызывать Foo.getInstance(). Прибегайте к явному синглтону лишь тогда, когда DI недоступен или действительно избыточен.
Что нужно каждому синглтону
Любая реализация синглтона включает три элемента:
- Приватный конструктор, чтобы никто за пределами класса не мог вызвать
new. - Приватное статическое поле, хранящее единственный экземпляр.
- Публичный статический метод доступа, возвращающий этот экземпляр.
Различия между реализациями в основном касаются момента создания экземпляра и способа обеспечения потокобезопасности доступа.
Ранняя инициализация
Простейшая форма создаёт экземпляр при загрузке класса:
public final class Eager {
private static final Eager INSTANCE = new Eager();
private Eager() {}
public static Eager getInstance() { return INSTANCE; }
}Инициализация класса в JVM гарантированно потокобезопасна и выполняется ровно один раз, поэтому INSTANCE устанавливается безопасно, без блокировок. Используйте этот подход, когда:
- Создание объекта дешёво, или вы уверены, что экземпляр всегда понадобится.
- Вас устраивает оплата этой стоимости в момент загрузки класса.
Ленивая инициализация с двойной проверкой блокировки
Если создание объекта дорогостоящее и экземпляр может не понадобиться, его инициализацию можно отложить. Наивная ленивая версия не является потокобезопасной; правильная использует двойную проверку блокировки с volatile:
public final class Lazy {
private static volatile Lazy instance;
private Lazy() {}
public static Lazy getInstance() {
Lazy local = instance; // local read avoids re-reading the volatile field
if (local == null) {
synchronized (Lazy.class) {
local = instance;
if (local == null) {
local = new Lazy();
instance = local;
}
}
}
return local;
}
}volatile необходим — без него другой поток может увидеть поле установленным в ненулевую ссылку, хотя конструктор ещё не завершился. Громоздко, но корректно.
Holder-идиома (инициализация по требованию)
Самая чистая ленивая форма использует приватный вложенный класс. JVM загружает holder только при первом вызове getInstance(), поэтому работа откладывается — а гарантии JVM по инициализации класса обеспечивают потокобезопасность:
public final class Holder {
private Holder() {}
private static class H {
private static final Holder INSTANCE = new Holder();
}
public static Holder getInstance() { return H.INSTANCE; }
}Никакого synchronized, никакого volatile, никакой двойной проверки блокировки — и при этом ленивая инициализация. Именно эту ленивую форму стоит использовать в большинстве случаев.
Синглтон на основе enum
Самый короткий корректный синглтон в Java — это enum с одной константой:
public enum Config {
INSTANCE;
public String get(String key) { /* ... */ }
}Config.INSTANCE и есть синглтон. Рекомендация Джошуа Блоха (Effective Java, Item 3) состоит в том, что это лучшая реализация синглтона, поскольку JVM гарантирует:
- Ровно один экземпляр. Enum-ы создаются ровно один раз на JVM.
- Потокобезопасное создание. Те же гарантии инициализации класса, что и в holder-паттерне.
- Защита от Reflection. Reflection не может вызвать конструктор enum; обычные синглтоны можно обойти через
Constructor.setAccessible(true). - Защита от сериализации. Десериализация обычного синглтона может незаметно породить второй экземпляр, если не обработать
readResolve. Enum-ы от этого защищены.
Единственное, чего нельзя сделать с enum, — наследовать другой класс, так как enum-ы неявно расширяют java.lang.Enum. Реализовывать интерфейсы по-прежнему можно.
Что может сломать наивные синглтоны
Обратите внимание на следующие ситуации — именно поэтому «просто используй статическое поле» не всегда достаточно:
- Несколько загрузчиков классов. Синглтон существует в одном экземпляре на загрузчик классов, а не на JVM. В контейнерах, изолирующих приложения с помощью собственных загрузчиков, у одного и того же класса может быть несколько «единственных» экземпляров.
- Reflection.
setAccessible(true)вместе сConstructor.newInstance()позволяют создать второй экземпляр любого синглтона, не основанного на enum. Если это реальная проблема, защитите конструктор с помощьюif (INSTANCE != null) throw .... - Сериализация. Сериализуемому синглтону нужен метод
private Object readResolve() { return INSTANCE; }, чтобы избежать создания второй копии при каждой десериализации. - Тестирование. Синглтоны печально трудно подменять или сбрасывать. Предпочитайте внедрение зависимостей в коде, который планируете покрывать юнит-тестами.
Работающий пример
Что дальше
На этом завершается часть 6 и весь обзор объектно-ориентированного Java — от классов, наследования и полиморфизма до интерфейсов, enum-ов, records, запечатанных иерархий и методов, которые наследует каждый объект. В следующей части рассматривается организация Java-кода: пространства имён, структура файловой системы, которая их отражает, и механизм import, позволяющий использовать типы из других мест. Продолжайте с пакетами Java.