00:00

Garbage Collection in Java – How Automatic Memory Management Works

Last updated: 8 min read

Garbage collection (GC) is the JVM's automatic memory management. Your program creates objects with new and never frees them explicitly; instead, the garbage collector periodically finds objects that can no longer be reached and reclaims their memory. This removes whole classes of bugs common in C and C++, such as double frees and dangling pointers.

This tutorial explains when an object becomes eligible for collection, how the heap is organised into generations, the collectors available in modern Java (G1, ZGC, Parallel and Serial), what System.gc() really does, and how memory leaks can still happen.

1. When Is an Object Garbage?

An object is eligible for garbage collection when it is no longer reachable from any GC root. GC roots include local variables and parameters of running methods, static fields of loaded classes, active threads and JNI references. The collector traces every reference from the roots; anything it cannot reach is garbage.

The program below uses WeakReference (which does not keep an object alive) to observe whether objects are actually collected:

Eligibility.java
import java.lang.ref.WeakReference;

public class Eligibility {
    static class Report {
        final String name;
        Report(String name) { this.name = name; }
    }

    // Returns true once the object behind 'ref' has actually been collected
    static boolean collected(WeakReference<?> ref) throws InterruptedException {
        for (int i = 0; i < 20 && ref.get() != null; i++) { System.gc(); Thread.sleep(20); }
        return ref.get() == null;
    }

    public static void main(String[] args) throws InterruptedException {
        // 1. Nulling the only reference
        Report r1 = new Report("daily");
        WeakReference<Report> w1 = new WeakReference<>(r1);
        r1 = null;
        System.out.println("1. reference set to null    -> collected: " + collected(w1));

        // 2. Reassigning the reference
        Report r2 = new Report("weekly");
        WeakReference<Report> w2 = new WeakReference<>(r2);
        r2 = new Report("monthly");
        System.out.println("2. reference reassigned     -> collected: " + collected(w2));

        // 3. Object created inside a method that has returned
        WeakReference<Report> w3 = createAndForget();
        System.out.println("3. method-local object      -> collected: " + collected(w3));

        // 4. Island of isolation: two objects refer only to each other
        Object[] a = new Object[1], b = new Object[1];
        a[0] = b; b[0] = a;
        WeakReference<Object> w4 = new WeakReference<>(a);
        a = null; b = null;
        System.out.println("4. island of isolation      -> collected: " + collected(w4));

        // Still referenced: never collected
        Report kept = new Report("audit");
        WeakReference<Report> w5 = new WeakReference<>(kept);
        System.out.println("   still referenced         -> collected: " + collected(w5) + " (" + kept.name + ")");
    }

    static WeakReference<Report> createAndForget() {
        Report temp = new Report("temp");
        return new WeakReference<>(temp);
    }
}
Output
1. reference set to null    -> collected: true
2. reference reassigned     -> collected: true
3. method-local object      -> collected: true
4. island of isolation      -> collected: true
   still referenced         -> collected: false (audit)

Common ways an object becomes unreachable:

  1. The only reference is set to null.
  2. The reference is reassigned to another object.
  3. The method that created it returns, and nothing else refers to it.
  4. Objects refer only to each other (an island of isolation). Java's tracing collectors handle cycles, unlike simple reference counting.

2. How the Heap Is Organised: Generations

Most objects die young: temporary strings, iterators and request objects live for microseconds. Collectors exploit this weak generational hypothesis by dividing the heap:

AreaContentsCollected by
Young generation: Eden + two Survivor spacesNewly created objectsFrequent, fast minor (young) collections
Old (tenured) generationObjects that survived several young collectionsLess frequent major / mixed collections
Metaspace (native memory, not heap)Class metadataFreed when class loaders are unloaded

New objects are allocated in Eden. Live objects are copied to a Survivor space at each minor GC; after surviving enough cycles they are promoted to the old generation.

3. Mark, Sweep and Compact

  • Mark: trace from the GC roots and mark every reachable object.
  • Sweep: reclaim the memory of unmarked objects.
  • Compact / copy: move live objects together to avoid fragmentation, so allocation stays a fast pointer bump.

Some work requires stop-the-world pauses, when application threads are halted. Modern collectors do most work concurrently with the application to keep pauses short.

4. Garbage Collectors in Modern Java

