Все что вы хотели знать о Singleton

Шаблон проектирования Singleton (Одиночка) один из самых некорректно применяемых паттернов. В этой статье мы рассмотрим несколько реализаций синглтона, которые работают корректно в многопоточной среде, при выполнении задач сериализции и клонирования и даже при рефлексивных атаках.

Для чего используется singleton

Паттерн singleton используется в случаях когда существовать может ровно один экземпляр класса и он должен быть доступен через хорошо известную точку доступа или когда единственный экземпляр должен быть расширяем подклассами и клиенты должны иметь возможность использовать наследуемый экземпляр без модификации своего кода.

Отличия от статических классов

В JDK есть примеры использования и синглтонов и статических классов, с одной стороны java.lang.Mathfinal класс со статическими методами, с другой стороны java.lang.Runtime – синглтон.

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

  • Если вам требуется поддерживать состояние, то синглтон больше подходит, нежели статический класс, поскольку поддержка состояния в статических классах может привести к ошибкам, особенно в конкурентном окружении, что может приводить к состоянию гонки без адекватной синхронизации параллельных модификаций различными потоками.
  • Синглтон класс может быть «лениво» загружен, если это тяжелый объект, но у статического класса нет таких преимуществ и он всегда сразу загружается.
  • С синглтоном вы можете использовать наследование и полиморфизм для расширения базового класса, реализовать интерфейс и предоставлять различные реализации.
  • Поскольку статические методы в Java не могут быть переопределены (overridden) можно сказать, что они менее гибкие. С другой стороны, вы можете переопределить (override) методы в синглтоне расширив его.

Недостатки статических классов

  • Написать юнит-тесты для синглтона легче чем для статического класса, поскольку вы можете передать mock объект туда, где ожидается синглтон.

Преимущества статических классов

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

Lazy или eager загрузка singleton?

При реализации синглтона у нас есть два варианта:

  • Eager loading (нетерпеливая/ранняя загрузка) — ранняя загрузка, создание экземпляра при инициализации.
  • Lazy loading (ленивая/отложенная загрузка) — создание экземпляра по требованию.

Ленивая загрузка используется, когда требуется отложить инициализацию очень больших объектов или вычисление тяжелых конструкций, а так же при наличии статических полей или методов, которые могут быть использованы до того как потребуется экземпляр; тогда и только тогда вам нужна ленивая инициализация. В противном случае предпочтительнее использовать раннюю загрузку.

Eager loading singleton

если ранняя загрузка подходит вам – выбирайте ее

Этот способ имеет ряд преимуществ:

  • Нет необходимости синхронизировать getInstance метод, поскольку все потоки видят тот же самый экземпляр и не требуется использования дорогой блокировки.
  • Статический инициализатор запускается в момент инициализации класса, после загрузки класса, но перед использованием этого класса любым потоком.
  • Ключевое слово final означает, что экземпляр не может быть переопределен, гарантируя существование одного, и только одного экземпляра.
private static final Singleton INSTANCE = new Singleton(); 

Статический инициализатор так же возможен, при необходимости. Загрузчик класса выполняет тот же самый процесс:

private static final Singleton INSTANCE;
static { 
    try {
        INSTANCE = new Singleton(); 
    } catch (Exception e) { 
        throw new RuntimeException("An error occurred!", e); 
    } 
} 

Заметьте, что конструктор сделан приватным для исключения возможности создать экземпляр класса вне синглтон класса:

private Singleton() {}

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

public static Singleton getInstance {
	return INSTANCE;
}

Нетерпеливая инициализация менее сложная и вероятность допустить ошибку меньше в ранней загрузке, поскольку объект создается заранее и сразу готов к использованию при запросе. Как ранее упоминал, если ранняя загрузка подходит вам - просто используйте ее.

Ниже идут примеры для синглтона ленивой загрузки и вы можете увидеть, насколько они сложнее.

Грубая синхронизация

простейший способ с серьезными проблемами производительности

В многопоточном окружении требуется быть уверенным, что только один поток (исполнения) может выполнить метод getInstance в данный момент. Самый простой способ для синглтона отложенной загрузки – использовать ключевое слово synchronized для метода.

С точки зрения производительности этот код неэффективен и имеет ненужный оверхед (накладные расходы).

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

В общем — не используйте этот вариант в серьезных проектах.

Double-checked locking синглтон

известная версия с малознакомыми проблемами

Double checked locking – это способ предотвратить создание другого экземпляра синглтона в многопоточном окружении при вызове метода getInstance.

Обратите внимание

  • Экземпляр синглтона проверяется дважды перед инициализацией.
  • Синхронизированная критическая секция используется только после первой проверки экземпляра синглтона для улучшения производительности.
  • Ключевое слово volatile используется при определении членов экземпляра. Это принуждает компилятор всегда читать и писать в главную память и не использовать кэш CPU. С volatile переменной гарантируется happens-before отношение, все записи выполняются перед любым чтением.
public class Singleton { 
	private volatile static Singleton instance; 
	private Singleton() {} 
	public static Singleton getInstance{ 
		if (instance == null) {  
			synchronized(Singleton.class){  
				if (instance == null) {   
					instance = new Singleton();  
				}  
			} 
		} 
		return instance; 
    }
}

Недостатки

  • Поскольку для правильной работы требуется ключевое слово volatile, этот способ не совместим с Java 1.4 и ранними версиями. Проблема в том, что неупорядоченная запись может позволить возвратить ссылку на экземпляр до того как выполнится конструктор синглтона.
  • Падение производительности из-за отказа использования кеша для volatile переменных.
  • Экземпляр синглтона проверяется дважды перед инициализацией.
  • Способ довольно многословен и затрудняет чтение.

Initialization-on-demand holder идиома

лучшая реализация для лениво загружаемого синглтона

  • Настолько отложенный, насколько это возможно, работает во всех известных версиях Java.
  • Использует гарантии языка, касаемо инициализации классов и поэтому работает корректно во всех Java-совместимых компиляторах и виртуальных машинах.
  • Отсутствие накладных расходов из-за синхронизации. Тесты производительности показывают, что так намного быстрее, чем даже синхронизация без конкуренции.
public class Singleton{
    private Singleton() {}

	public static Singleton getInstance { 
        return SingletonHolder.INSTANCE;
    } 
    private static class SingletonHolder { 
        private static final Singleton INSTANCE = new Singleton(); 
    } 
}

Это отличный способ получить синглтон используя требования JVM к классам и инициализации объектов. JVM гарантирует, что статический класс SingletonHolder не инициализируется до тех пор, пока JVM определит, что SingletonHolder должен быть выполнен. Статический класс SingletonHolder выполняется когда статический метод getInstance вызывается на классе Singleton, и когда это случается в первый раз, JVM загружает и инициализирует SingletonHolder класс.

Поскольку JLS (Java Language Specification) гарантирует, что фаза инициализации классов должна быть не конкурентной, никакой синхронизации не требуется для статического метода getInstance во время загрузки и инициализации.

Основанный на enum singleton

современный взгляд на старую проблему

Эта реализация синглтона использует гарантии Java, что любое enum значение инстанцируется только один раз и enum обеспечивает неявную поддержку потоковой безопасности (thread safety). Поскольку Java enum значения доступны глобально, то это может использоваться как синглтон.

public enum Singleton {
    SINGLETON; 
    public void method() { }
}

Как это работает? Вторая строка кода может считаться чем-то вроде:

public final static Singleton SINGLETON = new Singleton(); 

И мы получаем старый добрый синглтон с ранней загрузкой.

Помните, что поскольку это enum, вы всегда можете получить доступ к экземпляру через Singleton.INSTANCE:

Singleton s = Singleton.INSTANCE;

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

  • Для предотвращения создания еще одного экземпляра синглтона во время десериализации используйте синглтон, основанный на enum, поскольку о сериализации enum заботится JVM. Сериализация и десериализация enum работает по другому, нежели для обычных java объектов. Единственное, что сериализуется – это имя enum значения. Во время процесса десериализации enum метод valueOf используется с десериализованным именем для получения желаемого экземпляра.
  • Основанный на enum синглтон позволяет защитить себя от рефлексивных атак. Причиной, почему рефлексия не может быть использована для инстанцирования объектов типа enum, является запрет Java спецификации и данное правило закодировано в реализации метода newInstance класса Constructor, который обычно используется для создания объектов с помощью рефлексии:
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
    throw new IllegalArgumentException("Cannot reflectively create enum objects");
  • Enum не был задуман имеющим возможность быть клонированным, т.к. должен существовать один экземпляр каждого значения.
  • Самый лаконичный код среди всех реализаций синглтона.

Недостатки

  • Основанный на enum синглтон не поддерживает ленивую инициализацию.
  • Если вы поменяете вашу архитектуру и захотите конвертировать ваш синглтон в мультитон (multiton), enum вам в этом не поможет. Паттерн мультитон используется для контролируемого создания различных экземпляров, которые управляются с помощью map, что позволяет контролировать уникальность объекта по какому-либо признаку. Вместо того, что бы иметь единственный экземпляр на приложение (например java.lang.Runtime) паттерн мультитон обеспечивает единственный экземпляр на ключ.
  • Enum появился только только в Java 5, поэтому его невозможно использовать в ранних версиях.

Проблемы с сериализацией и десериализацией

Одна из проблем с обычным синглтоном (не основанные на enum) является то, что однажды реализовав Serializable интерфейс, он перестает им быть, поскольку метод readObject всегда возвращает новый экземпляр подобно конструктору в Java.

Для Serializable и Externalizable классов метод readResolve позволяет заменить объект, считанный из потока (данных) перед тем, как он будет возвращен вызывающему. Реализуя метод readResolve, класс может напрямую контролировать тип и экземпляр десериализуемых объектов. Метод readResolve вызывается когда ObjectInputStream считал объект с потока (данных) и подготавливает его для возврата вызывающему.

 public class Singleton implements Serializable {
     // skipped...
     protected Object readResolve() throws ObjectStreamException {
         return INSTANCE;
     }
 }

Или вы можете использовать основанный на enum синглтон, поскольку о сериализации enum заботиться JVM.

Проблемы с клонированием

Используя метод clone мы можем создать копию оригинального объекта. Что собственно и случается, если мы используем clone для обычного синглтона (не основанного на enum).

Для преодоления данной проблемы, необходимо переопределить (override) метод clone и выбросить исключение CloneNotSupportedException.

public class Singleton {
    // skipped...
    @Override
    protected Object clone() throws CloneNotSupportedException {
        throw new CloneNotSupportedException("Not permitted to clone Singleton");
    }
}

Проблемы с рефлексией

Одним из решений проблемы рефлексии с обычным синглтоном (не основанные на enum) является выброс исключения времени выполнения (run-time exception) в конструкторе, если экземпляр уже существует. Таким образом мы не допустим создания посторонних экземпляров.

private Singleton() {
    throw new IllegalStateException("Not permitted to construct Singleton by reflection");
}

Или вы можете использовать основанный на enum синглтон, поскольку рефлексия не может быть использована для инстанцирования объектов с типом enum.

Итак, почему синглтон может считаться анти-паттерном?

Почему вам следовало бы избегать паттерн Singleton (Одиночка) и вместо него использовать Dependency Injection (Инъекция зависимости)? Например, каждый класс, которому нужен доступ к общему объекту, получает ссылку на него через свой конструктор или через DI-контейнер.

Чем больше классов вызывают метод getInstance, тем больше код становится сильно связным (tightly coupled), монолитным, не тестируемым и сложным для изменений и повторного использования из-за неконфигурируемых скрытых зависимостей.

Сильная связность (tight coupling)

Поскольку синглтон предоставляет глобальный статус другим классам, он очень часто используется во многих местах в коде. Это превращает синглтон в центральное место вашего приложения от которого напрямую или косвенно зависит остальной код. Конкретный модуль может быть сложно переиспользовать и тестировать, т.к. зависимые модули так же должны быть включены.

Сложности с тестированием

Поскольку фабричный метод (getInstance) глобально доступный, вы вызываете его с именем класса, вместо того, что бы полагаться на интерфейс, который позже вы можете заменить другой реализацией или mock. Вот почему невозможно заменить его, когда вы хотите протестировать метод или класс.

Синглтон сохраняет состояние пока приложение выполняется

Постоянное состояние – враг юнит-тестирования. Одной из причин эффективности юнит-тестирования является независимость каждого теста от других. Если это не так, то очередность, в которой выполняются тесты, влияет на результаты их выполнения. Это может приводить к случаям, когда тесты завершаются с ошибкой, хотя не должны, а так же может приводить к успешно выполненным тестам, что даже хуже.

Заключение

Как любой инструмент паттерн синглтон может быть использован корректно или ошибочно. Плохое отношение к синглтону появилось из-за неподходящего использования для задач, для которых он не был разработан. Самая большая ошибка – использовать синглтон в качестве глобальной переменной.

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

Если вам понравилась статья — поделитесь с друзьями. Это лучшая мотивация развивать блог.

17 ноября 2019

Версии статьи

English version: All you want to know about Singleton   

Комментарии

Если у вас есть вопросы или комментарии — буду рад их услышать — присоединяйтесь к беседе!


Нюансы Java разработки

Этот блог о Java разработке и в деталях описывает наиболее важные темы.

Поиск по сайту