Привет! Сегодня будем подробно разбираться в разных паттернах проектирования, и начнем с шаблона Singleton, который еще называют “одиночка”. Если коротко: синглтон это класс, у которого во всей программе существует ровно один объект. Держится паттерн на двух вещах: приватный конструктор не дает создать объект снаружи, а статический метод getInstance() отдает всем один и тот же экземпляр. Реализаций у синглтона несколько, и различаются они только тем, в какой момент создается объект и что происходит, когда за ним приходят сразу несколько потоков. Давай вспомним: что мы знаем о шаблонах проектирования в целом? Шаблоны проектирования это лучшие практики, следуя которым можно решить ряд известных проблем. Шаблоны проектирования как правило не привязаны к какому-либо языку программирования. Воспринимай их как свод рекомендаций, следуя которым можно избежать ошибок и не изобретать свой велосипед.

Кратко

  • Синглтон гарантирует, что у класса будет ровно один объект, и дает к нему глобальную точку доступа.
  • Механика везде одна: приватный конструктор плюс публичный статический метод getInstance().
  • Реализации отличаются двумя вещами: когда создается объект, сразу или при первом обращении, и что происходит при доступе из нескольких потоков.
  • В статье разобраны шесть вариантов: Simple Solution, Lazy Initialization, Synchronized Accessor, Double Checked Locking, Class Holder Singleton и Enum Singleton.
  • Рабочий выбор по умолчанию это Class Holder Singleton: ленивый, потокобезопасный и быстрый, причем без единой строчки синхронизации в коде. Если ленивость не нужна, надежнее синглтон на enum: только он не ломается рефлексией и сериализацией.
  • У паттерна есть цена: нарушение принципа единственной ответственности, скрытая зависимость, глобальное состояние и просевшая тестируемость.
Заставка статьи: надпись SINGLETON PATTERN на красных плашках поверх фона из повторяющихся белых граненых блоков

Что такое синглтон?

Синглтон это один из самых простых шаблонов (паттернов) проектирования, который применяется к классу. Иногда говорят: “этот класс синглтон”, подразумевая, что этот класс реализует паттерн проектирования синглтон. Иногда необходимо написать класс, у которого можно будет создать только один объект. Например, класс, отвечающий за логирование или подключение к базе данных. Шаблон проектирования синглтон описывает, как мы можем выполнить такую задачу. Синглтон это шаблон (паттерн) проектирования, который делает две вещи:
  1. Дает гарантию, что у класса будет всего один экземпляр класса.

  2. Предоставляет глобальную точку доступа к экземпляру данного класса.

Отсюда две особенности, характерные для практически каждой реализации паттерна синглтон:
  1. Приватный конструктор. Ограничивает возможность создания объектов класса за пределами самого класса.

  2. Публичный статический метод, который возвращает экземпляр класса. Данный метод называют getInstance. Это глобальная точка доступа к экземпляру класса.

Варианты реализации

Шаблон проектирования синглтон применяют по-разному. Каждый вариант по-своему хорош и плох. Тут как всегда: идеала нет, но нужно к нему стремиться. Но прежде всего давай определимся, что такое хорошо и что такое плохо, и какие метрики влияют на оценку реализации шаблона проектирования. Начнем с положительного. Вот критерии, которые придают реализации сочности и привлекательности:
  • Ленивая инициализация: когда класс загружается во время работы приложения именно тогда, когда он нужен.

  • Простота и прозрачность кода: метрика, конечно, субъективная, но важная.

  • Потокобезопасность: корректная работа в многопоточной среде.

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

Теперь минусы. Перечислим критерии, которые выставляют реализацию в нелучшем свете:
  • Не ленивая инициализация: когда класс загружается при старте приложения, независимо от того, нужен он или нет (парадокс, в мире IT лучше быть лентяем)

  • Сложность и плохая читаемость кода. Метрика также субъективная. Будем считать, что если кровь пошла из глаз, реализация так себе.

  • Отсутствие потокобезопасности. Иными словами, “потокоопасность”. Некорректная работа в многопоточной среде.

  • Низкая производительность в многопоточной среде: потоки блокируют друг друга все время либо часто, при совместном доступе к ресурсу.

Код

Теперь мы готовы рассмотреть различные варианты реализации с перечислением плюсов и минусов:

Simple Solution

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }
}
Самая простая реализация. Плюсы:
  • Простота и прозрачность кода

  • Потокобезопасность

  • Высокая производительность в многопоточной среде

Минусы:
  • Не ленивая инициализация.
