JavaprogramowanieStarszy programista Java

Jakie konkretne ograniczenia cyklu życia osiąгаń uniemożliwiają bezpieczne przywracanie obiektu referencyjnego podczas fazy czyszczenia przy zastępowaniu przestarzałych implementacji Object.finalize() za pomocą java.lang.ref.Cleaner?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie.

Historia sięga Java 9, kiedy wprowadzono java.lang.ref.Cleaner jako zastępstwo za przestarzałą metodę Object.finalize(), która cierpiała na nieprzewidywalny czas wykonania, kary wydajnościowe i luki w bezpieczeństwie, w tym ataki przywracania. Głównym problemem jest to, że finalize() pozwalało na przechowywanie odniesienia do this w żywej grafie obiektów, co skutkowało "przywracaniem" obiektu, który został już uznany przez zbieracz śmieci jako niedostępny, naruszając tym samym inwariant jednolitej konstrukcji i destrukcji oraz potencjalnie pozostawiając zasoby natywne w niespójnym stanie.

Rozwiązanie opiera się na semantyce PhantomReference w implementacji Cleaner: akcja czyszczenia otrzymuje tylko obiekt Runnable lub akcję czyszczenia, a nie sam referent, co gwarantuje, że referent znajduje się w stanie osiągalności fantomowej — co oznacza, że ​​już został wyczyszczony i nie może być przywrócony — zapewniając, że logika czyszczenia działa na obiekcie, który jest nieodwracalnie niedostępny.

public class NativeResource { private static final Cleaner cleaner = Cleaner.create(); private final long nativeHandle; public NativeResource() { nativeHandle = allocateNative(); cleaner.register(this, new CleanupAction(nativeHandle)); Reference.reachabilityFence(this); } private static class CleanupAction implements Runnable { private final long handle; CleanupAction(long handle) { this.handle = handle; } @Override public void run() { releaseNative(handle); } } private native long allocateNative(); private static native void releaseNative(long handle); }

Sytuacja z życia

Nasz zespół zarządzał wysokoprzepustowym przetwarzaniem obrazów, w którym obiekty ImageProcessor opakowywały natywne bufory OpenCV alokowane za pomocą JNI. Początkowo polegaliśmy na finalize(), aby wywołać cvReleaseImage(), ale sporadycznie napotykaliśmy na wyczerpanie pamięci natywnej pomimo stabilności stosu Java, co towarzyszyły sporadyczne błędy segmentacji sugerujące błędy użycia po zwolnieniu w pamięci natywnej.

Pierwsze podejście polegało na zachowaniu finalize(), ale dodaniu ochrony przed przywracaniem, gdzie synchronizowaliśmy na statycznej mapie, aby śledzić "martwe" obiekty. To cierpiało na nieprzewidywalne opóźnienia - finalizacja mogła wystąpić minuty po rozpoczęciu presji na stertę - a sama logika ochrony tworzyła wycieki pamięci, utrzymując silne odniesienia do "martwych" obiektów w mapie śledzenia, ironicznie uniemożliwiając ich zbieranie całkowicie, jednocześnie pozwalając na wyścigi przywracania.

Drugie podejście polegało na naiwnej wykorzystaniu java.lang.ref.Cleaner przez uchwycenie natywnego wskaźnika w lambdach w konstruktorze instancji: cleaner.register(this, () -> free(pointer)). Choć to unikało opóźnień finalizacji, ryzykowało przedwczesne zbieranie, jeśli this uciekłby podczas konstrukcji, a krytycznie, jeśli lambda przypadkowo zamknęłaby się nad instancją ImageProcessor zamiast tylko nad wartością pointer, utworzyłoby to cykl silnych odniesień, uniemożliwiając zbieranie śmieci odniesienia, chociaż wciąż uniemożliwia przywracanie per se.

Wybraliśmy trzecie podejście: wdrożenie statycznej zagnieżdżonej klasy CleaningAction, która przechowywała tylko natywny wskaźnik (jako long) i implementowała Runnable, całkowicie odseparowaną od zewnętrznej instancji ImageProcessor. Zarejestrowaliśmy akcję czyszczenia natychmiast po pomyślnej alokacji natywnej i wyraźnie wywołaliśmy Reference.reachabilityFence(this) na końcu konstruktora, aby upewnić się, że obiekt pozostaje osiągalny do ukończenia rejestracji. To wyeliminowało ryzyko przywracania i wycieków natywnych, redukując incydenty dotyczące ciśnienia pamięci z codziennych wystąpień do zera przez sześć miesięcy.

Co często umyka kandydatom

Dlaczego Cleaner używa PhantomReference zamiast WeakReference lub SoftReference, i jak to zapobiega dostępowi akcji czyszczenia do stanu referenta?

PhantomReference jest stosowane, ponieważ pozwala zbieraczowi śmieci zidentyfikować obiekty nadające się do odzyskania, nie pozwalając kodowi programu na dostęp do pól lub metod referenta po wejściu w stan osiągalności fantomowej. W przeciwieństwie do WeakReference, która umożliwia odzyskanie referenta za pomocą get() do czasu zbierania, PhantomReference zawsze zwraca null z get(), zapewniając, że akcja czyszczenia działa na założeniu, że obiekt jest logicznie zniszczony i zapobiega wszelkim próbom przywrócenia lub inspekcji stanu, które mogą naruszyć inwarianty pośmiertne.

Czym jest atak "przywracania" w kontekście Object.finalize(), i dlaczego narusza on gwarancje bezpieczeństwa systemu typów?

Przywracanie następuje, gdy metoda finalize() przechowuje odniesienie this w polu statycznym lub w żywej grafie obiektów, ponownie czyniąc obiekt osiągalnym po tym, jak zbieracz śmieci oznaczył go do odzyskania. Narusza to inwariant, że konstruktor obiektu jest uruchamiany dokładnie raz, a jego finalizator jest uruchamiany najwyżej raz, co pozwala złośliwemu lub błędnemu kodowi obserwować obiekt w częściowo zniszczonym stanie, w którym zasoby natywne są zwalniane, ale pola Java są nadal dostępne, prowadząc do luk w bezpieczeństwie użycia po zwolnieniu i niespójnego zachowania obiektu.

Jak Reference.reachabilityFence wpływa na optymalizacje kompilacji just-in-time JVM, i kiedy jest ściśle konieczne używać go z rejestracjami Cleaner?

Reference.reachabilityFence działa jako bariera kompilatora, która zapobiega optymalizatorowi JVM w reorganizowaniu lub eliminowaniu odniesień obiektów przed ukończeniem krytycznej sekcji, zapobiegając w szczególności "wczesnej publikacji", gdzie obiekt staje się niedostępny, podczas gdy jego konstruktor nadal jest wykonywany. Jest to ściśle konieczne, gdy rejestrujemy obiekty z Cleaner podczas konstrukcji, ponieważ bez tego JVM może stwierdzić, że this nie jest już potrzebne po alokacji zasobu natywnego, ale przed wywołaniem rejestracji, pozwalając cleanerowi na działanie i zwolnienie zasobu, podczas gdy konstruktor nadal inicjalizuje obiekt, co prowadzi do uszkodzenia zasobów.