Finalizacja i czyszczenie obiektów

Trzy miesiące temu rozpocząłem mini serię artykułów o projektowaniu obiektów od omówienia zasad projektowania, które skupiały się na prawidłowej inicjalizacji na początku życia obiektu. W tym artykule Techniki projektowania skupię się na zasadach projektowania, które pomogą Ci zapewnić właściwe czyszczenie pod koniec życia obiektu.

Po co sprzątać?

Każdy obiekt w programie Java korzysta z zasobów obliczeniowych, które są ograniczone. Najwyraźniej wszystkie obiekty używają pamięci do przechowywania swoich obrazów na stercie. (Dotyczy to nawet obiektów, które nie deklarują zmiennych instancji. Każdy obraz obiektu musi zawierać jakiś rodzaj wskaźnika do danych klasy i może również zawierać inne informacje zależne od implementacji). Jednak obiekty mogą również wykorzystywać inne ograniczone zasoby oprócz pamięci. Na przykład niektóre obiekty mogą korzystać z zasobów, takich jak uchwyty plików, konteksty graficzne, gniazda i tak dalej. Kiedy projektujesz obiekt, musisz upewnić się, że ostatecznie zwolni on wszystkie ograniczone zasoby, których używa, aby system nie wyczerpał tych zasobów.

Ponieważ Java jest językiem zbierającym elementy bezużyteczne, zwolnienie pamięci związanej z obiektem jest łatwe. Wszystko, co musisz zrobić, to zwolnić wszystkie odniesienia do obiektu. Ponieważ nie musisz martwić się o jawne zwolnienie obiektu, co jest konieczne w językach takich jak C lub C ++, nie musisz martwić się uszkodzeniem pamięci przez przypadkowe dwukrotne zwolnienie tego samego obiektu. Musisz jednak upewnić się, że faktycznie zwolnisz wszystkie odniesienia do obiektu. Jeśli tego nie zrobisz, możesz skończyć z wyciekiem pamięci, tak jak wycieki pamięci, które otrzymujesz w programie C ++, gdy zapomnisz jawnie zwolnić obiekty. Niemniej jednak, dopóki zwolnisz wszystkie odwołania do obiektu, nie musisz martwić się jawnym „zwolnieniem” tej pamięci.

Podobnie, nie musisz martwić się o jawne zwolnienie jakichkolwiek obiektów składowych, do których odwołują się zmienne instancji obiektu, którego już nie potrzebujesz. Zwolnienie wszystkich odwołań do niepotrzebnego obiektu spowoduje w efekcie unieważnienie wszelkich odwołań do obiektów składowych zawartych w zmiennych instancji tego obiektu. Jeśli unieważnione odwołania były jedynymi pozostałymi odwołaniami do tych obiektów składowych, obiekty składowe będą również dostępne do czyszczenia pamięci. Bułka z masłem, prawda?

Zasady zbierania śmieci

Chociaż czyszczenie pamięci rzeczywiście sprawia, że ​​zarządzanie pamięcią w Javie jest o wiele łatwiejsze niż w C lub C ++, nie możesz całkowicie zapomnieć o pamięci podczas programowania w Javie. Aby wiedzieć, kiedy trzeba pomyśleć o zarządzaniu pamięcią w Javie, musisz trochę wiedzieć o sposobie traktowania czyszczenia pamięci w specyfikacjach Java.

Wyrzucanie elementów bezużytecznych nie jest wymagane

Pierwszą rzeczą, którą należy wiedzieć, jest to, że bez względu na to, jak pilnie przeszukujesz specyfikację maszyny wirtualnej Java (specyfikacja JVM), nie będziesz w stanie znaleźć żadnego zdania, które poleca, każda maszyna JVM musi mieć moduł odśmiecania pamięci. Specyfikacja Java Virtual Machine Specification daje projektantom maszyn wirtualnych dużą swobodę w decydowaniu o tym, jak ich implementacje będą zarządzać pamięcią, w tym w podejmowaniu decyzji, czy w ogóle używać czyszczenia pamięci, czy też nie. Dlatego możliwe jest, że niektóre maszyny JVM (takie jak wirtualna maszyna JVM z kartą inteligentną) mogą wymagać, aby programy wykonywane w każdej sesji „pasowały” do dostępnej pamięci.

Oczywiście zawsze możesz zabraknąć pamięci, nawet w systemie pamięci wirtualnej. Specyfikacja maszyny JVM nie określa, ile pamięci będzie dostępna dla maszyny JVM. To po prostu stwierdza, że gdy JVM nie zabraknie pamięci, należy go wyrzucić OutOfMemoryError.

Niemniej jednak, aby zapewnić aplikacjom Java największą szansę na uruchomienie bez wyczerpania pamięci, większość maszyn JVM będzie używać modułu odśmiecania pamięci. Moduł odśmiecania pamięci odzyskuje pamięć zajmowaną przez obiekty na stercie, do których nie istnieją odniesienia, dzięki czemu pamięć może być ponownie wykorzystana przez nowe obiekty i zazwyczaj usuwa fragmenty sterty w trakcie działania programu.

