ООП ·
‹ Предыдущий Следующий ›
⏱ 5 минут чтения Обновлено: 2026-07-31

Сборщик мусора и метод finalize

Классический учебный пример: переопределяем finalize(), обнуляем ссылку, вызываем System.gc() и ждём в консоли строчку «Чашка исчезает навсегда». Запустите этот код на современной JDK — и консоль вполне может остаться пустой. JVM не обязана выполнять запрос на сборку мусора, не обязана успеть выполнить финализаторы до выхода из main(), а на JDK 18 и выше достаточно запустить java --finalization=disabled Cup, чтобы вывода не было гарантированно. Именно поэтому finalize() и не годится для освобождения ресурсов.

1. Сборщик мусора в Java

Сборщик мусора (garbage collector, GC) в Java — это подсистема JVM, которая автоматически находит объекты, недостижимые из работающей программы, и освобождает занятую ими память. В языках вроде C или C++ память, выделенную под объект, освобождает сам разработчик. В Java этим занимается JVM: вы создаёте объект оператором new, а когда на него не остаётся ссылок, сборщик мусора рано или поздно освободит память.

Что это даёт на практике:

  • нет ручного free()/delete и, как следствие, нет двойного освобождения памяти и висячих указателей;
  • память под новые объекты выделяется быстро — в области Eden это фактически сдвиг указателя;
  • расплата — паузы на сборку и отсутствие контроля над моментом освобождения памяти.

Куча (heap) в HotSpot разделена на поколения: молодое (Young: Eden и два Survivor) и старое (Old / Tenured). Большинство объектов умирает молодыми, поэтому короткая сборка молодого поколения (minor GC) отрабатывает быстро; объекты, пережившие несколько сборок, перемещаются в старое поколение, которое собирается реже и дороже (major / full GC).

Важно

Сборщик мусора управляет только кучей. Метаданные классов с Java 8 живут в Metaspace (он заменил PermGen) за пределами кучи, а память, выделенная через ByteBuffer.allocateDirect() или JNI, вообще не относится к куче — её освобождение GC напрямую не контролирует.

2. Когда объект становится мусором

Объект считается мусором, если до него нельзя добраться ни по одной цепочке ссылок от так называемых корней сборки мусора (GC roots). Корнями являются:

  • локальные переменные и параметры методов в стеках всех живых потоков;
  • статические поля загруженных классов;
  • сами живые объекты Thread;
  • ссылки из нативного кода (JNI) и объекты, используемые как мониторы синхронизации.

Рассмотрим пример. В методе main() класса Cup создаётся объект Cup, на который указывает переменная cup. После выполнения строки cup = null объект всё ещё существует в памяти, но на него не указывает ни одна ссылка — он становится кандидатом на удаление. Вместе с ним недостижимым становится и объект Spoon, на который ссылалось поле cup.spoon.

public class Spoon {
}
public class Cup {
    Spoon spoon;

    Cup(Spoon spoon) {
        this.spoon = spoon;
    }

    public static void main(String[] args) {
        Cup cup = new Cup(new Spoon());
        cup = null; // объект Cup и вложенный Spoon стали недостижимы
    }
}

Важное следствие модели достижимости: циклические ссылки не мешают сборке мусора. Если два объекта ссылаются друг на друга, но до них не дотянуться от GC roots, они образуют «остров изоляции» и будут собраны целиком. Это принципиальное отличие от подсчёта ссылок (reference counting), где такой цикл остался бы в памяти навсегда.

public class Island {
    Island friend;

    public static void main(String[] args) {
        Island a = new Island();
        Island b = new Island();
        a.friend = b;   // a -> b
        b.friend = a;   // b -> a
        a = null;
        b = null;       // оба объекта недостижимы и будут собраны
    }
}

3. System.gc(): можно ли запустить сборку вручную

Попросить JVM выполнить сборку мусора можно двумя эквивалентными способами:

System.gc();
Runtime.getRuntime().gc(); // то же самое, System.gc() просто делегирует сюда

Ключевое слово здесь — попросить. Документация System.gc() прямо говорит, что это лишь подсказка (hint) виртуальной машине. JVM может выполнить полную сборку, выполнить её позже или проигнорировать вызов совсем. Более того, приложение можно запустить с флагом -XX:+DisableExplicitGC, и все явные вызовы System.gc() превратятся в пустую операцию.

В прикладном коде System.gc() вызывать не стоит: обычно это провоцирует дорогую полную сборку (stop-the-world) и делает поведение приложения хуже, а не лучше. Допустимые сценарии — учебные примеры, микробенчмарки и замеры памяти перед снятием heap dump.

4. Метод finalize()

protected void finalize() — метод класса Object, который поток-финализатор JVM может вызвать у объекта перед освобождением его памяти. Идея была такой: если объект владеет внешним ресурсом (файл, сокет, нативный буфер), переопределяем finalize() и закрываем ресурс там, чтобы он не утёк даже при забытом close().

Добавим finalize() в классы Spoon и Cup и попробуем спровоцировать его вызов через System.gc():

public class Spoon {
    @Override
    protected void finalize() {
        System.out.println("Ложка исчезает навсегда");
    }
}
public class Cup {
    private Spoon spoon;

    public Cup(Spoon spoon) {
        this.spoon = spoon;
    }

    @Override
    protected void finalize() {
        System.out.println("Чашка исчезает навсегда");
    }

    public static void main(String[] args) {
        Cup cup = new Cup(new Spoon());
        cup = null;
        System.gc();
    }
}

Возможный вывод:

Чашка исчезает навсегда
Ложка исчезает навсегда

Слово «возможный» здесь принципиально. Строки могут поменяться местами, может появиться только одна из них или не появиться ни одной: порядок финализации объектов не определён, а JVM спокойно завершает работу, не дожидаясь опустошения очереди финализаторов. Чтобы вывод стал стабильнее, в учебных примерах после System.gc() иногда добавляют небольшую задержку, но это костыль, а не гарантия.

Формулировка для собеседования

finalize() не вызывается при выходе объекта из области видимости, не вызывается при завершении JVM и вообще не гарантирован к вызову. Единственное, что обещает спецификация: если метод всё-таки будет вызван, то не более одного раза за время жизни объекта.

5. Почему finalize() признали устаревшим

Механизм финализации накопил столько проблем, что его решили убрать из языка:

  • Непредсказуемая задержка. Между моментом, когда объект стал мусором, и вызовом finalize() могут пройти минуты. Файловые дескрипторы и соединения кончатся раньше.
  • Риск исчерпания памяти. Объекты с finalize() переживают минимум одну лишнюю сборку и попадают в очередь финализации. Если финализаторы работают медленно, очередь растёт и приложение падает с OutOfMemoryError.
  • Проглоченные исключения. Исключение, брошенное из finalize(), просто игнорируется: объект остаётся в повреждённом состоянии, а в логах не будет ничего.
  • Воскрешение объекта. В finalize() можно записать this в статическое поле и вернуть объект к жизни. Второй раз finalize() у него уже не вызовут.
  • Многопоточность и безопасность. Финализатор работает в отдельном потоке, что порождает гонки; классическая атака finalizer attack позволяет получить доступ к недостроенному объекту, конструктор которого бросил исключение.
  • Накладные расходы. Создание и сборка объектов с финализатором заметно дороже обычных.

Хронология статуса метода:

Версия Java Что произошло с финализацией
Java 9 Object.finalize() помечен как @Deprecated; добавлен класс java.lang.ref.Cleaner как замена
Java 11 Удалены System.runFinalizersOnExit() и Runtime.runFinalizersOnExit()
Java 18 JEP 421: финализация помечена deprecated for removal; появился ключ запуска --finalization=disabled, полностью отключающий вызовы finalize()
Будущие релизы Финализация будет отключена по умолчанию, а затем удалена из платформы вместе с методом finalize()

Практический вывод: переопределять finalize() в новом коде нельзя. Знать о нём нужно, чтобы понимать легаси-код и отвечать на вопросы собеседования.

6. Чем заменить finalize(): try-with-resources и Cleaner

Основной способ освобождать ресурсы в Java — детерминированный: класс реализует AutoCloseable, а пользователь класса открывает его в try-with-resources. Тогда close() вызывается сразу по выходе из блока, независимо от сборщика мусора.

java.lang.ref.Cleaner (появился в Java 9) нужен только как страховка: он выполнит действие по очистке, если объект стал мусором, а close() так и не вызвали. В отличие от finalize(), действие очистки не имеет доступа к самому объекту, выполняется в выделенном потоке и не мешает сборке.

import java.lang.ref.Cleaner;

public class Cup implements AutoCloseable {

    private static final Cleaner CLEANER = Cleaner.create();

    // Состояние не должно ссылаться на Cup, иначе объект никогда не станет мусором
    private static class State implements Runnable {
        @Override
        public void run() {
            System.out.println("Чашка вымыта: ресурсы освобождены");
        }
    }

    private final Cleaner.Cleanable cleanable;

    public Cup() {
        this.cleanable = CLEANER.register(this, new State());
    }

    @Override
    public void close() {
        cleanable.clean(); // очистка выполнится ровно один раз
    }

    public static void main(String[] args) {
        try (Cup cup = new Cup()) {
            System.out.println("Наливаем чай");
        }
    }
}
Наливаем чай
Чашка вымыта: ресурсы освобождены

Неочевидный момент

Класс состояния в Cleaner обязан быть статическим вложенным (или отдельным) и не хранить ссылку на владельца. Нестатический вложенный класс и лямбда, захватывающая this, создают сильную ссылку на объект — он никогда не станет недостижимым, и очистка не выполнится никогда.

Сравнение доступных механизмов:

Механизм Когда срабатывает Детерминированность Статус
try-with-resources + AutoCloseable На выходе из блока try Полная, момент известен точно Рекомендуемый способ (Java 7+)
Явный close() в finally Там, где написан вызов Полная, но легко забыть Легаси-код до Java 7
java.lang.ref.Cleaner После того как объект стал недостижим Нет, только страховка Java 9+, замена finalize()
PhantomReference + ReferenceQueue После сборки объекта, вручную из очереди Нет, требует своего потока Низкоуровневый вариант Cleaner
finalize() Неизвестно, возможно никогда Отсутствует Deprecated for removal (Java 18)

7. Типы ссылок: strong, soft, weak, phantom

Пакет java.lang.ref позволяет управлять тем, насколько сильно ссылка удерживает объект от сборки. Эту тему часто спрашивают на собеседовании вместе с finalize().

Тип ссылки Класс Когда объект может быть удалён Где применяется
Strong (обычная) Никогда, пока ссылка достижима от GC roots Весь обычный код
Soft SoftReference При нехватке памяти, до выброса OutOfMemoryError Кэши, которые не жалко потерять
Weak WeakReference При первой же сборке, если сильных ссылок нет WeakHashMap, метаданные, слушатели
Phantom PhantomReference Уже удалён; get() всегда возвращает null Освобождение нативных ресурсов, основа Cleaner

8. Какие сборщики мусора есть в JVM

Сборщик мусора в HotSpot выбирается флагом запуска. Начиная с Java 9 по умолчанию используется G1.

Сборщик Флаг Особенности Когда выбирать
Serial -XX:+UseSerialGC Однопоточный, минимальные накладные расходы Маленькая куча, контейнер с одним ядром
Parallel -XX:+UseParallelGC Максимальная пропускная способность, длинные паузы Пакетная обработка, где паузы не критичны
G1 -XX:+UseG1GC По умолчанию с Java 9, куча из регионов, целевая длительность пауз Большинство серверных приложений
ZGC -XX:+UseZGC Паузы порядка долей миллисекунды на кучах в терабайты Сервисы, чувствительные к задержкам
Shenandoah -XX:+UseShenandoahGC Уплотнение кучи параллельно с работой приложения Низкие задержки, сборки OpenJDK
Epsilon -XX:+UseEpsilonGC Ничего не собирает, память просто заканчивается Бенчмарки и короткоживущие задачи

9. Что чаще всего понимают неправильно

  • «Есть GC — значит, утечек памяти нет». Утечка в Java — это объект, который больше не нужен, но всё ещё достижим: элемент в статической коллекции, неотписанный слушатель, ключ в HashMap без корректных equals()/hashCode(), поток из пула, удерживающий ThreadLocal.
  • «cup = null удаляет объект». Присваивание null лишь убирает одну ссылку. Память освободит сборщик мусора, и только если других ссылок не осталось.
  • «System.gc() запускает сборку». Это запрос, а не команда, и его можно отключить флагом JVM.
  • «finalize() — это деструктор». Деструктора в Java нет: деструктор в C++ вызывается детерминированно, finalize() — нет. Ближайший аналог по смыслу — close() в try-with-resources.
  • «Циклические ссылки не собираются». Собираются: JVM опирается на достижимость, а не на подсчёт ссылок.
  • «finalize() вызовется хотя бы при выходе из программы». Нет: метод runFinalizersOnExit(), который это обещал, удалён в Java 11 как небезопасный.

Часто задаваемые вопросы

Какой сборщик мусора используется в Java по умолчанию?

Начиная с Java 9 по умолчанию работает G1 (Garbage-First); в Java 8 по умолчанию был Parallel GC. На слабых машинах JVM по эргономике может выбрать Serial GC. Проверить текущий выбор можно командой java -XX:+PrintFlagsFinal -version или в логах запуска с ключом -Xlog:gc.

Бывают ли утечки памяти в Java, если есть сборщик мусора?

Да. Сборщик мусора удаляет только недостижимые объекты, поэтому утечка в Java — это ненужный объект, который остаётся достижимым. Типичные источники: растущие статические коллекции, кэши без вытеснения, неотписанные слушатели, ThreadLocal в пуле потоков, незакрытые ресурсы. Диагностируют такие утечки по heap dump в VisualVM, Eclipse MAT или через Java Flight Recorder.

Чем PhantomReference отличается от finalize()?

Фантомная ссылка не даёт доступа к объекту: её метод get() всегда возвращает null, поэтому воскресить объект невозможно. Уведомление приходит в ReferenceQueue уже после того, как объект признан недостижимым, и вы обрабатываете его в своём потоке в удобный момент. На фантомных ссылках построен класс Cleaner, который и стоит использовать вместо ручной работы с очередью.

Как полностью отключить финализацию в JDK 18 и новее?

Запустите приложение с ключом --finalization=disabled. Тогда JVM не вызовет ни один метод finalize(), даже переопределённый. Это удобный способ заранее проверить готовность к будущему удалению финализации: если после отключения ничего не сломалось, зависимости от finalize() в вашем коде и библиотеках нет.

Можно ли по finalize() посчитать, сколько объектов удалено?

Нет, такой счётчик будет неверным. Метод вызывается не для всех объектов, может не вызваться совсем, срабатывает максимум один раз и в непредсказуемый момент. Для подсчёта объектов и анализа памяти используйте инструменты JVM: jcmd GC.class_histogram, heap dump или события Java Flight Recorder.

Видео объяснение

Предпочитаете видеоформат? Посмотрите этот урок с примерами и объяснениями.

Комментарии

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