Przed Java 5 Model Pamięci Javy (JMM) cierpiał z powodu słabych gwarancji widoczności pamięci, co sprawiało, że wiele popularnych idiomów współbieżnych było niebezpiecznych. Wzorzec Double-Checked Locking pojawił się pod koniec lat 90. jako rzekome zwiększenie wydajności dla leniwej inicjalizacji, ale miał zasadniczy błąd dotyczący reorganizacji instrukcji. JSR-133 zdefiniował semantykę słowa kluczowego volatile w 2004 roku, aby zapewnić porządek pamięci typu acquire-release, szczególnie w celu rozwiązania problemów z widocznością, bez kosztów pełnej synchronizacji.
Bez volatile, JVM i architektury CPU mogą reorganizować instrukcje w taki sposób, że przypisanie referencji do zmiennej następuje przed zakończeniem wykonania konstruktora. Tworzy to okno, w którym inny wątek może zaobserwować nienaładowaną referencję do obiektu, którego pola zawierają wartości domyślne lub niezainitialized, co prowadzi do nieprzewidywalnego zachowania lub NullPointerException. Niebezpieczeństwo współbieżności jest szczególnie podstępne, ponieważ objawia się tylko w specyficznych warunkach czasowych i modelach pamięci sprzętowej, co utrudnia jego odtworzenie podczas testowania.
Deklaracja pola instancji jako volatile wprowadza barierę pamięci, która ustanawia relację happens-before między zapisem w konstruktorze a wszelkimi późniejszymi odczytami przez inne wątki. To zapobiega reorganizacji zapisu do pola volatile z poprzednimi zapisami w konstruktorze, zapewniając, że obiekt jest w pełni skonstruowany zanim jego referencja stanie się widoczna. Wzorzec pozwala wątkom sprawdzać referencję bez blokowania po inicjalizacji, zapewniając zarówno bezpieczeństwo wątków, jak i wysoką wydajność.
public class ConnectionPool { private static volatile ConnectionPool instance; private ConnectionPool() { // Ciężka inicjalizacja } public static ConnectionPool getInstance() { if (instance == null) { synchronized (ConnectionPool.class) { if (instance == null) { instance = new ConnectionPool(); } } } return instance; } }
Mikrousługa o dużej wydajności zajmująca się przetwarzaniem płatności wymagała singletona ConnectionPool do zarządzania połączeniami JDBC z klastrem PostgreSQL. W czasie szczytowego ruchu, tysiące wątków jednocześnie wywoływało getInstance() podczas pierwszego uruchomienia usługi, co wymagało strategii inicjalizacji bezpiecznej dla wątków, minimalizującej contention na blokadach. Sekwencja inicjalizacji obejmowała ustanawianie gniazd TCP, alokację bezpośrednich buforów bajtowych i wykonywanie zapytań do walidacji schematu, co sprawiało, że eager instantiation było nieproporcjonalnie drogie w scenariuszach autoskalowania.
Eager Initialization polegało na stworzeniu puli w bloku inicjalizacji statycznej. To podejście zapewniało bezpieczeństwo dla wątków poprzez mechanikę ładowania klas i całkowicie eliminowało potrzebę bloków synchronized. Jednak nawiązywanie połączenia wymagało trzech sekund handshake'ów TCP i wymiany poświadczeń, co naruszało umowy o poziomie usług dla czasów zimnego startu podczas wydarzeń autoskalowania.
Synchronized Method opakowywał metodę getInstance() słowem kluczowym synchronized. Choć poprawiło to wyścig, seryjizując cały dostęp, wprowadziło to poważne pogorszenie wydajności pod obciążeniem. Profilowanie ujawniło, że po inicjalizacji wątki spędzały niepotrzebne cykle na akwizycji blokady monitora, mimo niezmiennej natury pełni skonstruowanej puli, dodając około 18 milisekund opóźnienia na wywołanie.
Double-Checked Locking with volatile zostało wybrane jako optymalne podejście. To rozwiązanie używało niesynchronizowanej szybkiej ścieżki, aby sprawdzić null, a następnie blokady synchronized dla sekcji krytycznej, z drugim sprawdzeniem null w środku w celu zapobieżenia wielokrotnym instancjacjom. Modyfikator volatile zapewnił, że w pełni zainicjowany stan puli był widoczny dla wszystkich rdzeni CPU natychmiast po publikacji, równoważąc leniwą inicjalizację z zerowym narzutem blokady po uruchomieniu.
Wybrane rozwiązanie usunęło warunki wyścigu podczas uruchamiania, jednocześnie utrzymując dostęp bez blokad w czasie stabilnym, zapobiegając obserwowanym wcześniej instancjom NullPointerException, które miały miejsce w scenariuszach o wysokiej współbieżności. Monitorowanie potwierdziło, że JVM poprawnie obsługiwał widoczność pamięci na wszystkich 64 rdzeniach bez explicite synchronizacji po ustanowieniu singletona.
Dlaczego wzorzec podwójnego sprawdzenia blokady wymaga dwóch odrębnych sprawdzeń null zamiast jednego synchronizowanego sprawdzenia?
Pierwsze sprawdzenie działa poza blokiem synchronized, aby zapewnić szybką, bezblokową ścieżkę dla powszechnego przypadku, w którym instancja już istnieje. Drugie sprawdzenie wewnątrz bloku synchronized jest istotne, ponieważ wiele wątków może jednocześnie przejść przez pierwsze sprawdzenie null, gdy instancja jest jeszcze niezainitialized. Bez tej drugiej weryfikacji każdy wątek sequentialnie zajmie blokadę i stworzy oddzielne instancje, naruszając właściwość singletona. Wewnętrzne sprawdzenie zapewnia, że tylko pierwszy wątek wchodzący do sekcji krytycznej wykonuje konstrukcję, podczas gdy kolejne wątki odkrywają, że instancja jest już zainicjowana i pomijają tworzenie.
Jak Model Pamięci Javy odróżnia gwarancje widoczności zapisu volatile i wyjścia z bloku synchronized?
Oba konstrukcje ustanawiają relacje happens-before, ale działają na różnych granularności i cechach wydajnościowych. Wyjście z bloku synchronized opróżnia wszystkie zmodyfikowane zmienne w pamięci roboczej wątku do pamięci głównej, działając jako globalna bariera pamięci. W przeciwieństwie do tego, zapis volatile konkretnie zapobiega reorganizacji tej konkretnej zmiennej z otaczającymi instrukcjami i zapewnia, że zapis jest natychmiast widoczny. Przed Java 5 volatile nie miał tych gwarancji, co czyniło go niewystarczającym do bezpiecznego publikowania; nowoczesny JMM traktuje zapisy volatile podobnie jak operacje release w C++ i odczyty jako operacje acquire, zapewniając ukierunkowaną widoczność bez pełnych kosztów blokowania monitorów.
Czy obiekty niemutowalne mogą wyeliminować potrzebę volatile w wzorcu podwójnego sprawdzenia blokady?
Nie, ponieważ pola final gwarantują niemutowalność tylko po zakończeniu konstruktora, a nie podczas publikacji samej referencji. Bez volatile, reorganizacja instrukcji może spowodować zapis referencji do pamięci głównej przed zakończeniem wykonywania konstruktora, co pozwala innemu wątkowi zaobserwować nienaładowaną referencję do częściowo skonstruowanego obiektu. Chociaż pola final zapewniają, że wartości nie mogą się zmienić po konstrukcji, nie zapobiegają widoczności wartości domyślnych lub niezainitialized, jeśli referencja ucieknie zbyt wcześnie. Bezpieczne publikowanie wymaga albo volatile, albo synchronized, aby zapewnić relację happens-before między konstrukcją a widocznością, niezależnie od wewnętrznej niemutowalności obiektu.