JavaprogramowanieStarszy programista Java

Jakie niebezpieczeństwo synchronizacji powstaje, gdy jawne zwolnienie zasobów konkuruje z automatycznym sprzątaniem w klasach JDK zarządzających pamięcią natywną, exemplifikowane przez implementację **Inflater**?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Historia: Przed Javą 9 zarządzanie zasobami natywnymi w klasach takich jak Inflater i Deflater opierało się na Object.finalize(). Mechanizm ten został wycofany z powodu nieprzewidywalności, poważnych narzutów wydajności oraz ryzyka odrodzenia obiektów, co opóźniało zbieranie śmieci. Java 9 wprowadziła API Cleaner jako nowoczesną alternatywę, wykorzystującą PhantomReference i ReferenceQueue, aby oddzielić logikę sprzątania od cyklu życia obiektu, zapewniając, że obiekt pozostaje niedostępny podczas sprzątania.

Problem: W implementacji Inflater struktura natywna z_stream musi być jawnie zwolniona za pomocą metody end(), aby zapobiec wyciekom pamięci natywnej. Gdy wątek aplikacji wywołuje end() jawnie, podczas gdy wątek Cleaner jednocześnie próbuje uruchomić zarejestrowaną akcję sprzątania, powstaje warunek wyścigu. Bez odpowiedniej synchronizacji oba wątki mogą próbować zwolnić ten sam wskaźnik natywny, co prowadzi do błędu podwójnego zwolnienia, lub jeden wątek może uzyskać dostęp do zasobu po tym, jak drugi go zwolnił (użycie po zwolnieniu), co skutkuje awariami JVM (SIGSEGV) w natywnej bibliotece zlib.

Rozwiązanie: Rozwiązanie wykorzystuje flagę stanu AtomicBoolean, aby zapewnić, że sprzątanie natywne wykonuje się dokładnie raz, niezależnie od tego, który wątek je inicjuje. Zarówno jawna metoda end(), jak i akcja sprzątania Cleaner wykonują operację porównania i ustawienia (CAS) na tej fladze. Tylko wątek, który pomyślnie przechodzi flagę z false na true, kontynuuje wywołanie rutyny dealokacji natywnej. To podejście bez blokad gwarantuje bezpieczeństwo wątków przy jednoczesnym zachowaniu wysokiej wydajności wymaganej dla operacji kompresji.

Sytuacja z życia

Usługa kompresji logów o wysokiej przepustowości przetwarza miliony wpisów dziennie, używając instancji Deflater w puli, aby zminimalizować narzuty alokacji. Aby zoptymalizować użycie zasobów, deweloperzy zaimplementowali wzorzec zwrotu do puli, który jawnie wywołuje end() na instancjach Deflater przed oddaniem ich z powrotem do puli, polegając jednocześnie na zbieraniu śmieci w celu odzyskania instancji, które wyciekły z powodu nieobsługiwanych wyjątków w potoku przetwarzania.

System doświadczył sporadycznych, ale krytycznych awarii JVM (SIGSEGV) w szczytowych obciążeniach, z zrzutami rdzenia wskazującymi na uszkodzenie pamięci w natywnej bibliotece zlib. Dochodzenie ujawniło, że gdy instancja Deflater była zwracana do puli, wątek aplikacji wywoływał end(), ale jeśli instancja stała się jednocześnie kandydatem do zbierania śmieci, wątek Cleaner również próbował oczyścić ten sam uchwyt natywnego z_stream. Ten niesynchronizowany dostęp do zasobu natywnego powodował, że proces padał w sposób nieprzewidywalny.

Pierwszym rozważanym rozwiązaniem było synchronizowanie każdego dostępu do instancji Deflater przy użyciu bloków lub metod synchronized. To podejście skutecznie zapobiegałoby warunkowi wyścigu, zapewniając wyłączność wzajemną. Niemniej wprowadzało znaczący narzut spowodowany konkurencją w wysoko wydajnym potoku kompresji i narażało na zakleszczenia, jeśli obiekt byłby niepoprawnie dostępny z wielu wątków jednocześnie, naruszając umowę bezpieczeństwa wątków klasy.

Drugie podejście polegało na użyciu AtomicBoolean do śledzenia stanu sprzątania. Zarówno jawna metoda end(), jak i akcja Cleaner atomowo sprawdzałyby i ustawiały tę flagę przed dotknięciem zasobu natywnego. To oferowało bezpieczeństwo bez blokad przy minimalnym narzucie wydajnościowym, chociaż wymagało starannej implementacji, aby zapewnić, że uchwyt natywny nie byłby używany po atomowym sprawdzeniu, ale przed wywołaniem natywnym.