Algorytm czyszczenia pamięci nie jest zdefiniowany

Innym poleceniem, którego nie znajdziesz w specyfikacji maszyny JVM, jest to, że wszystkie maszyny JVM korzystające z funkcji czyszczenia pamięci muszą używać algorytmu XXX. Projektanci każdej maszyny JVM mogą decydować, jak będzie działać czyszczenie pamięci w ich implementacjach. Algorytm czyszczenia pamięci to jeden z obszarów, w którym dostawcy JVM mogą dążyć do tego, aby ich implementacja była lepsza niż konkurencja. Jest to istotne dla Ciebie jako programisty Java z następującego powodu:

Ponieważ na ogół nie wiesz, jak będzie wykonywane czyszczenie pamięci wewnątrz maszyny JVM, nie wiesz, kiedy dany obiekt zostanie usunięty.

Więc co? możesz zapytać. Powód, dla którego możesz się przejmować, kiedy obiekt jest zbierany jako śmieci, ma związek z finalizatorami. ( Finalizator jest zdefiniowany jako zwykła metoda instancji Java o nazwie, finalize()która zwraca wartość void i nie pobiera żadnych argumentów). Specyfikacje języka Java dają następującą obietnicę dotyczącą finalizatorów:

Przed odzyskaniem pamięci zajmowanej przez obiekt, który ma finalizator, moduł wyrzucania elementów bezużytecznych wywoła finalizator tego obiektu.

Biorąc pod uwagę, że nie wiesz, kiedy obiekty będą zbierane jako śmieci, ale wiesz, że obiekty, które można sfinalizować, zostaną sfinalizowane, ponieważ są zbierane jako śmieci, możesz dokonać następującego wielkiego odliczenia:

Nie wiesz, kiedy obiekty zostaną sfinalizowane.

Powinieneś odcisnąć ten ważny fakt w swoim mózgu i na zawsze pozwolić mu wpływać na projekty obiektów Java.

Finalizatory, których należy unikać

Główna zasada dotycząca finalizatorów jest następująca:

Nie projektuj programów Java w taki sposób, aby poprawność zależała od „terminowego” finalizacji.

Innymi słowy, nie pisz programów, które zepsują się, jeśli pewne obiekty nie zostaną sfinalizowane w określonych punktach w życiu wykonywania programu. Jeśli napiszesz taki program, może on działać na niektórych implementacjach JVM, ale zawieść na innych.

Nie polegaj na finalizatorach, aby zwolnić zasoby inne niż pamięć

Przykładem obiektu, który łamie tę regułę, jest taki, który otwiera plik w swoim konstruktorze i zamyka plik w swojej finalize()metodzie. Chociaż ten projekt wydaje się schludny, uporządkowany i symetryczny, potencjalnie tworzy podstępny błąd. Program w języku Java ma zazwyczaj do dyspozycji skończoną liczbę uchwytów plików. Gdy wszystkie te uchwyty są w użyciu, program nie będzie mógł otworzyć więcej plików.

Program Java, który wykorzystuje taki obiekt (taki, który otwiera plik w swoim konstruktorze i zamyka go w finalizatorze) może działać dobrze na niektórych implementacjach JVM. W takich implementacjach finalizacja występowałaby wystarczająco często, aby zapewnić dostęp do wystarczającej liczby uchwytów plików przez cały czas. Ale ten sam program może zawieść na innej JVM, której moduł odśmiecania pamięci nie finalizuje wystarczająco często, aby zapobiec wyczerpaniu uchwytów plików. Lub, co jest jeszcze bardziej podstępne, program może działać teraz na wszystkich wdrożeniach JVM, ale zawieść w krytycznej sytuacji przez kilka lat (i cykle wydawania) w przyszłości.

Inne praktyczne zasady dotyczące finalizatora

Dwie inne decyzje pozostawione projektantom JVM to wybór wątku (lub wątków), który będzie wykonywał finalizatory, oraz kolejność, w jakiej finalizatory będą uruchamiane. Finalizatory mogą być uruchamiane w dowolnej kolejności - sekwencyjnie przez pojedynczy wątek lub jednocześnie przez wiele wątków. Jeśli twój program w jakiś sposób zależy od poprawności finalizatorów uruchamianych w określonej kolejności lub przez określony wątek, może działać na niektórych implementacjach JVM, ale zawieść na innych.

Należy również pamiętać, że Java uważa, że ​​obiekt jest sfinalizowany, niezależnie od tego, czy finalize()metoda zwraca normalnie, czy kończy się nagle, zgłaszając wyjątek. Odśmiecacze pamięci ignorują wszelkie wyjątki zgłaszane przez finalizatory i w żaden sposób nie powiadamiają reszty aplikacji o zgłoszeniu wyjątku. Jeśli chcesz upewnić się, że konkretny finalizator w pełni wykona określoną misję, musisz napisać ten finalizator tak, aby obsługiwał wszelkie wyjątki, które mogą wystąpić, zanim finalizator zakończy swoją misję.

