Garbage Collection

Reference types, real memory leaks, and diagnosing GC problems in production

← Back to Index

What is Garbage Collection?

Garbage Collection (GC) is the JVM's automatic memory management: it identifies heap objects no longer reachable by any live thread and reclaims their memory, without the developer explicitly freeing it. This eliminates an entire category of bugs that plague manually-managed languages.

// C/C++: manual management โ€” forget this and you leak memory forever
int* ptr = (int*)malloc(sizeof(int));
*ptr = 42;
free(ptr);  // miss this line โ†’ memory leak. Use after free โ†’ undefined behaviour.

// Java: GC tracks reachability automatically
Person p = new Person("Alice");
// ... use p ...
p = null;  // no more references โ†’ eligible for collection
// GC reclaims it on its own schedule โ€” you never call free()

This page assumes you already understand the generational heap (Young/Old generation, Eden, Survivor spaces) and the major GC algorithms (G1, ZGC, Shenandoah) โ€” those are covered in depth in JVM Internals. Here we go further: how references actually work, what "memory leak" means when there's no free(), and how to diagnose GC problems in a running system.

Reference Types โ€” Controlling GC Eligibility

By default, every reference you create is a strong reference โ€” as long as it exists, the object cannot be collected. Java's java.lang.ref package provides weaker reference types that let you hint the GC about how badly you need an object kept alive.

/*
 *  Reference strength (strongest to weakest):
 *
 *  STRONG     โ€” normal references (Object o = new Object())
 *               Never collected while reachable. Default for everything.
 *
 *  SOFT       โ€” collected only when the JVM is low on memory
 *               (right before OutOfMemoryError would otherwise be thrown)
 *               Use for: memory-sensitive caches
 *
 *  WEAK       โ€” collected at the NEXT GC cycle, regardless of memory pressure
 *               Use for: canonical mappings, metadata that shouldn't keep
 *               its key alive (WeakHashMap)
 *
 *  PHANTOM    โ€” already finalized; queue-only notification after collection
 *               Use for: cleanup actions when an object is actually freed
 *               (replaces deprecated finalize())
 */

WeakReference โ€” the most common in practice

import java.lang.ref.*;

Person p = new Person("Alice");
WeakReference<Person> weakRef = new WeakReference<>(p);

p = null;          // remove the only strong reference
System.gc();   // (just for demonstration โ€” never call this in real code)

weakRef.get();  // null โ€” collected, because only a weak reference remained

// Real-world use: WeakHashMap โ€” entries disappear when the KEY has no
// other strong references. Perfect for caches keyed by objects with
// their own lifecycle (e.g. listener registries keyed by component).
Map<Component, Listener> listeners = new WeakHashMap<>();
listeners.put(button, clickListener);
// When 'button' is no longer referenced elsewhere, this entry
// disappears automatically โ€” no manual cleanup needed.

SoftReference โ€” memory-sensitive caching

// Survives until the JVM is actually under memory pressure โ€”
// effectively "keep this if you can afford it"
class ImageCache {
    private final Map<String, SoftReference<BufferedImage>> cache = new ConcurrentHashMap<>();

    public BufferedImage get(String path) {
        SoftReference<BufferedImage> ref = cache.get(path);
        BufferedImage img = (ref != null) ? ref.get() : null;
        if (img == null) {
            img = loadFromDisk(path);
            cache.put(path, new SoftReference<>(img));
        }
        return img;
    }
}
// In production: prefer Caffeine or Guava Cache โ€” they offer size-based
// and time-based eviction with far more control than SoftReference alone.

PhantomReference โ€” replacing finalize()

// finalize() is deprecated since Java 9 โ€” unpredictable timing,
// can resurrect objects, adds GC overhead. Use Cleaner instead.

class NativeResource implements AutoCloseable {
    private static final Cleaner cleaner = Cleaner.create();
    private final Cleaner.Cleanable cleanable;
    private final long nativeHandle;

    NativeResource() {
        this.nativeHandle = allocateNative();
        // Register cleanup action โ€” runs when 'this' becomes phantom reachable
        this.cleanable = cleaner.register(this, new CleanupAction(nativeHandle));
    }

    @Override
    public void close() { cleanable.clean(); }  // explicit, preferred path

    // Static nested class โ€” must NOT hold a reference to the outer object,
    // or it would prevent collection entirely (defeats the purpose)
    private static class CleanupAction implements Runnable {
        private final long handle;
        CleanupAction(long handle) { this.handle = handle; }
        @Override public void run() { freeNative(handle); }  // fallback if close() never called
    }
}

Memory Leaks โ€” Yes, They Happen With GC

A "leak" in Java doesn't mean forgotten free() calls โ€” it means objects remain strongly reachable long after they're actually needed. The GC is doing its job correctly; your code is just keeping references alive unintentionally.

