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
staticunless 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 callSystem.gc(), always close with try-with-resources.