Rethinking Singleton Design Pattern

Kiedy pytam programistów, jakie znają wzorce projektowe, bardzo często (na początku) wymieniają wzorzec Singleton. Ciekawe, dlaczego tak się dzieje? Czy naprawdę używają go tak często? Czy może jest on jednym z najprostszych do implementacji wzorców?

Nazwa Singleton wywodzi się z matematyki, dokładnie z teorii mnogości. Singleton to inaczej zbiór jednoelementowy, czyli zbiór, do którego należy jeden i tylko jeden element.

Odpowiedzialności wzorca Singleton

  • zagwarantowanie, że klasa będzie miała tylko jeden egzemplarz (per JVM)
  • zapewnienie globalnego dostępu do tego obiektu

Czasem istnieje potrzeba, by klasa miała tylko jeden egzemplarz. Przykładem mogą być klasy odpowiedzialne za: logowanie, cache’owanie, pule połączeń, konfigurację czy system plików. Wzorce projektowe także korzystają z implementacji wzorca Singleton, np. Abstract Factory, Factory Method, Service Locator. Ponadto w samym JDK możemy spotkać realizację wzorca Singleton, np. java.lang.Runtime.

Jednym ze sposobów udostępnienia obiektu w dowolnym miejscu jest zmienna globalna. Pomijając wady takiego rozwiązania, lepszym pomysłem jest przydzielenie klasie zadania śledzenia swojego jedynego egzemplarza. Poprzez przechwytywanie żądań udostępnienia lub utworzenia nowego obiektu, klasa zapewnia, że nie zostanie utworzony żaden inny jej egzemplarz. Dodatkowo umożliwia dostęp do jedynego egzemplarza.

Dzięki użyciu tego wzorca można w przyszłości (w razie konieczności) łatwo zmienić podejście i zezwolić na tworzenie więcej niż jednej instancji klasy. Ponadto można także kontrolować liczbę egzemplarzy używanych w aplikacji.

