Przewodnik po metodzie finalizacji w Javie

1. Przegląd

W tym samouczku skupimy się na podstawowym aspekcie języka Java - metodzie finalize udostępnianej przez główną klasę Object .

Mówiąc najprościej, jest to wywoływane przed wyrzucaniem elementów bezużytecznych dla określonego obiektu.

2. Korzystanie z finalizatorów

Metoda finalize () jest nazywana finalizatorem.

Finalizatory są wywoływane, gdy JVM ustali, że ta konkretna instancja powinna zostać usunięta. Taki finalizator może wykonywać dowolne operacje, w tym przywracać obiekt do życia.

Głównym celem finalizatora jest jednak zwolnienie zasobów używanych przez obiekty, zanim zostaną one usunięte z pamięci. Finalizator może działać jako główny mechanizm operacji czyszczenia lub jako siatka bezpieczeństwa, gdy inne metody zawiodą.

Aby zrozumieć, jak działa finalizator, przyjrzyjmy się deklaracji klasy:

public class Finalizable { private BufferedReader reader; public Finalizable() { InputStream input = this.getClass() .getClassLoader() .getResourceAsStream("file.txt"); this.reader = new BufferedReader(new InputStreamReader(input)); } public String readFirstLine() throws IOException { String firstLine = reader.readLine(); return firstLine; } // other class members }

Klasa Finalizable ma czytnik pól , który odwołuje się do zamykanego zasobu. Kiedy obiekt jest tworzony z tej klasy, konstruuje on nową instancję BufferedReader odczytującą z pliku w ścieżce klas.

Taka instancja jest wykorzystywana w metodzie readFirstLine do wyodrębnienia pierwszej linii z danego pliku. Zauważ, że czytnik nie jest zamknięty w podanym kodzie.

Możemy to zrobić za pomocą finalizatora:

@Override public void finalize() { try { reader.close(); System.out.println("Closed BufferedReader in the finalizer"); } catch (IOException e) { // ... } }

Łatwo zauważyć, że finalizator jest deklarowany tak jak każda normalna metoda instancji.

W rzeczywistości czas, w którym garbage collector wywołuje finalizatory, zależy od implementacji maszyny JVM i warunków systemu, na które nie mamy wpływu.

Aby wyrzucać śmieci na miejscu, skorzystamy z metody System.gc . W rzeczywistych systemach nigdy nie powinniśmy odwoływać się do tego jawnie z kilku powodów:

  1. To jest kosztowne
  2. Nie uruchamia natychmiast czyszczenia pamięci - to tylko wskazówka dla maszyny JVM, aby uruchomić GC
  3. JVM wie lepiej, kiedy należy wywołać GC

Jeśli musimy wymusić GC, możemy użyć do tego jconsole .

Poniżej znajduje się przypadek testowy demonstrujący działanie finalizatora:

@Test public void whenGC_thenFinalizerExecuted() throws IOException { String firstLine = new Finalizable().readFirstLine(); assertEquals("baeldung.com", firstLine); System.gc(); }

W pierwszej instrukcji tworzony jest obiekt Finalizable , a następnie wywoływana jest jego metoda readFirstLine . Ten obiekt nie jest przypisany do żadnej zmiennej, dlatego kwalifikuje się do czyszczenia pamięci, gdy wywoływana jest metoda System.gc .

Asercja w teście weryfikuje zawartość pliku wejściowego i służy tylko do udowodnienia, że ​​nasza niestandardowa klasa działa zgodnie z oczekiwaniami.

Po uruchomieniu dostarczonego testu na konsoli zostanie wydrukowany komunikat o zamknięciu buforowanego czytnika w finalizatorze. Oznacza to, że wywołano metodę finalize i wyczyściła ona zasób.

Do tego momentu finalizatory wyglądają na świetny sposób na operacje przed zniszczeniem. Jednak to nie do końca prawda.

W następnej sekcji zobaczymy, dlaczego należy ich unikać.

3. Unikanie finalizatorów

Pomimo korzyści, jakie przynoszą, finalizatorzy mają wiele wad.

3.1. Wady finalizatorów

Przyjrzyjmy się kilku problemom, które napotkamy podczas używania finalizatorów do wykonywania krytycznych działań.

Pierwszą zauważalną kwestią jest brak punktualności. Nie możemy wiedzieć, kiedy finalizator działa, ponieważ wyrzucanie elementów bezużytecznych może wystąpić w dowolnym momencie.

Samo w sobie nie stanowi to problemu, ponieważ finalizator nadal działa, wcześniej czy później. Jednak zasoby systemowe nie są nieograniczone. W związku z tym mogą nam zabraknąć zasobów, zanim nastąpi czyszczenie, co może spowodować awarię systemu.

Finalizatory mają również wpływ na przenośność programu. Ponieważ algorytm czyszczenia pamięci zależy od implementacji maszyny JVM, program może działać bardzo dobrze w jednym systemie, zachowując się inaczej w innym.

Koszt wydajności to kolejny istotny problem związany z finalizatorami. W szczególności JVM musi wykonywać o wiele więcej operacji podczas konstruowania i niszczenia obiektów zawierających niepusty finalizator .

Ostatnim problemem, o którym będziemy rozmawiać, jest brak obsługi wyjątków podczas finalizacji. Jeśli finalizator zgłosi wyjątek, proces finalizacji zostaje zatrzymany, pozostawiając obiekt w uszkodzonym stanie bez żadnego powiadomienia.

3.2. Demonstracja efektów finalizatorów

Czas odłożyć teorię na bok i zobaczyć efekty finalizatorów w praktyce.

Zdefiniujmy nową klasę z niepustym finalizatorem:

public class CrashedFinalizable { public static void main(String[] args) throws ReflectiveOperationException { for (int i = 0; ; i++) { new CrashedFinalizable(); // other code } } @Override protected void finalize() { System.out.print(""); } }

Zwróć uwagę na metodę finalize () - po prostu wypisuje pusty ciąg na konsoli. Gdyby ta metoda była całkowicie pusta, maszyna JVM traktowałaby obiekt tak, jakby nie miał finalizatora. Dlatego musimy dostarczyć finalize () implementację, która w tym przypadku prawie nic nie robi.

Wewnątrz metody głównej w każdej iteracji pętli for jest tworzona nowa instancja CrashedFinalizable . To wystąpienie nie jest przypisane do żadnej zmiennej, dlatego kwalifikuje się do czyszczenia pamięci.

Dodajmy kilka instrukcji w wierszu oznaczonym // innym kodem, aby zobaczyć, ile obiektów istnieje w pamięci w czasie wykonywania:

if ((i % 1_000_000) == 0) { Class finalizerClass = Class.forName("java.lang.ref.Finalizer"); Field queueStaticField = finalizerClass.getDeclaredField("queue"); queueStaticField.setAccessible(true); ReferenceQueue referenceQueue = (ReferenceQueue) queueStaticField.get(null); Field queueLengthField = ReferenceQueue.class.getDeclaredField("queueLength"); queueLengthField.setAccessible(true); long queueLength = (long) queueLengthField.get(referenceQueue); System.out.format("There are %d references in the queue%n", queueLength); }

The given statements access some fields in internal JVM classes and print out the number of object references after every million iterations.

Let's start the program by executing the main method. We may expect it to run indefinitely, but that's not the case. After a few minutes, we should see the system crash with an error similar to this:

... There are 21914844 references in the queue There are 22858923 references in the queue There are 24202629 references in the queue There are 24621725 references in the queue There are 25410983 references in the queue There are 26231621 references in the queue There are 26975913 references in the queue Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded at java.lang.ref.Finalizer.register(Finalizer.java:91) at java.lang.Object.(Object.java:37) at com.baeldung.finalize.CrashedFinalizable.(CrashedFinalizable.java:6) at com.baeldung.finalize.CrashedFinalizable.main(CrashedFinalizable.java:9) Process finished with exit code 1

Looks like the garbage collector didn't do its job well – the number of objects kept increasing until the system crashed.

If we removed the finalizer, the number of references would usually be 0 and the program would keep running forever.

3.3. Explanation

To understand why the garbage collector didn't discard objects as it should, we need to look at how the JVM works internally.

When creating an object, also called a referent, that has a finalizer, the JVM creates an accompanying reference object of type java.lang.ref.Finalizer. After the referent is ready for garbage collection, the JVM marks the reference object as ready for processing and puts it into a reference queue.

We can access this queue via the static field queue in the java.lang.ref.Finalizer class.

Meanwhile, a special daemon thread called Finalizer keeps running and looks for objects in the reference queue. When it finds one, it removes the reference object from the queue and calls the finalizer on the referent.

During the next garbage collection cycle, the referent will be discarded – when it's no longer referenced from a reference object.

If a thread keeps producing objects at a high speed, which is what happened in our example, the Finalizer thread cannot keep up. Eventually, the memory won't be able to store all the objects, and we end up with an OutOfMemoryError.

Notice a situation where objects are created at warp speed as shown in this section doesn't often happen in real life. However, it demonstrates an important point – finalizers are very expensive.

4. No-Finalizer Example

Let's explore a solution providing the same functionality but without the use of finalize() method. Notice that the example below isn't the only way to replace finalizers.

Instead, it's used to demonstrate an important point: there are always options that help us to avoid finalizers.

Here's the declaration of our new class:

public class CloseableResource implements AutoCloseable { private BufferedReader reader; public CloseableResource() { InputStream input = this.getClass() .getClassLoader() .getResourceAsStream("file.txt"); reader = new BufferedReader(new InputStreamReader(input)); } public String readFirstLine() throws IOException { String firstLine = reader.readLine(); return firstLine; } @Override public void close() { try { reader.close(); System.out.println("Closed BufferedReader in the close method"); } catch (IOException e) { // handle exception } } }

It's not hard to see that the only difference between the new CloseableResource class and our previous Finalizable class is the implementation of the AutoCloseable interface instead of a finalizer definition.

Notice that the body of the close method of CloseableResource is almost the same as the body of the finalizer in class Finalizable.

The following is a test method, which reads an input file and releases the resource after finishing its job:

@Test public void whenTryWResourcesExits_thenResourceClosed() throws IOException { try (CloseableResource resource = new CloseableResource()) { String firstLine = resource.readFirstLine(); assertEquals("baeldung.com", firstLine); } }

In the above test, a CloseableResource instance is created in the try block of a try-with-resources statement, hence that resource is automatically closed when the try-with-resources block completes execution.

Running the given test method, we'll see a message printed out from the close method of the CloseableResource class.

5. Conclusion

W tym samouczku skupiliśmy się na podstawowej koncepcji języka Java - metodzie finalizacji . Wygląda to przydatne na papierze, ale może mieć brzydkie efekty uboczne w czasie wykonywania. Co ważniejsze, zawsze istnieje alternatywne rozwiązanie do korzystania z finalizatora.

Jednym z krytycznych punktów, na które należy zwrócić uwagę, jest to, że finalize stał się przestarzały, począwszy od Java 9 - i ostatecznie zostanie usunięty.

Jak zawsze, kod źródłowy tego samouczka można znaleźć na GitHub.