Trzecią opcją było całkowite usunięcie jawnych wywołań end() i poleganie wyłącznie na Cleaner w zarządzaniu zasobami. To całkowicie wyeliminowałoby warunek wyścigu, ale wprowadziłoby nieprzewidywalność w czasie zwolnienia pamięci natywnej, co mogłoby spowodować poważne ciśnienie pamięci podczas pauz zbierania śmieci, jeśli cykle GC opóźniały się w porównaniu do współczynnika alokacji struktur natywnych.

Zespół wybrał podejście AtomicBoolean (Rozwiązanie 2), ponieważ zapewniało ono deterministyczne natychmiastowe sprzątanie, gdy to możliwe (jawne wywołanie), jednocześnie zapewniając bezpieczeństwo w przypadku późniejszego uruchomienia sprzątania. Zmieńli klasę opakowującą, aby zaimplementować AutoCloseable, zapewniając, że sprawdzanie stanu atomowego chroni dealokację natywną. To całkowicie rozwiązało awarie, utrzymując wymaganą przepustowość, eliminując związane z pamięcią natywną awarie w produkcji.

Co często umyka kandydatom

**Jak API Cleaner zapobiega problemowi odrodzenia obiektu, który jest nieodłączny dla Object.finalize()?

W Object.finalize(), obiekt jest nadal osiągalny, gdy metoda finalize() jest wywoływana, ponieważ referencja this pozostaje ważna, co pozwala obiektowi na odrodzenie się poprzez przechowywanie referencji do siebie w polu statycznym. To odrodzenie opóźnia zbieranie śmieci w nieskończoność, jeśli obiekt wielokrotnie się odradza. API Cleaner zapobiega temu, korzystając z PhantomReference. Gdy akcja sprzątania Cleaner jest uruchamiana, referent (obiekt, który jest czyszczony) jest już w stanie phantom reachable, co oznacza, że nie można go odrodzić, ponieważ nie istnieją do niego silne, miękkie ani słabe referencje. Akcja sprzątania jest oddzielnym Runnable, a nie metodą na samym obiekcie, co zapewnia, że obiekt pozostaje niedostępny przez cały proces sprzątania.

Dlaczego Thread.interrupt() jest nieskuteczne w zatrzymywaniu wątku Cleaner podczas zamykania JVM i jakie są tego konsekwencje?

Wątek Cleaner jest wątkiem demona, który ciągle blokuje się na ReferenceQueue.remove(), czekając na dostępne referencje phantom. Chociaż ReferenceQueue.remove() odpowiada na przerwania, rzucając InterruptedException, implementacja Cleaner łapie ten wyjątek i kontynuuje swoją nieskończoną pętlę, skutecznie ignorując przerwania. Ten projekt zapewnia, że krytyczne sprzątanie zasobów kończy się nawet podczas sekwencji zamykania. Jednak jeśli zarejestrowana akcja sprzątania wisząc w nieskończoność (np. czekająca na limit czasu sieciowego lub utknęła w nieskończonej pętli), wątek Cleaner nigdy nie zakończy się. Może to uniemożliwić JVM zamknięcie w sposób płynny, jeśli inne wątki, które nie są demonami, czekają na zasoby, które Cleaner ma zwolnić.

Jakie katastrofalne wycieki pamięci wystąpią, jeśli akcja sprzątania Cleaner przechwytuje silną referencję do obiektu, który jest czyszczony?

Jeśli Runnable przekazywane do Cleaner.register() przechwytuje silną referencję do obiektu (np. za pomocą this::cleanupMethod lub lambdy odwołującej się do this), tworzy to fatalny cykl referencji. Cleaner utrzymuje wewnętrzny zestaw obiektów Cleanable, z których każdy przechowuje referencję do akcji sprzątania Runnable. Jeśli ten Runnable odnosi się do oryginalnego obiektu, obiekt pozostaje silnie osiągalny z wątku Cleaner. W konsekwencji obiekt nigdy nie staje się phantom reachable, PhantomReference nigdy nie trafia do kolejki, a akcja sprzątania nigdy się nie uruchamia. W międzyczasie obiekt nie może być zbierany jako śmieć, co prowadzi do poważnego wycieku pamięci, który rośnie nieograniczenie z każdą zarejestrowaną instancją do Cleaner, ostatecznie prowadząc do OutOfMemoryError.