Historia synchronizacji ClassLoader sięga pierwotnej specyfikacji JVM, która wymagała bezpiecznego ładowania klas wątków, ale początkowo zapewniała tylko współdzielone blokowanie na monitorze instancji ClassLoader. Przed Java 7 każde wywołanie loadClass() synchronizowało się na this, tworząc globalne wąskie gardło w środowiskach wielowątkowych, takich jak serwery aplikacji, gdzie równoczesne ładowanie klas jest powszechne. Java 7 wprowadziła API registerAsParallelCapable(), pozwalające ładowarkom na wybór drobnoziarnistych schematów blokowania, które dramatycznie poprawiają przezroczystość.
Główny problem wynika z rekurencyjnej natury modelu rodzic-dziecko, połączonej z synchronizowanymi metodami. Gdy potomny ClassLoader przysłoni loadClass() i synchronizuje się na własnej instancji, trzyma ten blokadę podczas wywoływania parent.loadClass(), przejmując tym samym blokadę rodzica. W złożonych hierarchiach — takich jak bundle OSGi z dwukierunkowymi importami pakietów lub architekturami wtyczek z wymaganiami dotyczących cyklicznej widoczności — prowadzi to do klasycznych cykli kolejkowania blokad, w których Wątek-A trzyma Child-A i czeka na Parent, podczas gdy Wątek-B trzyma Parent i czeka na Child-A.
Rozwiązanie przenosi synchronizację z instancji ładowarki na nazwę klasy, która jest ładowana. Gdy registerAsParallelCapable() jest wywoływane w statycznym inicjalizatorze ClassLoader, JVM utrzymuje ConcurrentHashMap równoległych ładowarek i blokuje na zintereneowanej nazwie klasy, a nie na obiekcie ładowarki. Umożliwia to równoczesne ładowanie różnych klas przez różne wątki w tej samej ładowarce. Jednak wprowadza to nowe zagrożenie: jeśli Ładowarka-A blokuje na nazwie klasy „X” i deleguje do Ładowarki-B w celu uzyskania zależności, podczas gdy Ładowarka-B jednocześnie blokuje na nazwie klasy „Y” i deleguje z powrotem do Ładowarki-A dla „X”, wątki wchodzą w cykliczne oczekiwanie na różne nazwy klas w różnych przestrzeniach nazw ładowarek — zakleszczenie niewidoczne dla standardowej analizy monitorów.
Platforma handlu o wysokiej częstotliwości wdrożyła modułowy silnik strategii, w którym każde algorytmiczne jar ładowane przez potomnych URLClassLoader odnosiło się do wspólnego rodzica dla klas danych rynkowych. Podczas otwarcia rynku 500 wątków jednocześnie aktywowało strategie, co spowodowało ogromne przeciążenie monitora rodzica ładowarki, co skutkowało utratą możliwości handlowych.
Rozwiązanie 1: Domyślna Synchronizacja
Początkowa implementacja opierała się na dziedziczonej metodzie synchronized loadClass. Choć zapewniała spójność ** happens-before**, to podejście zserializowało całe ładowanie klas przez jeden monitor. Profilowanie wydajności ujawniło, że 95% wątków było zablokowanych, czekając na blokadę rodzica ClassLoader, co ograniczyło efektywną wydajność do poziomów jednowątkowych podczas krytycznego okna startowego.
Rozwiązanie 2: Niesynchronizowane Ładowanie
Programiści próbowali całkowicie usunąć synchronizację, zakładając, że niezmienne zawartości jar zapewniają idempotentne ładowanie. To spowodowało wiele różnych obiektów Class dla identycznych definicji w tej samej ładowarce, co spowodowało LinkageError oraz tajemnicze komunikaty ClassCastException stwierdzające, że „Strategia nie może być rzucona na Strategię” z powodu zduplikowanych definicji klas ładowanych przez rywalizujące wątki.
Rozwiązanie 3: Rejestracja Zdolna do Równoległego Działania
Zespół wdrożył registerAsParallelCapable() w niestandardowej podklasie ClassLoader, ściśle przysłaniając findClass() zamiast loadClass(), aby zachować mechanizm równoległego blokowania. Umożliwiło to równoczesne rozwiązywanie różnych nazw klas przy zachowaniu łańcucha delegacji rodzicielskiej. Rozwiązanie wymagało przekształcenia hierarchii wtyczek, aby wyeliminować cykliczne zależności pakietów między rodzeńiem. Rezultat: czas uruchamiania spadł z 120 sekund do 8 sekund przy pełnym obciążeniu, bez wykrycia jakichkolwiek zakleszczeń ClassLoader w ciągu sześciu miesięcy produkcyjnego handlu.
Dlaczego przysłonięcie loadClass() zamiast findClass() w cichym sposobie dezaktywuje optymalizacje zdolne do równoległego działania?
Mechanizm zdolny do równoległego działania wbudowuje drobnoziarniste blokowanie w metodzie szablonowej loadClass(String name, boolean resolve) dostarczonej przez JDK. Gdy podklasa przysłania loadClass(String), omija wewnętrzną logikę, która zdobywa blokady na określonych nazwach klas za pośrednictwem wewnętrznej parallelLockMap ClassLoader. Podklasa niezamierzenie wraca do niesynchronizowanego dostępu — powodując wyścigi z dublującymi się definicjami klas — lub musi ręcznie synchronizować się na this, ponownie wprowadzając globalne wąskie gardło. Odpowiedni wzór deleguje do super.loadClass() w celu sprawdzenia pamięci podręcznej i delegacji rodzica, ograniczając niestandardową logikę konwersji tablicy bajtów na klasę do findClass(), która wykonuje się w już ustalonym kontekście blokady specyficznej dla nazwy.
Jak wzorce ServiceLoader mogą wywoływać zakleszczenia nawet z równoległowo zdolnymi ClassLoaders?
Kiedy ServiceLoader działający w rodzicielskiej ClassLoader próbuje zainstalować implementację usługi znajdującą się w Child-A, nieświadomie wywołuje Child-A.loadClass(). Jeśli ta klasa implementacyjna wywołuje inicjalizację statyczną (<clinit>), która ładuje klasę pomocniczą z rodzica (np. logger), a inny wątek trzyma blokadę rodzica w oczekiwaniu na załadowanie innej implementacji usługi z Child-A, tworzy się cykliczne oczekiwanie. Wątek-1 trzyma blokadę nazwy klasy rodzica dla „Logger” i czeka na blokadę Child-A dla „ServiceImpl”. Wątek-2 trzyma blokadę Child-A dla „ServiceImpl” (z powodu początkowego wywołania ServiceLoader) i czeka na blokadę rodzica dla „Logger”. To ładowanie klas między ładowarkami podczas inicjalizacji tworzy łańcuchy zakleszczeń, które standardowi analitycy zrzutów wątków mają trudności z identyfikacją, ponieważ monitorują instancje monitorów ClassLoader, a nie wewnętrzne blokady nazwowe.
Co to jest wyścig warunkowy „defineClass window” i dlaczego zdolność równoległa go nie zapobiega?
Zdolność równoległa zapewnia, że operacje loadClass dla tej samej nazwy klasy są zserializowane, ale defineClass() pozostaje odrębną operacją natywną podatną na wyścigi. Jeśli niestandardowa ładowarka wdraża zewnętrzne buforowanie lub transformację bajtów poza standardowym sprawdzeniem findLoadedClass — na przykład w agencie Java, który przechwytuje loadClass — dwa wątki mogą jednocześnie przejść weryfikację „nie załadowano” i wywołać defineClass(byte[], ...) dla tej samej nazwy binarnej. Drugi wątek otrzymuje LinkageError: próba zdefiniowania zduplikowanej klasy. Dzieje się tak, ponieważ sprawdzenie i wstawienie SystemDictionary są atomowe na poziomie JVM, ale okno między niestandardowym sprawdzeniem a wywołaniem defineClass nie jest chronione przez blokadę nazwy zdolną do równoległego działania, chyba że kod ściśle stosuje wzór metody szablonowej bez zewnętrznych skutków ubocznych lub dodatkowej synchronizacji.