Gwarancja wynika z zasady 'happens-before' modelu pamięci Java (JMM) związanej z inicjalizacją klasy. Kiedy JVM po raz pierwszy uzyskuje dostęp do pola lub metody static klasy, musi najpierw zakończyć fazę inicjalizacji tej klasy. Faza ta wykonuje bloki inicjalizatorów static i przypisania pól pod wewnętrznym blokadą unikalną dla tego obiektu klasy. W rezultacie, każda operacja zapisu wykonywana w środku inicjalizatora statycznego — na przykład, konstrukcja instancji singletona — tworzy związek 'happens-before' z każdym kolejnym odczytem tego pola przez wątki uzyskujące dostęp do klasy, zapewniając pełną widoczność zbudowanego stanu bez konieczności używania słów kluczowych synchronized czy deklaracji volatile.
public class ConnectionPool { private ConnectionPool() { // kosztowna ręka TCP i tworzenie wątków } private static class Holder { static final ConnectionPool INSTANCE = new ConnectionPool(); } public static ConnectionPool getInstance() { return Holder.INSTANCE; // Uruchamia inicjalizację klasy Holder } }
Problem: Aplikacja do handlu finansowego wymagała singletonu ConnectionPool, który był kosztowny w konstrukcji z powodu początkowych rąk TCP i tworzenia wątków, ale niekoniecznie musiał być potrzebny w niektórych lekkich trybach diagnostycznych. Eager initialization marnowałby setki milisekund podczas uruchamiania, nawet gdy pula pozostawała nieużywana, podczas gdy Double-checked locking wymagałby starannego zarządzania semantyką volatile i barierami porządku, aby zapobiec ponownemu porządkowaniu instrukcji.
Rozwiązanie 1: Eager Initialization: To podejście inicjalizuje pole statyczne w momencie ładowania klasy, co jest trywialne do zaimplementowania i gwarantuje bezpieczeństwo wątków przez JVM. Jednakże nie spełnia wymogu unikania kosztów konstrukcji, gdy pula nigdy nie jest używana, marnując znaczące zasoby w trybach diagnostycznych i niepotrzebnie zwiększając czas uruchamiania wdrożenia.
Rozwiązanie 2: Synchronizowany dostęp: Owinięcie getter w synchronized zapewnia bezpieczeństwo wśród wszystkich wątków i jest proste w kodowaniu. Niestety zmusza każdego wywołującego do pozyskania monitora, nawet po istnieniu instancji, tworząc poważne wąskie gardło przy dużym obciążeniu transakcyjnym, gdzie mikrosekundy mają znaczenie, a wątki walczą o ten sam lock.
Rozwiązanie 3: Idiom holder z inicjalizacją na żądanie: Definiuje prywatną statyczną klasę ConnectionPoolHolder, która zawiera instancję static final ConnectionPool, gdzie getInstance po prostu zwraca ConnectionPoolHolder.INSTANCE. Wykorzystuje leniwe ładowanie klas przez JVM: klasa holder jest inicjalizowana tylko wtedy, gdy getInstance jest wywoływana, a blokada inicjalizacji klasy gwarantuje bezpieczną publikację bez jawnej synchronizacji czy narzutów volatile.
Wybrane rozwiązanie: Zespół wybrał idiom holder ze względu na zerowy narzut na wydajność po inicjalizacji i gwarantowane bezpieczeństwo zgodnie z Java Memory Model, ponieważ doskonale wyważa leniwą inicjalizację z wydajnością czasu wykonania.
Wynik: Aplikacja osiągnęła latencję dostępu poniżej mikrosekundy dla odwołania do puli przy równoległym obciążeniu, opóźniając intensywną inicjalizację do pierwszego użycia, eliminując narzuty uruchamiania w trybach diagnostycznych i pozostając wolną od warunków wyścigu podczas sesji handlowych o dużej objętości.
Co się stanie, gdy konstruktor singletona wyrzuci wyjątek podczas inicjalizacji klasy holder?
Jeżeli static initializer wyrzuci wyjątek, JVM oznacza klasę jako mającą nieudana inicjalizację i wyrzuca ExceptionInInitializerError (opakowując przyczynę). Krytycznie, każdy kolejny wątek próbujący uzyskać dostęp do ConnectionPoolHolder otrzyma NoClassDefFoundError, nawet jeśli przyczyną była przejrzysta (jak tymczasowa niedostępność sieci). W przeciwieństwie do Double-Checked Locking, który może potencjalnie ponownie spróbować konstrukt w blokach catch, idiom holder wymaga zewnętrznej logiki odzyskiwania, ponieważ klasa pozostaje w stanie nieudanej inicjalizacji przez czas życia definiującego ClassLoader.
Czy wzorzec holder z inicjalizacją na żądanie można dostosować do singletonów zakotwiczonych w instancji w wielotenancyjnym kontenerze?
Nie. Wzorzec ten polega ściśle na polach static i blokadach inicjalizacji na poziomie klasy. Dla singletonów zakotwiczonych w instancji lub per-tenant holder musiałby być klasą wewnętrzną kontekstu najemcy, ale blokady inicjalizacji klas są na poziomie ClassLoader, a nie na poziomie instancji kontenera. Prowadzi to do dzielenia instancji pomiędzy najemcami (co stwarza ryzyko bezpieczeństwa i izolacji) lub wymaga jawnej synchronizacji w instancji najemcy, co podważa cel wzorca braku blokady dostępu. Kandydaci często mylą leniwe ładowanie na poziomie klasy z leniwym ładowaniem na poziomie obiektu.
Jak działa ten idiom, gdy w aplikacji serwerowej zaangażowane są różne hierarchie ClassLoader?
Każdy ClassLoader samodzielnie inicjalizuje swoją własną kopię klasy holder. W Tomcat lub WildFly, jeśli klasa singletona jest obecna zarówno w aplikacji webowej, jak i w wspólnym loaderze nadrzędnym, lub jeśli aplikacja webowa jest ponownie wdrażana (tworząc nowy ClassLoader), będą istnieć wyraźne instancje. Narusza to umowę singletona w całym procesie JVM. Wzorzec ten zapewnia bezpieczeństwo wątków w obrębie jednego namespace ładowania klas, ale nie zapewnia globalnej semantyki singletona JVM, co jest kluczowym rozróżnieniem w złożonych środowiskach, gdzie wymusza się izolację ładowania klas.