7 błędów, które popełniasz wykorzystując singleton | Wzorce Projektowe

Singleton

Mimo iż singleton jest jednym z podstawowych wzorców projektowych, zdecydowana większość jego implementacji zawiera podstawowe błędy projektowe. Sam się przekonaj i sprawdź, czy dobrze to robisz.

Zacznijmy jednak od drobnego wprowadzenia – zapoznaj się z podstawową definicją singletonu oraz poniższymi przykładami, jak można wykorzystać go w praktyce.

Co to jest singleton?

Singleton – to kreacyjny wzorzec projektowy, który ma na celu ograniczenie ilości tworzonych obiektów danej klasy tylko do jednej instancji.

Wzorzec ten może być zastosowany wszędzie tam, gdzie chcemy przechować jeden spójny obiekt dostępny z wielu miejsc aplikacji.

Przykłady zastosowania

Przykładem wykorzystania tego wzorca może być klasa przechowująca konfigurację aplikacji. Z każdego miejsca w systemie możemy ją zmodyfikować i chcemy, żeby zmiany były widoczne również z dowolnego miejsca. Jednocześnie nie możemy pozwolić na to, by w systemie były utrzymywane różne wersje konfiguracji.

Struktura wzorca – implementacja

Jest wiele możliwości implementacji singletonu, jednak wszystkie można sprowadzić do dwóch głównych punktów:

1. Zapewnij tylko jedną instancję

Należy ograniczyć swobodną możliwość tworzenia nowych instancji klasy. Można to osiągnąć przez oznaczenia konstruktora prywatnym modyfikatorem dostępu.

Ponieważ „z zewnątrz” nie można już utworzyć nowej instancji, klasa sama musi zarządzać swoimi instancjami i w odpowiednim momencie ją utworzyć.

2. Przygotuj globalny punkt dostępu

Żeby móc pobrać instancje naszej klasy, należy przygotować jakiś punkt dostępu, który powinien być dostępny globalnie.

Najczęściej realizuje się to przez statyczną metodę zwracającą przygotowaną wcześniej instancję singletonu.

Przykładowa implementacja

Poniżej najprostsza możliwa implementacja singletonu w Javie.

Zgodnie z przedstawioną wcześniej strukturą wzorca mamy prywatny konstruktor oraz globalny punkt dostępu w formie statycznej metody.

Inicjalizacja obiektu eager vs lazy

Powyższa implementacja nie wykorzystuje jednak bardzo ważnej zalety singletonu – mianowicie inicjalizacji obiektu, dopiero kiedy zajdzie taka potrzeba. Instancja zostanie utworzona już w momencie załadowania klasy, nawet jeżeli nigdy nie zajdzie potrzeba jej wykorzystania.

Dzięki implementacji leniwego tworzenia obiektu można odwlec w czasie moment kosztownego budowania instancji oraz potencjalnie oszczędzić zasoby, jeżeli nie będzie potrzeby wcale jego utworzenia.

Zmodyfikujemy przykład z nowymi założeniami.

Przenieśliśmy moment generowania instancji do statycznej metody. Kod singletonu powoli nabiera kształtu. Pytanie jednak, czy jest on już wystarczająco dobry.

W poszukiwaniu „idealnego” singletonu

W kolejnych krokach będziemy próbować złamać pierwotne założenia singletonu oraz postaramy się znaleźć sposób, jak można się przed tym zabezpieczyć.

Każdą kolejną implementację będziemy testować, sprawdzając, czy powstałe instancje znajdują się w tym samym miejscu w pamięci. Jeżeli uda nam się utworzyć nową instancję to znaczy, że kod nie jest odporny na tę metodę.

Podstawowe wywołanie zadziałało – metoda getInstance() zwróciła obiekty, które znajdują się dokładnie w tym samym miejscu w pamięci.

Poszukajmy czegoś trudniejszego.

1. Refleksja

Refleksja jest bardzo potężnym i potencjalnie niebezpiecznym narzędziem. Przy jej pomocy możemy modyfikować właściwości załadowanego kodu aplikacji podczas jej działania.

