Перейти к основному содержимому

Руководство по методу finalize в Java

· 8 мин. чтения

1. Обзор

В этом руководстве мы сосредоточимся на ключевом аспекте языка Java — методе finalize , предоставляемом корневым классом Object .

Проще говоря, это вызывается перед сборкой мусора для конкретного объекта.

2. Использование финализаторов

Метод finalize() называется финализатором.

Финализаторы вызываются, когда JVM выясняет, что этот конкретный экземпляр должен быть удален сборщиком мусора. Такой финализатор может выполнять любые операции, в том числе возвращать объект к жизни.

Однако основная цель финализатора — освободить ресурсы, используемые объектами, прежде чем они будут удалены из памяти. Финализатор может работать как основной механизм для операций очистки или как подстраховка, когда другие методы не работают.

Чтобы понять, как работает финализатор, давайте взглянем на объявление класса:

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
}

Класс Finalizable имеет средство чтения полей , которое ссылается на закрываемый ресурс. Когда объект создается из этого класса, он создает новый экземпляр BufferedReader , читающий из файла в пути к классам.

Такой экземпляр используется в методе readFirstLine для извлечения первой строки в заданном файле. Обратите внимание, что в данном коде средство чтения не закрыто.

Мы можем сделать это с помощью финализатора:

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

Легко заметить, что финализатор объявляется точно так же, как любой обычный метод экземпляра.

На самом деле время, когда сборщик мусора вызывает финализаторы, зависит от реализации JVM и состояния системы, которые мы не можем контролировать.

Чтобы сборка мусора происходила на месте, воспользуемся методом System.gc . В реальных системах мы никогда не должны вызывать это явно по ряду причин:

  1. это дорого
  2. Он не запускает сборку мусора немедленно — это просто подсказка JVM для запуска GC.
  3. JVM лучше знает, когда нужно вызвать GC

Если нам нужно форсировать GC, мы можем использовать для этого jconsole .

Ниже приведен тестовый пример, демонстрирующий работу финализатора:

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

В первом операторе создается объект Finalizable , затем вызывается его метод readFirstLine . Этот объект не назначен какой-либо переменной, поэтому он подходит для сборки мусора при вызове метода System.gc .

Утверждение в тесте проверяет содержимое входного файла и используется только для того, чтобы доказать, что наш пользовательский класс работает должным образом.

Когда мы запустим предоставленный тест, в консоли будет напечатано сообщение о закрытии буферизованного считывателя в финализаторе. Это означает , что был вызван метод finalize , и он очистил ресурс.

До этого момента финализаторы выглядели как отличный способ для операций предварительного уничтожения. Однако это не совсем так.

В следующем разделе мы увидим, почему следует избегать их использования.

3. Избегайте финализаторов

Несмотря на преимущества, которые они приносят, финализаторы имеют много недостатков.

3.1. Недостатки финализаторов

Давайте рассмотрим несколько проблем, с которыми мы столкнемся при использовании финализаторов для выполнения критических действий.

Первая заметная проблема — отсутствие оперативности. Мы не можем знать, когда запускается финализатор, так как сборка мусора может произойти в любое время.

Само по себе это не проблема, потому что финализатор рано или поздно все равно выполнится. Однако системные ресурсы не безграничны. Таким образом, у нас могут закончиться ресурсы до того, как произойдет очистка, что может привести к сбою системы.

Финализаторы также влияют на переносимость программы. Поскольку алгоритм сборки мусора зависит от реализации JVM, программа может очень хорошо работать в одной системе, но по-разному вести себя в другой.

Стоимость производительности — еще одна важная проблема, связанная с финализаторами. В частности, JVM должна выполнять гораздо больше операций при построении и уничтожении объектов, содержащих непустой финализатор .

Последняя проблема, о которой мы поговорим, — это отсутствие обработки исключений во время финализации. Если финализатор выдает исключение, процесс финализации останавливается, оставляя объект в поврежденном состоянии без какого-либо уведомления.

3.2. Демонстрация эффектов финализаторов

Пришло время отложить теорию в сторону и посмотреть на эффект финализаторов на практике.

Давайте определим новый класс с непустым финализатором:

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("");
}
}

Обратите внимание на метод finalize() — он просто выводит на консоль пустую строку. Если бы этот метод был полностью пустым, JVM обработала бы объект так, как если бы у него не было финализатора. Поэтому нам нужно обеспечить finalize() реализацией, которая в данном случае почти ничего не делает.

