Objects, Memory and the Garbage Collector

Article · 15:00

Java memory is in two parts: a stack frame per method call, and a heap holding every object. Collection is about reachability rather than use, which is the whole of the four classic leaks - and why a nested class should be static unless it needs not to be.

Objects, Memory and the Garbage Collector

Java's memory is in two parts. The stack holds one frame per method call: its parameters, its local variables, and - for a local of reference type - the reference itself. The heap holds every object, and is shared by every thread. When a method returns its frame is discarded; the objects it pointed at are not, and stay until nothing can reach them.

import java.util.*;

public class Reachability {
    public static void main(String[] args) {
        List<String> list = build();          // the local frame in build() is gone,
        System.out.println(list);             // the ArrayList it made is not

        list = null;                          // now nothing can reach it, and the
                                              // collector may take it at any time
        System.out.println("unreachable, not deleted - collection is not immediate");

        // Two names for one object. Clearing one changes nothing for the other.
        List<String> a = new ArrayList<>(List.of("x"));
        List<String> b = a;
        a = null;
        System.out.println(b);                // still reachable through b
    }

    static List<String> build() {
        List<String> local = new ArrayList<>();
        local.add("built inside build()");
        return local;
    }
}

There is no free. An object is collectable when it is unreachable from any live thread's stack or any static field - and "unreachable", not "unused", is the rule that explains every Java memory leak.

What a leak looks like in a language with a collector

import java.util.*;

public class Leaks {
    // The classic: a static collection that only ever grows. Every entry is
    // reachable from a static field, so none of it can ever be collected.
    private static final Map<String, byte[]> CACHE = new HashMap<>();

    static void handleRequest(int id) {
        CACHE.put("request-" + id, new byte[10_000]);     // never removed
    }

    public static void main(String[] args) {
        for (int i = 0; i < 1_000; i++) handleRequest(i);
        System.out.println("cache holds " + CACHE.size() + " entries, about "
                + (CACHE.size() * 10_000 / 1_000_000) + "MB, forever");

        // A bounded cache is the fix: evict, or use a size-limited structure.
        Map<String, byte[]> bounded = new LinkedHashMap<>(16, 0.75f, true) {
            private static final long serialVersionUID = 1L;
            @Override protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
                return size() > 100;
            }
        };
        for (int i = 0; i < 1_000; i++) bounded.put("request-" + i, new byte[10_000]);
        System.out.println("bounded holds " + bounded.size());
    }
}

The other three, in the order you will meet them: a listener registered and never removed; a ThreadLocal set on a pooled thread and never cleared; and an inner class that is not static, which holds a reference to its enclosing instance and keeps that alive as long as it lives.

public class InnerClasses {
    private final byte[] large = new byte[1_000_000];

    // Non-static: every instance holds a hidden reference to the Outer that
    // made it - including the megabyte above. Hand one of these to a long-lived
    // collection and the Outer cannot be collected either.
    class Inner {
        int size() { return large.length; }
    }

    // Static: no such reference. This is the default you want.
    static class Independent {
        int value() { return 42; }
    }

    public static void main(String[] args) {
        var outer = new InnerClasses();
        var inner = outer.new Inner();
        System.out.println(inner.size() + " reachable through the inner instance");
        System.out.println(new Independent().value() + " holds nothing");
    }
}

The generational bet

Most objects die young. The collector is built around that: new objects go in a small young generation collected often and cheaply, and the few that survive several collections are promoted to the old generation, collected rarely and expensively.

Two consequences worth knowing. Short-lived garbage is nearly free, so avoiding an allocation is often not worth contorting code for. And an object that survives long enough to be promoted, then dies, is the expensive case - which is what a cache with the wrong lifetime produces.

public class Generations {
    public static void main(String[] args) {
        var runtime = Runtime.getRuntime();
        System.out.println("max heap: " + runtime.maxMemory() / 1_000_000 + "MB");

        long before = runtime.totalMemory() - runtime.freeMemory();

        // Ten million short-lived objects. This is the cheap case.
        long total = 0;
        for (int i = 0; i < 10_000_000; i++) {
            var point = new int[]{i, i};       // dies immediately
            total += point[0];
        }

        long after = runtime.totalMemory() - runtime.freeMemory();
        System.out.println("sum " + total);
        System.out.println("used before " + before / 1_000_000 + "MB, after "
                + after / 1_000_000 + "MB - most of it was never kept");
        System.out.println("(these numbers are indicative; the collector runs when it chooses)");
    }
}

finalize is gone, and System.gc() is a suggestion

Do not implement finalize - it is deprecated for removal and was never reliable. Do not call System.gc() in application code: it is a hint, it may do nothing, and in production it is usually a long pause you asked for by mistake.

Close what you open, with try-with-resources. AutoCloseable is the mechanism Java actually gives you for releasing a file handle, a socket or a connection, and the collector has nothing to do with it.

public class Closing {
    static final class Connection implements AutoCloseable {
        private final String name;
        Connection(String name) {
            this.name = name;
            System.out.println("opened " + name);
        }
        void use() { System.out.println("using " + name); }
        @Override public void close() { System.out.println("closed " + name); }
    }

    public static void main(String[] args) {
        try (var first = new Connection("db"); var second = new Connection("cache")) {
            first.use();
            second.use();
            throw new IllegalStateException("something went wrong");
        } catch (IllegalStateException e) {
            System.out.println("caught: " + e.getMessage());
        }
        System.out.println("both were closed, in reverse order, before the catch ran");
    }
}

What to take away

  • The stack holds frames and references; the heap holds objects, shared by all threads.
  • An object lives while it is reachable - unused is not the same as unreachable.
  • Leaks are static collections, unremoved listeners, ThreadLocals, and non-static inner classes.
  • Nested classes should be static unless they genuinely need the enclosing instance.
  • Most objects die young and cost almost nothing; promoted-then-discarded is the expensive case.
  • Never implement finalize, never call System.gc(), always close with try-with-resources.