W naszym wypadku, w celu utworzenia nowej instancji wykonamy trzy kroki:

  • pobierzemy prywatny konstruktor z klasy;
  • zmienimy jego modyfikator dostępu na publiczny;
  • następnie przy jego pomocy utworzymy nową instancję naszej klasy;
W kilku prostych krokach udało nam się utworzyć nową instancję klasy, czyli złamać założenia singletonu.

Spróbujmy teraz zabezpieczyć nasz kod przed taką ingerencją. W tym celu zmodyfikujemy konstruktor tak, żeby sprawdzał, czy statyczne pole instance jest już uzupełnione. Jeżeli instancja została już utworzona wcześniej, powinien zgłosić błąd.

Udało nam się w ten sposób zabezpieczyć przed utworzeniem nowej instancji. Teraz próba utworzenia kolejnego obiektu tej klasy zostanie przerwana wyjątkiem.

Nie jest to jednak idealne zabezpieczenie. Wyobraź sobie co się stanie, jeżeli próba utworzenia instancji przy pomocy refleksji zostanie wywołana przed standardowym wywołaniem metody: Singleton.getInstance(). Zapraszam do dyskusji w komentarzach.

2. Serializacja i deserializacja

Serializacja polega na zamianie obiektu na strumień bajtów, który później podczas deserializacji możemy odtworzyć jako nowy obiekt. Więcej o samej serializacji pisałem w tekście o klonowaniu przez serializację.

Co dla nas w tym momencie jest bardzo ważne, podczas deserializacji nie jest zachowany standardowy cykl życia obiektu. Podczas jego tworzenia nie jest wywoływany żaden konstruktor!

Dlatego korzystając z tego mechanizmu, również możemy utworzyć nową instancję pierwotnego singletonu.

Po raz kolejny udało nam się utworzyć nową instancję z pominięciem zabezpieczeń. Całe szczęście specyfikacja Javy przewidziała na taką sytuację specjalną metodę: readResolve.

ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException;

Jeżeli klasa posiada taką metodę, to podczas deserializacji wynik jej działania jest zwracany zamiast standardowej metody: readObject.

Problem z serializacją obiektów występuje tylko wtedy, jeżeli jawnie oznaczymy naszą klasę jako serializowaną przy pomocy interfejsu: java.io.Serializable. W przeciwnym wypadku obiektu wcale nie uda się zserializować.

3. Klonowanie

Java udostępnia wbudowany mechanizm klonowania obiektów, dzięki któremu również można ominąć standardowy cykl budowania obiektów.

Można się przed tym zabezpieczyć rezygnując z klonowania obiektów lub, podobnie jak w przypadku serializacji, w metodzie clone zwrócić istniejącą już instancję singletonu.

W przypadku klonowania, podobnie jak przy serializacji, klasa musi najpierw być jawnie oznaczona jako zdolna do klonowania.

4. Wielowątkowość [multi thread]

W wielowątkowym środowisku może dojść do sytuacji, w której kilka niezależnych wątków w tym samym czasie próbuje wykonać nasz kod. Jeżeli nie zabezpieczymy się przed tym odpowiednio, może to prowadzić do nieprzewidzianych sytuacji.

Przeprowadźmy test, który zasymuluje taką sytuację:

Test polega na próbie pobrania instancji metodą: getInstance() przez dwa niezależne wątki. Test uznajemy za zaliczony, jeżeli obie instancje znajdują się w tym samym miejscu w pamięci. Natomiast jeżeli jedna z nich jest pusta lub zostały utworzone dwa obiekty, test uznajemy za niezaliczony.

Dodatkowo test uruchomiłem 1000 razy, żeby zobaczyć uśredniony wynik. Żeby test mógł być uruchamiany wielokrotnie, przed każdym powtórzeniem trzeba jeszcze zresetować zmienną statyczną przechowującą instancję – SingletonSimpleLazy.instance = null;. W ramach testu chwilowo zmieniłem jej modyfikator dostępu na publiczny.

testSuccess: 854, testFail: 146