Implementacje wzorca Singleton

  • Klasyczna implementacja (lazy initialization)
  • public class Singleton {
    
    	private static volatile Singleton instance = null;
    
    	private Singleton() {
    	}
    
    	public static Singleton getInstance() {
    		if (instance == null) {
    			instance = new Singleton();
    		}
    		return instance;
    	}
    
    	public void doSomething() {
    		// do some stuff here
    	}
    }

    Jest to najbardziej znana implementacja korzystająca ze statycznego pola instance (do przechowywania instancji klasy) oraz ze statycznej metody getInstance() (do zwracania tej instancji). Powyższa implementacja używa techniki zwanej lazy initialization – obiekt zostanie utworzony dopiero, gdy zostanie wywołana metoda getInstance(); innymi słowy zostanie utworzony dopiero wtedy, kiedy będzie potrzebny.

    Niestety powyższy kod nie jest bezpieczny wątkowo (w środowisku wielowątkowym). Aby to naprawić, można dodać słowo kluczowe synchronized do sygnatury metody getInstance():

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

    Synchronizacja na poziomie metody może jednak znacząco spowolnić działanie aplikacji. Jeśli zachodzi taka konieczność, powinniśmy synchronizować tylko krytyczny kawałek kodu:

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

    Niestety tak zmieniony kod nie jest thread-safe (w środowisku wielowątkowym). Może zdarzyć się sytuacja, że wątek 1 wejdzie do bloku synchronizowanego (linia 3) i zostanie wywłaszczony, zanim zdąży przypisać obiekt do zmiennej instance. Następnie wątek 2 wejdzie do bloku if (linia 2) i będzie czekał aż wątek 1 wyjdzie z bloku synchronizowanego, by utworzyć kolejną instancję Singleton.

    Oczywiście można zaradzić tej sytuacji. Z pomocą przychodzi wzorzec Double-checked locking:

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

    W bloku synchronized sprawdzamy jeszcze raz, czy instance == null. Powyższy kod jest poprawną* implementacją wzorca Singleton.

    *) w tym konkretnym przypadku jest OK, ale gdyby klasa posiadała inne pola nieoznaczone jako volatile, to sytuacja nieco by się skomplikowała. Więcej informacji na ten temat znajdziecie w poście Double-checked locking: Clever but broken

  • Klasyczna implementacja (eager initialization)
  • public class Singleton {
    
    	private static volatile Singleton instance = new Singleton();
    
    	private Singleton() {
    	}
    
    	public static Singleton getInstance() {
    		return instance;
    	}
    
    	public void doSomething() {
    		// do some stuff here
    	}
    }

    lub:

    public class Singleton {
    
    	public static final Singleton INSTANCE = new Singleton();
    
    	private Singleton() {
    	}
    
    	public void doSomething() {
    		// do some stuff here
    	}
    }

    Powyższe dwa kawałki kodu są bardziej zwięzłe, bezpieczne wątkowo oraz nie posiadają wad poprzednich implementacji.

    Należy jednak zauważyć, że obiekt Singleton zostanie powołany do życia podczas wczytywania klasy Singleton.class; może się to odbyć znacznie wcześniej, niż obiekt będzie potrzebny. Może być też tak, że nie będzie potrzebny w ogóle. Trzeba wziąć to pod uwagę w sytuacji, gdy utworzenie obiektu wymaga sporo zasobów.

    Drugi przykład (z publicznym polem INSTANCE) nie pozwala – w przyszłości – na dodanie możliwości zliczania ilości tworzonych/utrzymywanych instancji.

  • Implementacja wg Billa Pugha
  • public class Singleton {
    
    	private Singleton() {
    	}
    
    	private static class LazyHolder {
    		private static final Singleton INSTANCE = new Singleton();
    	}
    
    	public static Singleton getInstance() {
    		return LazyHolder.INSTANCE;
    	}
    
    	public void doSomething() {
    		// do some stuff here
    	}
    }

    Bill Pugh przyczynił się do zmian w Java Memory Model. Powyższy kod używa Initialization-on-demand holder idiom. Dzięki tej technice, klasa LazyHolder będzie zainicjowana dopiero w momencie użycia; zatem można używać innych, statycznych metod z klasy Singleton bez niepotrzebnego tworzenia obiektu Singleton.

  • Implementacja uwzględniająca interfejs Serializable
  • import java.io.Serializable;
    
    public class Singleton implements Serializable {
    
    	private static volatile Singleton instance = null;
    
    	private Singleton() {
    	}
    
    	public static Singleton getInstance() {
    		if (instance == null) {
    			synchronized (Singleton.class) {
    				if (instance == null) {
    					instance = new Singleton();
    				}
    			}
    		}
    		return instance;
    	}
    
    	public void doSomething() {
    		// do some stuff here
    	}
    
    	protected Object readResolve() {
    		return instance;
    	}
    }

    Jeśli klasa Singleton implementuje interfejs Serializable, to po każdej deserializacji zwracane będą inne instancje obiektu. Aby temu zapobiec, należy dodać metodę readResolve(), która zwraca jedyną instancję obiektu Singleton.

    Dodatkowo, aby zapobiec problemom z deserializacją (w przypadku kiedy między serializacją a deserializacją struktura klasy się zmieniła), warto dodać do klasy Singleton pole serialVersionUID o unikalnej wartości:

    private static final long serialVersionUID = 1L;
  • Implementacja uwzględniająca interfejs Clonable
  • public class Singleton implements Cloneable {
    
    	private static volatile Singleton instance = null;
    
    	private Singleton() {
    	}
    
    	public static Singleton getInstance() {
    		if (instance == null) {
    			synchronized (Singleton.class) {
    				if (instance == null) {
    					instance = new Singleton();
    				}
    			}
    		}
    		return instance;
    	}
    
    	public void doSomething() {
    		// do some stuff here
    	}
    
    	public Object clone() throws CloneNotSupportedException {
    		throw new CloneNotSupportedException();
    	}
    }

    Jeśli klasa Singleton musi (pośrednio) implementować interfejs Cloneable, to należy nadpisać metodę clone() pochodzącą z klasy Object i rzucać w niej wyjątek CloneNotSupportedException.

    Innym rozwiązaniem jest zwracanie this lub instance w metodzie clone():

    public Object clone() throws CloneNotSupportedException {
    	return instance;
    }

    Powyższe rozwiązanie kłóci się jednak z rekomendacją, że x.clone() != x, ale jest bliższe Liskov’s Substitution Principle.

  • Implementacja z uwzględnieniem wielu Classloaderów
  • private static Class getClass(String classname) throws ClassNotFoundException {
    	ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
    	if (classLoader == null) {
    		classLoader = Singleton.class.getClassLoader();
    	}
    	return (classLoader.loadClass(classname));
    }

    Ponieważ np. w środowisku serwerowym, używanych jest kilka Classloaderów, należy samemu zapewnić, by klasa Singleton była ładowana przez ten sam Classloader. Można to zrobić używając powyższego kodu. Obszerniejszy przykład znajduje się w poście Inigo Surguya.

  • Implementacja wg Joshua Blocha
  • public enum Singleton {
    	INSTANCE;
    
    	public void doSomething() {
    		// do some stuff here
    	}
    }

    Jest to najprostsza i zalecana implementacja wzorca Singleton. Oferuje bezpieczeństwo wątkowe, zapewnia obsługę serializacji i zabezpieczenie przeciw tworzeniu wielu obiektów nawet podczas serializacji czy użyciu reflekcji.

Podsumowanie

  • Mimo długiego wpisu nie udało mi się wyczerpać tematu w 100%. Istnieje jeszcze kilka specyficznych przypadków i subtelnych kwestii, które można by opisać. Jednak z uwagi na złożoność problemu oraz znikomą szansę na pojawienie się tych problemów w codziennym kodowaniu, poprzestanę na powyższych przykładach.
  • Mam nadzieję, że ten wpis pokazuje, że wzorzec Singleton nie jest aż tak prosty w implementacji, jakby się mogło wydawać. Szczególnie w środowisku wielowątkowym.
  • Istnieje złośliwe stwierdzenie, że 95% kodu napisanego w Javie nie jest bezpieczne wątkowo. Pozostałe 5% napisali autorzy książki Java Concurrency in Practice:).
  • Wzorzec Singleton jest często traktowany jako Antywzorzec! Jednym z powodów jest to, że łamie Single Responsibility Principle: tworzy instancję oraz udostępnia własne metody. Kolejnym powodem są problemy z testowaniem zarówno obiektów Singleton jak i ich klientów. Jest to szczególnie uporczywe, gdy używamy DI. Czasem, zamiast tego, musimy używać frameworków typu PowerMock i/lub refleksji.
  • Jeśli w Twojej aplikacji jest kilka obiektów Singleton, powinieneś przemyśleć design jeszcze raz; najprawdopodobniej kilku z nich można się pozbyć. Jeśli jednak potrzebujesz dokładnie jednego obiektu danej klasy, to zachęcam Cię do skorzystania z powyższych rad i wyjaśnień.

Leave a Reply