Недавно, как начинающий Java программист, я задался вопросом: как освободить память занимаемую объектом? Мне было уже известно, что непосредственно удалить объект в Java не возможно. В отличие от C++, в Java нет таких функций для освобождения памяти как free() или delete(). Всем занимается сборщик мусора, который в автоматическом режиме освобождает память от тех объектов, которые больше не используются. Но иногда все же может потребоваться вручную освободить память. Что делать в таком случае?

Для начала, определим когда может возникнуть такая необходимость. Например, если для работы программы требуется хранить в памяти какой-либо объемный объект и при этом его необходимо периодически обновлять. Тогда, в момент генерации нового экземпляра, в памяти окажется два тяжелых объекта и, если они достаточно велики, может возникнуть OutOfMemoryError. Я постарался проиллюстрировать данную проблему в приведенном ниже псевдокоде:

class ExampleClass {
  
  Map<Long, String> enormous;
  
  // Code
  
  void refresh() {
    enormous = EnormousObjectProvider.getNewInstance();
  }
}

class EnormousObjectProvider {
  
  // Code
  
  Map<Long, String> getNewInstance(){
    // Some actions (e.g. API or DB requests or special calculations)
    // ...
    // While we are here, we have the second instance of large object
    // it means that if both objects are heavy enough we can be out of memory
    return result;
  }
}

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

Чтобы избавиться от ссылок на объект, просто присвоим ему значение null. Так, например, работает метод HashMap.clear():

/**
 * Removes all of the mappings from this map.
 * The map will be empty after this call returns.
 */
public void clear() {
    Node<K,V>[] tab;
    modCount++;
    if ((tab = table) != null && size > 0) {
        size = 0;
        for (int i = 0; i < tab.length; ++i)
            tab[i] = null;
    }
}

Он обходит массив объектов Node и присваивает каждому значение null, но почему такой метод освобождения памяти работает? Если мы присвоили значение null здесь, гарантирует ли это, что на объект больше нигде нет ссылок и он доступен для утилизации сборщиком мусора? Для этого вспомним как работают ссылочные типы данных в Java.

Жизненный цикл объекта начинается с его создания при помощи оператора new, например:

Person p1 = new Person();

Данная строка означает “выделить пространство в оперативной памяти и присвоить ссылку на эту ячейку памяти переменной p1”. Ключевая мысль здесь в том, что p1 это не сам объект, а лишь ссылка на него.
Теперь взглянем что происходит когда мы присваиваем значение переменной p1 новой переменной:

Person p2 = p1;

Важно понимать, что мы не создаем две ссылки, которые указывают на один объект, как можно подумать читая многие статьи в интернете. Мы создаем ссылку p2 на ссылку p1:



В этом очень легко убедиться проведя небольшой эксперимент. Выполним следующий код:

public void demo() {
        Person p1;
        p1 = new Person("Tom");
        Person p2 = p1;
        p2 = null;
        p1.greeting();
    }

    class Person {
        String name;

        Person(String name) {
            this.name = name;
        }

        void greeting() {
            System.out.println("Hello, " + name);
        }
    }

В консоли мы увидим “Hello, Tom”, но стоит нам присвоить null переменной p2, а не p1 и мы получим NullPointerException. Это означает что ссылки p1 и p2 указывают на разные объекты.

Поэтому, если мы присвоим p1 = null, то на объект больше не будет указателей, даже если мы передавали его в методы или присваивали значение p1 другим переменным. Это означает, что на объект больше нет ссылок и он доступен для сборщика мусора.

В заключение, испытаем изученный подход на практике:

public class Main {

    private static final long MEGABYTE = 1024L * 1024L;

    public static long bytesToMegabytes(long bytes) {
        return bytes / MEGABYTE;
    }

    public static void main(String[] args) {

        Runtime runtime = Runtime.getRuntime();
        runtime.gc();
        System.out.println("Initial memory: " + usedMemory(runtime));

        // Object initialization
        SomeClass referenceA = new SomeClass("One");
        System.out.println("Memory after object initialization: " + usedMemory(runtime));

        // Reference initialization
        SomeClass referenceB = referenceA;
        System.out.println("Memory after reference initialization: " + usedMemory(runtime));

        // Reference to someClassRef
        referenceA.exampleMethod();

        // Set object to Null
        referenceA = null;
        System.out.println("Memory after setting object to null: " + usedMemory(runtime));

        // Call garbage collector
        System.gc();
        System.out.println("Memory after calling GC: " + usedMemory(runtime));

    }

    public static long usedMemory(Runtime runtime) {
        return runtime.totalMemory() - runtime.freeMemory();
    }
}

class SomeClass {

    private String name;

    private int field1;

    private String filed2;

    private HashMap<String, String> bigMap;

    public SomeClass(String name){
        this.name = name;
        this.field1 = Integer.MAX_VALUE;
        filed2 = "";
        for (int i = 0; i < 100_000; i++) {
            filed2 += "A";
        }
        bigMap = new HashMap<>();
        for (int i = 0; i < 100_000; i++) {
            bigMap.put(String.valueOf(i), String.valueOf(i));
        }
    }

    public void exampleMethod() {
        this.field1 -= 1;
    }
}

Ниже приведен результат выполнения программы:

Initial memory: 1276760
Memory after object initialization: 56019128
Memory after reference initialization: 56019128
Memory after setting object to null: 56019128
Memory after calling GC: 16369232

Как мы видим, объем использованной памяти уменьшился, а значит цель достигнута.