Wynik pokazuje, że około 15% wywołań kończy się porażką.

Niby większość zaliczona, natomiast jeżeli Ci to nie wystarcza, zapraszam do dalszej lektury.

Dlaczego niektóre testy zakończyły się porażką?

Żeby odpowiedzieć na to pytanie, przypomnijmy sobie, jak wygląda metoda getInstance()

Jeżeli w kodzie aplikacji nie ma zdefiniowanych żadnych semaforów – blokad wskazujących wątkom, w którym miejscu mają wstrzymać swoje działanie, kolejność, w jakiej poszczególne fragmenty kodu zostaną wykonane przez różne wątki, może być różna. Prowadzi to do tak zwanych wyścigów.

W naszym przykładzie oba wątki mogły jednocześnie sprawdzić warunek, czy instancja jest równa null i w obu wypadkach była to prawda. Dlatego zostały utworzone dwie instancje.

Żeby się przed tym zabezpieczyć, wprowadzimy odpowiednie blokady.

Synchronizacja wątków

Synchronizacja wątków polega na zdefiniowaniu specjalnych sekcji krytycznych, do których w danym momencie może uzyskać dostęp tylko jeden wątek.

Warto również zauważyć, że problem ten występuje tylko, jeżeli chcemy skorzystać z leniwego tworzenia instancji.

Metoda synchronized

Najprostszą możliwą forma synchronizacji w tym wypadku jest utworzenie sekcji krytycznej w ramach całej metody getInstance.

Po tej modyfikacji tylko jeden wątek będzie mógł wejść do tej metody w tym samym czasie.

Uzyskaliśmy dzięki temu zabezpieczenie przed utworzeniem nowej instancji, jednak spowolniliśmy jednocześnie bardzo naszą aplikację. W obecnej wersji nawet jak instancja będzie już utworzona, wszystkie wątki będą musiały poczekać na swoją kolej, żeby ją pobrać.

Powstało w ten sposób wąskie gardło, które postaramy się wyeliminować.

Blok synchronizowany

Przenieśmy sekcję krytyczną trochę niżej, za warunek. Dzięki temu po utworzeniu instancji nie będzie już potrzeby kolejkowania wątków.

Niestety to rozwiązanie również nie jest idealne. Może bowiem dojść do sytuacji, w której więcej niż jeden wątek będzie czekał na wejście do sekcji krytycznej. Wtedy, po zwolnieniu blokady, on również utworzy nową instancję.

Blokada z podwójnym zatwierdzeniem [Double-checked locking optimization]

Na takie sytuacje powstał specjalny wzorzec projektowy: blokada z podwójnym zatwierdzeniem. Polega to na podwójnym sprawdzeniu warunku: najpierw przed blokadą i potem jeszcze raz po przejściu przez blokadę.

Teraz nawet jak kilka wątków przejdzie przez blokadę, to zostaną na nich skolejkowane i nie przejdą przez drugi warunek.

Została nam do poprawy już tylko jedna rzecz. W ramach optymalizacji zmienne w pamięci komputera przechowywane są w keszu procesora. Dlatego może dojść do sytuacji, w której jeden wątek ustawi poprawnie wartość zmiennej, ale drugi podejmie próbę jej odczytania, zanim zostanie ona jeszcze zsynchronizowana.

Zmienna volatile

Specyfikacja Javy pozwala określić, które zmienne mają być zawsze przechowywane we współdzielonej pamięci, a nie w keszu. Dzięki oznaczeniu zmiennej jako volatile niezależnie od tego, który wątek próbuje ją odczytać, jej wartość będzie zawsze spójna.

Po tych zmianach mamy już pewność, że nasz kod jest odpowiednio przygotowany do działania w środowisku wielowątkowym.

Classloader

Java pobiera klasy przez specjalne classloadery. W prostych aplikacjach desktopowych zazwyczaj mamy do czynienia z jednym domyślnym classloaderem i najczęściej jego istnienie jest nawet niezauważalne. Jednak w środowisku JavaEE, np. w kontenerze serwletów wykorzystywanych jest więcej niż jeden classloader jednocześnie.