Внутри основного метода создается новый экземпляр CrashedFinalizable на каждой итерации цикла for . Этот экземпляр не назначен какой-либо переменной, поэтому подходит для сборки мусора.

Давайте добавим несколько операторов в строку, отмеченную // другим кодом , чтобы увидеть, сколько объектов существует в памяти во время выполнения:

if ((i % 1_000_000) == 0) {
Class<?> finalizerClass = Class.forName("java.lang.ref.Finalizer");
Field queueStaticField = finalizerClass.getDeclaredField("queue");
queueStaticField.setAccessible(true);
ReferenceQueue<Object> 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);
}

Данные операторы обращаются к некоторым полям во внутренних классах JVM и распечатывают количество ссылок на объекты после каждого миллиона итераций.

Запустим программу, выполнив основной метод. Мы можем ожидать, что он будет работать бесконечно, но это не так. Через несколько минут мы должны увидеть сбой системы с ошибкой, подобной этой:

...
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.<init>(Object.java:37)
at com.foreach.finalize.CrashedFinalizable.<init>(CrashedFinalizable.java:6)
at com.foreach.finalize.CrashedFinalizable.main(CrashedFinalizable.java:9)

Process finished with exit code 1

Похоже, сборщик мусора плохо справлялся со своей задачей — количество объектов увеличивалось до тех пор, пока система не рухнула.

Если бы мы удалили финализатор, количество ссылок обычно было бы равно 0, и программа продолжала бы работать вечно.

3.3. Объяснение

Чтобы понять, почему сборщик мусора не отбрасывал объекты должным образом, нам нужно посмотреть, как работает JVM внутри.

При создании объекта, также называемого референтом, который имеет финализатор, JVM создает сопутствующий ссылочный объект типа java.lang.ref.Finalizer . После того, как референт готов к сборке мусора, JVM помечает эталонный объект как готовый к обработке и помещает его в очередь ссылок.

Мы можем получить доступ к этой очереди через очередь статических полей в классе java.lang.ref.Finalizer .

Тем временем специальный поток демона под названием Finalizer продолжает работать и ищет объекты в очереди ссылок. Когда он находит его, он удаляет эталонный объект из очереди и вызывает финализатор для референта.

Во время следующего цикла сборки мусора референт будет отброшен, когда на него больше не будет ссылок из ссылочного объекта.

Если поток продолжает создавать объекты с высокой скоростью, как это произошло в нашем примере, поток Finalizer не может угнаться за ним. В конце концов, память не сможет хранить все объекты, и мы получим OutOfMemoryError .

Обратите внимание, что ситуация, когда объекты создаются со скоростью деформации, как показано в этом разделе, не часто встречается в реальной жизни. Однако он демонстрирует важный момент — финализаторы очень дороги.

4. Пример без финализатора

Давайте рассмотрим решение, обеспечивающее ту же функциональность, но без использования метода finalize() . Обратите внимание, что приведенный ниже пример — не единственный способ заменить финализаторы.

Вместо этого он используется для демонстрации важного момента: всегда есть варианты, которые помогают нам избежать финализаторов.

Вот объявление нашего нового класса:

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
}
}
}

Нетрудно заметить, что единственная разница между новым классом CloseableResource и нашим предыдущим классом Finalizable заключается в реализации интерфейса AutoCloseable вместо определения финализатора.

Обратите внимание, что тело метода закрытия CloseableResource почти такое же, как тело финализатора в классе Finalizable .

Ниже приведен тестовый метод, который считывает входной файл и освобождает ресурс после завершения своей работы:

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

В приведенном выше тесте экземпляр CloseableResource создается в блоке try оператора try-with-resources, поэтому этот ресурс автоматически закрывается, когда блок try-with-resources завершает выполнение.

Запустив данный тестовый метод, мы увидим сообщение, распечатываемое из метода close класса CloseableResource .

5 . Вывод

В этом уроке мы сосредоточились на основной концепции Java — методе finalize . Это выглядит полезным на бумаге, но может иметь уродливые побочные эффекты во время выполнения. И, что более важно, всегда есть альтернативное решение для использования финализатора.

Важно отметить, что finalize устарела, начиная с Java 9, и в конечном итоге будет удалена.

Как всегда, исходный код этого руководства можно найти на GitHub .