Уточним, что значит «не ленивая». Виртуальная машина не грузит все классы при старте программы: по спецификации языка класс инициализируется прямо перед первым созданием его объекта, первым вызовом его статического метода или первым обращением к его статическому полю. Так что объект здесь появится не при запуске приложения, а при первом же касании класса Singleton, пусть даже это будет обращение к какой-нибудь его константе. Разница с ленивыми вариантами в том, что моментом создания ты не управляешь. В попытке исправить последний недостаток, мы получаем реализацию номер два:

Lazy Initialization

public class Singleton {
    private static Singleton INSTANCE;

    private Singleton() {}

    public static Singleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }
}
Плюсы:
  • Ленивая инициализация.

Минусы:
  • Не потокобезопасно

Реализация интересна. Мы можем инициализироваться лениво, но утратили потокобезопасность. Не беда: в реализации номер три мы все синхронизируем.

Synchronized Accessor

public class Singleton {
    private static Singleton INSTANCE;

    private Singleton() {
    }

    public static synchronized Singleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }
}
Плюсы:
  • Ленивая инициализация.

  • Потокобезопасность

Минусы:
  • Низкая производительность в многопоточной среде

Отлично! В реализации номер три мы вернули потокобезопасность! Правда, медленную… Теперь метод getInstance синхронизирован, и входить в него можно только по одному. На самом деле нам нужно синхронизировать не весь метод, а лишь ту его часть, в которой мы инициализируем новый объект класса. Но мы не можем просто обернуть в synchronized блок часть, отвечающую за создание нового объекта: это не обеспечит потокобезопасность. Все немного сложнее. Правильный способ синхронизации представлен ниже:

Double Checked Locking

public class Singleton {
    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;
    }
}
Плюсы:
  • Ленивая инициализация.

  • Потокобезопасность

  • Высокая производительность в многопоточной среде

Минусы:
  • Многословно и легко ошибиться: достаточно забыть volatile, и реализация тихо перестает быть потокобезопасной, а поймать такую ошибку тестами почти невозможно.

Обрати внимание на слово volatile в объявлении поля: без него эта реализация не работает. Строка INSTANCE = new Singleton() это не одно действие, а три: выделить память, отработать конструктор, записать ссылку в поле. Два последних шага компилятор или процессор вправе поменять местами, и тогда соседний поток увидит в INSTANCE ссылку на еще недостроенный объект, причем даже не заходя в блок synchronized, ведь первая проверка на null его туда не пустит. Ключевое слово volatile такую перестановку запрещает, и начиная с Java 5 этого достаточно, о чем прямо говорит декларация «Double-Checked Locking is Broken». Вариант с final здесь не подойдет: статическое финальное поле обязано получить значение при инициализации класса, а вся идея ленивой реализации в том, чтобы этого не делать. Следующая реализация, которую мы обсудим, это Class Holder Singleton. Построена она на статическом вложенном классе.

Class Holder Singleton

public class Singleton {

    private Singleton() {
    }

    private static class SingletonHolder {
        public static final Singleton HOLDER_INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonHolder.HOLDER_INSTANCE;
    }
}
Плюсы:
  • Ленивая инициализация.

  • Потокобезопасность.

  • Высокая производительность в многопоточной среде.

Минусы:
  • Для корректной работы необходима гарантия, что объект класса Singleton инициализируется без ошибок. Иначе первый вызов метода getInstance закончится ошибкой ExceptionInInitializerError, а все последующие NoClassDefFoundError.

Реализация практически идеальная. И ленивая, и потокобезопасная, и быстрая. Но есть нюанс, описанный в минусе. Осталась еще одна реализация, которой в классических подборках часто нет, а зря. Она построена на перечислении.

Enum Singleton

public enum Singleton {
    INSTANCE;

    public void doWork() {
        // полезная работа синглтона
    }
}
Точка доступа тут не getInstance(), а сама константа: Singleton.INSTANCE.doWork(). Плюсы:
  • Простота и прозрачность кода: конструктор прятать не нужно, компилятор делает это сам.

  • Потокобезопасность: константа создается при инициализации класса, а за нее отвечает сама виртуальная машина.

  • Высокая производительность в многопоточной среде: синхронизации в коде нет вовсе.

  • Единственный из шести вариантов, который не ломается ни рефлексией, ни сериализацией.

Минусы:
  • Не ленивая инициализация, ровно в том же смысле, что и у Simple Solution.

  • Такой синглтон нельзя унаследовать от другого класса: перечисление уже наследуется от java.lang.Enum. Интерфейсы реализовывать при этом можно.