Może wtedy dojść do utworzenia niezależnego obiektu singletonu w ramach każdego z nich.

Zasymulujemy taką sytuację w środowisku JavaSE w trzech kolejnych krokach:

  1. Klasa naszego singletonu została już pobrana przez domyślny classloader. Spróbujemy pobrać ją jeszcze raz, korzystając z klasy URLClassLoader. W tym celu potrzebujemy podać ścieżkę do zbudowanego wcześniej pliku jar z naszą klasą;
  2. W drugim kroku pobieramy ponownie klasę na podstawie jej pełnej nazwy;
  3. W ostatnim kroku pobieramy statyczną metodę getInstance i korzystając z niej, tworzymy nowy obiekt singletonu.

Udało nam się to zrobić, ponieważ w Javie ta sama klasa załadowana przez różne classloadery jest traktowana jak dwie różne klasy.

Bardzo ciężko jest się przed tym zabezpieczyć. Można próbować zdefiniować ręcznie classloader i korzystać z niego, jednak nie jest to w 100% skuteczny sposób.

Różne maszyny wirtualne

Jeżeli aplikacja uruchamiana jest na kilku maszynach wirtualnych, np. w środowisku EJB również dojdzie do utworzenia kilku instancji singletonu na każdej JVM.

W takiej sytuacji najlepiej unikać singletonów przechowujących lokalny stan obiektu.

Garbage Collection

Jeżeli klasa singletonu zostanie odśmiecona i potem ponownie załadowana, również dojdzie do utworzenia nowej instancji. Jeżeli na obiekt singletonu nie będzie wskazywała żadna referencja, może on zostać usunięty i utworzony ponownie dopiero, jeżeli zajdzie taka potrzeba.

Ten problem występował w starszych wersjach Javy, obecnie nie powinien już mieć miejsca. Więcej na ten temat można przeczytać tutaj.

Alternatywne implementacje singletonu

W poprzednich krokach rozważaliśmy tylko jedną z możliwości implementacji singletonu. Istnieją jednak alternatywne sposoby, np. z wykorzystaniem statycznej klasy lub enuma.

Static holder pattern

Singleton Static holder pattern jest to bezpieczne rozwiązanie i jednocześnie zapewniające dobrą wydajność w wielowątkowym środowisku. Dzięki niemu mamy również zapewnione leniwe utworzenie instancji.

W tym rozwiązaniu cały trud poprawnej implementacji wzorca zrzucamy na maszynę wirtualną Javy. Zmienna INSTANCE zostanie zainicjowana dopiero w momencie załadowania klasy Holder, czyli podczas pierwszego wywołania metody getInstance().

Enum

Implementacja singletonu opartego na enumie również zrzuca cały ciężar poprawnej implementacji na Javę. Enumy charakteryzują się tym, że może istnieć tylko jedna instancja danej klasy.

W tym miejscu warto sprawdzić, jak enum jest wewnętrznie zaimplementowany przez Javę. Posłużymy się w tym celu narzędziem javap:

Enum to zwyczajna finalna klasa dziedzicząca po java.lang.Enum.

Korzystając z tego rozwiązania, osiągnęliśmy wynik bardzo podobny do klasy SingletonSimpleEager, czyli singletonu z zachłannym tworzeniem instancji.

Gotowe biblioteki a singleton

Zamiast implementować wzorzec ręcznie, można również posłużyć się gotowymi już rozwiązaniami.

Immutables

Immutables to biblioteka do pracy z klasami typu: value object. Jedną z jej funkcjonalności jest automatyczna implementacja wzorca singletonu.

Zalety singletonu

  1. Klasa zaimplementowana jako singleton samodzielnie kontroluje liczbę swoich instancji, dzięki czemu, po wprowadzeniu niewielkich modyfikacji, można przerobić ją na pulę zarządzającą większą ilością obiektów.
  2. Sam proces tworzenia nowej instancji jest niewidoczny dla użytkownika. Dlatego można zaimplementować leniwe tworzenie instancji i przyczynić się do zaoszczędzenia zasobów.
  3. Łatwo można przerobić go na fabrykę obiektów, uniezależniając się od konkretnej implementacji.

Singleton jako antywzorzec

Singleton przez wielu programistów uważany jest za antywzorzec projektowy. Dzieje się tak głównie dlatego, że jest on dość często nadużywany.

Poniżej zestawienie głównych zarzutów wobec niego. Niektóre z nich przy odrobienie elastyczności można obejść lub nawet odrzucić.

  1. Poważnie utrudnia testowanie aplikacji
    Testy są tylko utrudnione, jeżeli w singletonie przechowywany jest stan. Należy wtedy pamiętać, by był on odpowiednio zainicjowany lub wyczyszczony przed każdym wywołaniem testu.
  2. Brak elastyczności
    Taka jest właśnie specyfika singletonu, że już na poziomie kodu jest na sztywno określona liczba instancji.
  3. Łamie zasadę jednej odpowiedzialności (single responsibility principle)
    Klasa zaimplementowana jako singleton z założenia jest już odpowiedzialna za dwie rzeczy: za realizację swoich funkcji biznesowych oraz zarządzanie instancją.
  4. Łamie zasadę otwarte-zamknięte (Open/Closed principle), ponieważ nie można go rozszerzać
    W pierwotnej wersji wzorca rzeczywiście ciężko jest go rozszerzać. Można jednak połączyć singleton z fabryką i nie będzie stanowiło to już problemu.
  5. Jest to obiektowy zamiennik zmiennej globalnej

 

Podsumowanie

Dzięki wykorzystaniu singletonu w swoich aplikacjach można zyskać na wydajności oraz przejrzystości kodu. Należy jednak pamiętać, by go nie nadużywać i nie starać się go wprowadzić wszędzie na siłę. Wzorzec ten powinien być stosowany raczej sporadycznie.

Podobnie sprawa ma się, jeżeli chodzi o wszystkie zabezpieczenia przedstawione w artykule. Tu również należy podejść z rozwagą, nie zawsze jest sens wprowadzać wszystko u siebie. Jeżeli piszemy prostą aplikację, uruchamianą w środowisku jednowątkowym i dodatkowo jesteśmy jedyną osobą odpowiedzialną za jej rozwój, z powodzeniem sprawdzi się u nas najprostsza implementacja. Oszczędzimy w ten sposób czas potrzebny na napisanie oraz utrzymanie nadmiarowego kodu.

 

Programista – Pytania rekrutacyjne

Lista pytań rekrutacyjnych, które pozwolą przygotować Ci się na rozmowę kwalifikacyjną.

4 komentarze
Share:

4 Comments

  1. Elon says:

    Temat opisany chyba z każdej możliwej strony i ciężko tu cokolwiek dodać ale spróbuję 😉

    Jako jeden z minusów pierwszej implementacji podajesz eager initialization. Tylko w tym konkretnym przypadku jest ona całkowicie lazy. Nadal do utworzenia obiektu wymagane jest wywołanie dowolnej metody klasy czyli w tym przypadku getInstance(). A skoro już wywołujemy tę metodę to znaczy, że chcemy w tym konkretnym momencie korzystać z obiektu, czyli minus przestaje być minusem.

    1. Tomek says:

      Elon słuszna uwaga.
      Jest to rzeczywiście bardzo specyficzny przypadek i dla tego testu obie implementacje zachowają się bardzo podobnie.

      Rozwijając, inicjalizacja w stylu:
      private static final SingletonSimpleEager instance = new SingletonSimpleEager();
      zostanie wywołana w momencie załadowania klasy SingletonSimpleEager, a klasa jest ładowana dopiero podczas jej pierwszego aktywnego wykorzystania, czyli np. wywołania jednej z metod.
      Drugie rozwiązanie jest o tyle lepsze (klasa SingletonSimpleLazy), że możemy wywołać inne statyczne metody klasy lub wykorzystać jej statyczne zmienne, bez tworzenia instancji singletonu.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *