26 ноября 2021

☕ Разбираемся, почему в Java утекает память несмотря на сборщик мусора

Telegram: @fivoronov
Сборщик мусора облегчает написание кода и справляется с основными проблемами, но не гарантирует полного отсутствия утечек памяти. Изучите базовые принципы его работы, чтобы понять, какими видами мусора он заниматься не будет.
☕ Разбираемся, почему в Java утекает память несмотря на сборщик мусора
Что такое утечка памяти?
Утечкой называют присутствие в памяти ненужных программе объектов и данных. На их содержание тратятся ресурсы и в конечном итоге это может привести к ошибке работы виртуальной машины java.lang.OutOfMemoryError, которая завершит работу программы.

Как сборщик мусора ищет мусор?

Как же сборщик мусора (англ. Garbage Collector, GC) определяет ненужность объекта? Базовый принцип работы GC – это поиск объектов, на которые программа потеряла ссылки.

Рассмотрим пример вызова метода, внутри которого создадим объект, используем его для подсчёта примитивного значения и без сохранения куда-либо созданного объекта возвращаем посчитанное значение:

        public static boolean isGreetingTooHard() {
    String msg = "Hello World!";
    return msg.length() > 10;
}

    

После завершения вызова метода isGreetingTooHard локальная переменная msg будет уничтожена и в нашей программе не останется ни одной ссылки на созданный объект строки. Значит, программа физически не сможет использовать объект нигде после вызова метода и GC может спокойно удалить его из памяти, не опасаясь что обращения нему в будущем.

В программах часто объекты путешествуют из одних методов в другие, сохраняются и удаляются из полей, массивов, коллекций, что усложняет определение факта потери на них ссылок. Но для GC это не является неразрешимой проблемой и он способен определить, можно ли до объекта дойти по ссылкам из существующей в этот момент ячейки программы (переменной, параметра,..), и, если нельзя, то этот объект точно является мусором, ведь программа его физически уже никогда не сможет использовать.

Поиск мусора через достижимость объектов
Поиск мусора через достижимость объектов

Когда мусор не найдётся?

Объект может быть достижим из живых локальных переменных / статических полей и других активных ячеек программы, но при этом всё равно быть уже ненужным. Этот объект будет мусором, но GC не будет очищать память от него. Рассмотрим некоторые примеры подобных и других утечек памяти в Java.

☕ Подтянуть свои знания по Java вы можете на нашем телеграм-канале «Библиотека Java для собеса»

Статические поля

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

Незакрытые ресурсы

Под открытые ресурсы (например, соединения или потоки ввода-вывода) выделяется память, которая освобождается при закрытии через вызов метода close. Уже ненужные программе но незакрытые ресурсы могут блокировать освобождение занятой памяти. Частая причина этого вида утечки это ошибка в программе, из-за которой ресурс не закрывается, но при этом ссылка на объект ресурса теряется. Например, в следующем примере сканнер закрыт не будет, если из входного потока будет считан 0, на который попытаются разделить:

        public static double divide() {
    Scanner scanner = new Scanner(...);
    double result = 1;
    while (scanner.hasNextInt()) {
        result /= scanner.nextInt();
    }
    scanner.close();
    return result;
}

    

Чтобы избежать этого рода утечки памяти, используйте try-with-resources для автозакрытия ресурсов даже в случае возникновения исключений (до Java 7 используйте для этих целей finally):

        public static double divide() {
    try (Scanner scanner = new Scanner(...)) {
        double result = 1;
        while (scanner.hasNextInt()) {
            result /= scanner.nextInt();
        }
        return result;
    }
}

    

Внутренние классы

Ряд механизмов в Java предполагают наличие неявной ссылки на объект. К числу таких относится и внутренний класс. Рассмотрим пример:

Country.java
        public class Country {
    //Население страны
    protected String[] population = new String[1_000_000_000];
    protected String headOfState = "Mr. President";

    public ForeignMinister newForeignMinister() {
        return new ForeignMinister();
    }

    public class ForeignMinister {
        protected int deals = 0; // количество успешных сделок

        public void acceptInvitation() {
            deals++;
            System.out.println(headOfState + " приглашён на глобальную встречу");
        }
    }
}
    

Класс описывает объект с информацией о стране – в полях содержатся имя главы государства (поле headOfState) и имена всех жителей (большой массив population). Внутренний класс описывает объект министра иностранных дел – поле количества успешных договоров и метод приглашения главы государства на саммит.

Теперь представим себе метод, который создаёт (и никуда не сохраняет) объект страны, спрашивает у него объект министра иностранных дел и возвращает только министра из метода:

        public static ForeignMinister getAnyMinister() {
    Country proglibLand = new Country();
    return proglibLand.newForeignMinister();
}

    

Будет ошибкой заключить, что созданный объект страны будет подчищен GC после завершения метода. Класс министра это внутренний классом, его объекту доступны все поля объекта страны, от которой он создан. Для этого джава будет поддерживать неявную ссылку в объекте министра на объект страны, храня в памяти его вместе со всеми полями, включая огромный массив с именами жителей, который нашей программе логически не нужен, а значит является мусором.

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

🧩☕ Интересные задачи по Java для практики можно найти на нашем телеграм-канале «Библиотека задач по Java»

Собственные структуры данных

Утечка памяти это частая ошибка при проектировании своих собственных структур данных. Представим себе, что мы решили написать собственную реализацию стека, т.е. набор данных с двумя операциями: push(значение) вставляет новое значение в конец набора; pop() вынимает значение с конца набора.

Рассмотрим упрощённую реализацию без красивой обработки ошибок:

LeakyStack.java
        public class LeakyStack<T> {
    private T[] data;      // буфер
    private int nextIndex; // свободная ячейка буфера

    public LeakyStack(int size) {
        data = (T[]) new Object[size];
    }

    public void push(T value) {
        data[nextIndex++] = value;
    }

    public T pop() {
        return data[--nextIndex];
    }
}

    

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

Пользоваться стеком можно так:

        LeakyStack<String> stack = new LeakyStack<>(10);
stack.push("Petya");
stack.push("Katya");
System.out.println(stack.pop()); // Katya

    

Наша реализация приводит к утечке памяти. И речь не о наличии ячеек в массиве про запас, проблема тут гораздо серьёзнее. Если, как в нашем примере выше, пользователь положил объект "Katya" на стек, а затем вынул и никуда не сохранил, то он ожидает удаление этого объекта из памяти через GC. Но этого не произойдёт, тк в нашем массиве ссылка на этот объект продолжит храниться, предотвращая удаление мусора из кучи.

Избавиться от этого поможет об-null-ение ячеек, значения которых больше не нужны:

        public T pop() {
    T popped = data[--nextIndex];
    data[nextIndex] = null;
    return popped;
}

    

Итог

Наличие встроенных алгоритмов сборки мусора ещё не гарантирует что весь мусор будет вычищаться, а занятая им память освобождаться для переиспользования. Понимание принципов работы GC помогает избежать накопления такого мусора в программе, который не будет убран автоматически.

Источники

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Java Team Lead
Москва, по итогам собеседования
Senior Java Developer
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