Jeszcze jedna praktyczna zasada dotycząca finalizatorów dotyczy obiektów pozostawionych na stercie pod koniec okresu istnienia aplikacji. Domyślnie moduł odśmiecania pamięci nie wykonuje finalizatorów żadnych obiektów pozostawionych na stercie po zamknięciu aplikacji. Aby zmienić to ustawienie domyślne, należy wywołać runFinalizersOnExit()metodę class Runtimeor System, przekazując truejako pojedynczy parametr. Jeśli twój program zawiera obiekty, których finalizatory muszą być bezwzględnie wywołane przed zakończeniem programu, pamiętaj, aby wywołać je runFinalizersOnExit()gdzieś w swoim programie.

Więc do czego służą finalizatory?

Do tej pory możesz mieć wrażenie, że finalizatorzy nie są dla ciebie zbyt przydatni. Chociaż jest prawdopodobne, że większość zaprojektowanych przez Ciebie klas nie będzie zawierała finalizatora, istnieje kilka powodów, dla których warto używać finalizatorów.

Jednym rozsądnym, choć rzadkim zastosowaniem finalizatora jest zwolnienie pamięci przydzielonej metodami natywnymi. Jeśli obiekt wywołuje natywną metodę, która przydziela pamięć (na przykład wywoływaną funkcję C malloc()), finalizator tego obiektu może wywołać metodę natywną, która zwalnia tę pamięć (wywołania free()). W takiej sytuacji finalizator byłby używany do zwolnienia pamięci przydzielonej w imieniu obiektu - pamięci, która nie zostanie automatycznie odzyskana przez moduł odśmiecania pamięci.

Innym, bardziej powszechnym zastosowaniem finalizatorów jest zapewnienie mechanizmu rezerwowego do zwalniania skończonych zasobów niezwiązanych z pamięcią, takich jak uchwyty plików lub gniazda. Jak wspomniano wcześniej, nie należy polegać na finalizatorach przy zwalnianiu skończonych zasobów niezwiązanych z pamięcią. Zamiast tego powinieneś podać metodę, która zwolni zasób. Ale możesz także chcieć dołączyć finalizator, który sprawdza, czy zasób został już zwolniony, a jeśli nie, to przechodzi dalej i zwalnia go. Taki finalizator chroni przed niechlujnym używaniem twojej klasy (i miejmy nadzieję, że nie zachęci). Jeśli programista klienta zapomni wywołać podaną metodę w celu zwolnienia zasobu, finalizator zwolni zasób, jeśli obiekt zostanie kiedykolwiek wyrzucony do pamięci. finalize()SposóbLogFileManager class, pokazany w dalszej części tego artykułu, jest przykładem tego rodzaju finalizatora.

Unikaj nadużyć finalizatora

Finalizacja stwarza interesujące komplikacje dla maszyn JVM i kilka interesujących możliwości dla programistów Java. To, co finalizacja daje programistom, to władza nad życiem i śmiercią obiektów. Krótko mówiąc, w Javie jest możliwe i całkowicie legalne wskrzeszanie obiektów w finalizatorach - przywracanie ich do życia poprzez ponowne odwoływanie się do nich. (Jednym ze sposobów, w jaki finalizator mógłby to osiągnąć, jest dodanie odniesienia do finalizowanego obiektu do statycznej, połączonej listy, która wciąż jest „aktywna”). Chociaż taka moc może być kusząca, aby ćwiczyć, ponieważ sprawia, że ​​czujesz się ważny, praktyczna zasada to oprzeć się pokusie użycia tej mocy. Ogólnie rzecz biorąc, wskrzeszanie obiektów w finalizatorach stanowi nadużycie finalizatora.

Głównym uzasadnieniem tej reguły jest to, że każdy program, który używa resurrection, może zostać przeprojektowany na łatwiejszy do zrozumienia program, który nie używa resurrection. Formalny dowód tego twierdzenia pozostawia się czytelnikowi jako ćwiczenie (zawsze chciałem to powiedzieć), ale w nieformalnym duchu rozważ, że wskrzeszenie przedmiotu będzie tak samo przypadkowe i nieprzewidywalne jak finalizacja przedmiotu. W związku z tym projekt korzystający z resurrection będzie trudny do odgadnięcia przez kolejnego programistę zajmującego się konserwacją, który może nie w pełni zrozumieć specyfiki czyszczenia pamięci w Javie.

Jeśli czujesz, że po prostu musisz przywrócić obiekt do życia, rozważ sklonowanie nowej kopii obiektu zamiast wskrzeszania tego samego starego obiektu. Powodem tej rady jest to, że moduły odśmiecania pamięci w JVM wywołują finalize()metodę obiektu tylko raz. Jeśli ten obiekt zostanie wskrzeszony i stanie się dostępny do czyszczenia pamięci po raz drugi, finalize()metoda obiektu nie zostanie ponownie wywołana.