CollectorFlagStrength
G1 (default since Java 9)-XX:+UseG1GCBalanced throughput and pause times; region-based heap
ZGC (generational since Java 21)-XX:+UseZGCSub-millisecond pauses, even with terabyte heaps
Shenandoah-XX:+UseShenandoahGCLow pauses with concurrent compaction
Parallel-XX:+UseParallelGCMaximum throughput for batch jobs; longer pauses
Serial-XX:+UseSerialGCSingle thread; small heaps and containers with one CPU

The old CMS collector was removed in Java 14. Useful options: -Xms/-Xmx (initial and maximum heap) and -Xlog:gc to log every collection.

5. System.gc() and Memory Statistics

System.gc() (or Runtime.getRuntime().gc()) only suggests a collection. The JVM may ignore it (for example with -XX:+DisableExplicitGC). Runtime also reports memory usage:

MemoryStats.java
public class MemoryStats {
    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();
        long mb = 1024 * 1024;

        long before = rt.totalMemory() - rt.freeMemory();
        byte[][] garbage = new byte[50][];
        for (int i = 0; i < garbage.length; i++) garbage[i] = new byte[1024 * 1024];   // ~50 MB
        long during = rt.totalMemory() - rt.freeMemory();

        garbage = null;                     // all 50 arrays become unreachable
        System.gc();                        // a request, not a command
        long after = rt.totalMemory() - rt.freeMemory();

        System.out.println("Used memory grew by ~50 MB when allocating: " + (during - before >= 45 * mb));
        System.out.println("Used memory dropped after GC:               " + (after < during - 40 * mb));
        System.out.println("Max heap (-Xmx) is available via Runtime.maxMemory(): " + (rt.maxMemory() > 0));
    }
}
Output
Used memory grew by ~50 MB when allocating: true
Used memory dropped after GC:               true
Max heap (-Xmx) is available via Runtime.maxMemory(): true

Do not call System.gc() in production code; the collector knows better when to run, and forced full collections cause unnecessary pauses.

6. finalize() Is Deprecated: Use Cleaner and try-with-resources

Object.finalize() was called by the GC before reclaiming an object, but it ran unpredictably or not at all and slowed collection. It is deprecated for removal. Release resources with AutoCloseable and try-with-resources; use java.lang.ref.Cleaner only as a safety net:

CleanerDemo.java
import java.lang.ref.Cleaner;

public class CleanerDemo {
    private static final Cleaner CLEANER = Cleaner.create();

    // A resource that should be closed explicitly; Cleaner is only a safety net
    static class TempFile implements AutoCloseable {
        private final Cleaner.Cleanable cleanable;

        TempFile(String path) {
            // The cleanup action must NOT reference 'this'
            this.cleanable = CLEANER.register(this, () -> System.out.println("deleted " + path));
        }
        @Override public void close() { cleanable.clean(); }   // runs the action at most once
    }

    public static void main(String[] args) {
        try (TempFile f = new TempFile("/tmp/report.csv")) {
            System.out.println("working with the file");
        }
        System.out.println("after try-with-resources");
    }
}
Output
working with the file
deleted /tmp/report.csv
after try-with-resources

See final vs finally vs finalize.

7. Memory Leaks in a Garbage-Collected Language

GC frees unreachable objects, but objects that are still reachable and no longer needed stay in memory forever. Typical causes:

  • Ever-growing static collections or caches without eviction (use bounded caches).
  • Listeners and callbacks that are registered but never removed.
  • Inner or anonymous class instances holding references to large outer objects.
  • ThreadLocal values not removed in thread pools.
  • Unclosed resources such as streams and connections.

Symptoms are growing heap usage and eventually OutOfMemoryError: Java heap space. Diagnose with heap dumps (-XX:+HeapDumpOnOutOfMemoryError), jcmd, VisualVM, JDK Mission Control or Eclipse MAT.

8. Reference Types

TypeCollected whenUse
Strong (normal)Never while reachableEveryday references
SoftReferenceMemory is running lowMemory-sensitive caches
WeakReferenceNext GC, if only weakly reachableWeakHashMap, canonicalising maps
PhantomReferenceAfter the object is finalised; get() always returns nullPost-mortem clean-up (Cleaner uses it)

9. Summary

  • The JVM automatically reclaims objects that are unreachable from GC roots, including cycles.
  • The heap is split into young and old generations, because most objects die young.
  • G1 is the default collector; ZGC and Shenandoah offer very short pauses; Parallel maximises throughput.
  • System.gc() is only a hint; finalize() is deprecated in favour of try-with-resources and Cleaner.
  • Leaks still happen when unneeded objects stay reachable.

Related Tutorials