1. Static collections that only grow

// โŒ Static field is a GC root โ€” anything in this list lives forever
public class RequestLog {
    private static final List<Request> log = new ArrayList<>();
    public static void record(Request r) { log.add(r); }  // never removed!
}

// โœ… Bounded cache with eviction
private static final Cache<String, Request> log = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofHours(1))
    .build();

2. Inner classes holding implicit outer references

// โŒ Non-static inner class holds an implicit reference to the outer instance.
// If this Listener outlives the Activity (Android-style leak, but applies
// anywhere a long-lived object holds a short-lived listener), the outer
// object can never be collected.
public class ReportGenerator {
    private byte[] hugeBuffer = new byte[100_000_000];  // 100MB

    class CompletionListener {  // non-static โ€” implicitly holds ReportGenerator.this
        void onComplete() { ... }
    }
}
// eventBus.subscribe(reportGen.new CompletionListener());
// If eventBus lives forever, so does the 100MB buffer.

// โœ… Static nested class โ€” no implicit outer reference
public class ReportGenerator {
    private static class CompletionListener {
        void onComplete() { ... }
    }
}

3. ThreadLocal not removed in pooled threads

// โŒ In a thread pool, threads are REUSED across requests.
// A ThreadLocal set during request A and not removed leaks into
// request B's thread โ€” and worse, the value lives as long as the thread.
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

void handleRequest(Request req) {
    CONTEXT.set(new UserContext(req.getUser()));
    process(req);
    // missing CONTEXT.remove() โ€” thread pool reuses this thread forever
}

// โœ… Always remove in finally
void handleRequest(Request req) {
    try {
        CONTEXT.set(new UserContext(req.getUser()));
        process(req);
    } finally {
        CONTEXT.remove();  // critical in thread pools
    }
}

4. Unclosed resources and registered listeners

// โŒ Resources hold native handles โ€” not cleaned by GC, leak file descriptors
FileInputStream fis = new FileInputStream("data.txt");
// never closed โ€” leaks an OS file handle until GC finalizes it (unpredictable)

// โœ… try-with-resources guarantees close()
try (var fis = new FileInputStream("data.txt")) { ... }

// โŒ Listener registered on a long-lived publisher, never removed
eventBus.subscribe(this);  // keeps 'this' alive as long as eventBus exists

// โœ… Always unsubscribe
eventBus.unsubscribe(this);

Diagnosing GC Problems

Reading GC logs

# Enable structured GC logging (Java 9+ unified logging)
java -Xlog:gc*:file=gc.log:time,uptime,level,tags MyApp

# Example log line:
[2.341s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 512M->128M(2048M) 8.2ms
/*
 *  2.341s          โ€” JVM uptime when this GC happened
 *  GC(12)          โ€” 13th GC event since JVM start (0-indexed)
 *  Pause Young     โ€” type: minor GC (Young Gen only)
 *  512M->128M      โ€” heap usage before -> after this collection
 *  (2048M)         โ€” total heap capacity
 *  8.2ms           โ€” pause duration (the number that matters for latency)
 */

# What to watch for:
# - Frequent Full GC ("Pause Full") = old gen filling fast = possible leak
# - Growing "before" size over time across many GCs = leak signature
# - Pause times trending up = heap fragmentation or sizing issue

Heap dumps โ€” finding the actual leak

# Capture a heap dump from a running JVM
jmap -dump:live,format=b,file=heap.hprof <pid>

# Or trigger automatically on OutOfMemoryError (always enable in production)
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps MyApp

# Analyse with Eclipse MAT (Memory Analyzer Tool) or VisualVM:
# 1. Open the .hprof file
# 2. Run "Leak Suspects Report" โ€” MAT's heuristics often find it directly
# 3. Look at "Dominator Tree" โ€” sorts objects by retained heap size
# 4. Check "Path to GC Root" on suspect objects โ€” shows WHY it's still reachable

# Quick live stats without a full dump
jstat -gc <pid> 1000        # GC stats every 1000ms
jcmd <pid> GC.heap_info     # current heap summary
jcmd <pid> VM.native_memory # native memory breakdown (needs -XX:NativeMemoryTracking=summary)

Comparing two heap dumps โ€” the leak-hunting technique

/*
 *  The most reliable way to find a leak: take two heap dumps under
 *  load, separated by time, and compare object counts.
 *
 *  1. jmap -dump:live,file=before.hprof 
 *  2. Let the app run under normal load for 30+ minutes
 *  3. jmap -dump:live,file=after.hprof 
 *  4. Open both in Eclipse MAT, use "Compare" feature
 *
 *  Classes whose instance count grew disproportionately to traffic
 *  are your leak suspects. A healthy app's object counts should be
 *  roughly stable under steady-state load โ€” growth = leak.
 */
Never call System.gc() in production code

It's a hint, not a command โ€” the JVM may ignore it, but most implementations honour it and trigger an expensive Full GC, causing a visible pause for no real benefit. If you genuinely need to influence GC behaviour, tune the collector and heap sizing instead. The only legitimate use of System.gc() is in profiling/diagnostic tools, never in application code. Disable it entirely in production with -XX:+DisableExplicitGC if third-party code calls it.

Writing GC-Friendly Code

// โŒ String concatenation in a loop โ€” creates N intermediate objects
String result = "";
for (int i = 0; i < 10_000; i++) result += i;

// โœ… StringBuilder โ€” single mutable buffer
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10_000; i++) sb.append(i);

// โŒ Autoboxing in hot loops โ€” allocates a wrapper object every iteration
Long sum = 0L;
for (long i = 0; i < 10_000_000; i++) sum += i;  // box, unbox, box...

// โœ… Primitive accumulator โ€” zero allocation
long sum = 0;
for (long i = 0; i < 10_000_000; i++) sum += i;

// โœ… Object pooling โ€” ONLY for genuinely expensive objects
// (DB connections, thread pools). NOT for ordinary objects โ€”
// modern generational GC handles short-lived objects extremely cheaply,
// and pooling adds complexity and potential for stale state bugs.
Don't over-optimise allocation

Modern generational GCs are extremely fast at collecting short-lived objects โ€” a Minor GC over Eden space can process gigabytes per second. Premature object pooling for ordinary domain objects usually makes code more complex without measurable benefit. Profile first (async-profiler, JFR allocation profiling) and only optimise allocation hotspots that show up as real bottlenecks.

Interview Questions

๐ŸŽ“ Junior level

Q: What makes an object eligible for garbage collection?
An object becomes eligible when it is no longer reachable from any GC root โ€” local variables on any thread's stack, static fields, or active JNI references. Reachability is transitive: if object A references object B, and A is reachable, B is reachable too. Setting a reference to null is one way to make an object unreachable, but it's not the only way โ€” going out of scope or removing it from a collection also works.

Q: Can you have a memory leak in Java if there's no manual free()?
Yes. A leak happens when objects remain strongly reachable longer than intended โ€” a growing static collection, an unremoved listener, an unclosed resource. The GC is doing its job correctly: those objects genuinely are still reachable. The bug is in the application code holding references too long, not in the garbage collector.

Q: Should you call System.gc()?
No, essentially never in production code. It's a hint that may trigger an expensive Full GC pause for no guaranteed benefit, and the JVM is free to ignore it. Let the JVM's collector decide when to run based on its own heuristics โ€” that's literally what it's designed to do better than manual triggering.

๐Ÿ”ฅ Senior level

Q: What is the difference between WeakReference and SoftReference, and when would you use each?
A WeakReference is cleared at the next GC cycle regardless of memory pressure โ€” use for canonical mappings or metadata that shouldn't keep its subject alive (WeakHashMap). A SoftReference is cleared only when the JVM is genuinely low on memory, right before it would throw OutOfMemoryError โ€” use for memory-sensitive caches that should hold data as long as there's spare capacity. In practice, dedicated caching libraries (Caffeine, Guava) with explicit size/time-based eviction are usually a better choice than raw SoftReference, which gives you no control over eviction order or timing.

Q: How would you diagnose a suspected memory leak in a production service?
(1) Enable GC logging and watch for a trend: growing "before GC" heap usage across many collections, or increasing Full GC frequency. (2) Take two heap dumps separated by time under steady load, then compare object counts in Eclipse MAT โ€” classes growing disproportionately to traffic are suspects. (3) Use MAT's "Path to GC Root" on suspect objects to find exactly what's holding the reference. (4) Common culprits: static collections, ThreadLocal not removed in pooled threads, listeners registered but never unregistered, inner classes holding implicit outer references. Always capture -XX:+HeapDumpOnOutOfMemoryError in production so an actual crash gives you a dump for free.

Q: Why is finalize() deprecated and what replaced it?
finalize() has multiple problems: timing is unpredictable (no guarantee it runs promptly, or at all, before JVM shutdown), it can "resurrect" an object by storing a new strong reference to itself, it adds overhead to every object with a finalizer (they require an extra GC pass), and an exception thrown inside it is silently swallowed. Cleaner (Java 9+) replaces it: register a cleanup action tied to phantom reachability, runs on a dedicated thread, no resurrection possible since the cleanup action must not reference the object itself. The cleanup action is a fallback โ€” explicit close() via AutoCloseable and try-with-resources should always be the primary mechanism.