Теперь о том, ради чего этот вариант вообще стоит знать. Все пять реализаций выше защищают синглтон только от честного new. Рефлексия обходит приватный конструктор через setAccessible(true), а десериализация делает новый объект, если не написать метод readResolve(). С перечислением не работает ни то, ни другое: Constructor.newInstance() бросает IllegalArgumentException, когда конструктор принадлежит enum-классу, а ObjectInputStream не создает константу заново, а находит существующую по имени через Enum.valueOf(). Сравнительная таблица различных реализаций паттерна Singleton:
Реализация Ленивая инициализация Потокобезопасность Скорость работы при многопоточности Когда использовать?
Simple Solution - + Быстро Никогда. Либо когда не важна ленивая инициализация. Но лучше никогда.
Lazy Initialization + - Неприменимо Всегда, когда не нужна многопоточность
Synchronized Accessor + + Медленно Никогда. Либо когда скорость работы при многопоточности не имеет значения. Но лучше никогда
Double Checked Locking + + Быстро В редких случаях, когда нужно обрабатывать исключения при создании синглтона. (когда неприменим Class Holder Singleton)
Class Holder Singleton + + Быстро Всегда, когда нужна многопоточность и есть гарантия, что объект синглтон класса будет создан без проблем.
Enum Singleton - + Быстро Когда надежность важнее всего: единственный вариант, устойчивый к рефлексии и сериализации

Плюсы и минусы паттерна Singleton

В целом синглтон делает именно то, что от него ждут:
  1. Дает гарантию, что у класса будет всего один экземпляр класса.

  2. Предоставляет глобальную точку доступа к экземпляру данного класса.

Однако у этого шаблона есть недостатки:
  1. Синглтон нарушает SRP (Single Responsibility Principle): класс синглтона, помимо непосредственных обязанностей, занимается еще и контролированием количества своих экземпляров.

  2. Зависимость обычного класса или метода от синглтона не видна в публичном контракте класса.

  3. Глобальные переменные это плохо. Синглтон превращается в итоге в одну здоровенную глобальную переменную.

  4. Наличие синглтона снижает тестируемость приложения в целом и классов, которые используют синглтон, в частности.

Ну вот и все. Мы рассмотрели с тобой паттерн проектирования синглтон. Теперь в разговоре за жизнь с друзьями программистами ты сможешь сказать не только чем он хорош, но и пару слов о том, чем он плох. Удачи в освоении новых знаний.

Вопросы и ответы

Что такое синглтон простыми словами?

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

Как сделать класс синглтоном в Java?

Нужны три вещи. Приватный конструктор, чтобы снаружи класс нельзя было создать через new. Приватное статическое поле, в котором лежит единственный экземпляр. И публичный статический метод getInstance(), который этот экземпляр отдает. Дальше начинаются варианты: создавать объект при инициализации класса или при первом вызове getInstance(), и как защитить создание от нескольких потоков. Есть и путь короче: объявить enum с единственной константой, тогда всю работу берет на себя компилятор.

Почему Double Checked Locking не работает без volatile?

Потому что запись ссылки и заполнение полей объекта могут поменяться местами. Строка INSTANCE = new Singleton() раскладывается на три шага: выделить память, отработать конструктор, записать ссылку в поле. Компилятор и процессор вправе переставить два последних, и тогда другой поток увидит в INSTANCE ненулевую ссылку на недостроенный объект, даже не заходя в synchronized: внешняя проверка на null его туда не пустит. Слово volatile запрещает такую перестановку, и с Java 5 этого достаточно, чтобы идиома работала.

Какая реализация синглтона лучше?

Если нужна именно ленивая инициализация, то Class Holder Singleton: объект создается при первом обращении к вложенному классу-держателю, потокобезопасность обеспечивает сама виртуальная машина, а синхронизации в коде нет, поэтому нет и потерь скорости. Если ленивость не критична, надежнее синглтон на enum: он короче всех и единственный не ломается рефлексией и сериализацией. Double Checked Locking остается для случая, когда конструктор синглтона может бросить исключение и его надо обработать.

Чем плох синглтон?

Тем, что это глобальная переменная с хорошими манерами. Класс совмещает свою прямую работу с контролем над числом собственных экземпляров, а это нарушение принципа единственной ответственности. Зависимость от синглтона не видна в сигнатурах: класс выглядит самостоятельным, а внутри дергает getInstance(). И тестировать такой код тяжело, потому что подменить синглтон заглушкой обычно нечем.

Можно ли сломать синглтон через рефлексию или сериализацию?

Да, и это работает против всех реализаций из статьи, кроме последней. Рефлексия позволяет добраться до приватного конструктора через setAccessible(true) и создать второй объект. Десериализация по умолчанию тоже создает новый экземпляр, если не написать метод readResolve(). Устойчив к обоим способам только синглтон на enum: конструктор перечисления рефлексии недоступен, Constructor.newInstance() бросает на нем IllegalArgumentException, а при десериализации константа не создается заново, а находится по имени через Enum.valueOf().

Читайте также

Дополнительное чтение: