Java Garbage Collection Tutorial: Reachability, Leaks and Runnable Examples

Trace three connected objects, find exactly when each becomes eligible for collection, and repair a cache that keeps byte arrays reachable.

KnowledgeGate Team

Exam prep & CS education

Updated 7 Nov 20266 min read
Java Garbage Collection Tutorial: Reachability, Leaks and Runnable Examples

You may know System.gc() yet struggle to trace which connected objects remain reachable. That gap causes wrong answers and misleading fixes. A runnable object graph and a leaking cache separate eligibility from collection timing. Use the Complete Java Course for the broader Java foundation.

Garbage collection starts with reachability, not memory being old

The Java Virtual Machine Specification defines a shared heap for objects and arrays, managed by an automatic storage system. It does not require one layout or collection algorithm.

For a source-level trace, treat a GC root as a starting point in live program state. An active local reference or a static field can provide such a starting path. Follow the vocabulary in order:

  1. A strong reference points to an object.

  2. An object is reachable if a strong-reference path from a live root reaches it.

  3. It is unreachable when no such path remains.

  4. It is then eligible for collection. Eligibility does not imply instant removal.

  5. It is reclaimed only when the JVM performs the relevant collection work.

A class defines shape, an object is an instance, and a reference can point to it. Setting a reference to null removes only that path. Review OOP for Teaching CS Exams: Classes and Inheritance for the foundation. String Handling in Java: Pool, Immutability, Interview Qs covers identity and pooling, which do not replace reachability reasoning.

Fully worked reachability trace with objects 10, 20 and 30

Run this exact program:

java
public class ReachabilityDemo {
    static class Box {
        final int id;
        Box next;

        Box(int id) {
            this.id = id;
        }
    }

    public static void main(String[] args) {
        Box head = new Box(10);
        head.next = new Box(20);
        head.next.next = new Box(30);
        Box alias = head.next;                 // P1
        System.out.println("before cut: " + head.next.id + " -> " + alias.next.id);
        head.next = null;                      // P2
        System.out.println("alias path: " + alias.id + " -> " + alias.next.id);
        alias = null;                          // P3
        System.out.println("head remains: " + head.id);
    }                                         // P4
}

Its exact output is:

Code
before cut: 20 -> 30
alias path: 20 -> 30
head remains: 10

Point

Source-level root paths

Result

P1

head -> 10 -> 20 -> 30; alias -> 20

3 reachable, 0 eligible

P2

head -> 10; alias -> 20 -> 30

3 reachable, 0 eligible

P3

head -> 10; alias = null

1 reachable, 2 eligible

P4

main has returned

This invocation's local paths no longer keep 10 alive

At P2, alias still reaches box 20, which reaches box 30. The answer is therefore 0 eligible boxes at P2 and 2 at P3. These are source-level paths, not a physical stack or heap layout that an implementation may optimise.

Three-panel object graph shows aliasing, reachability, and eligibility changes: two roots share Box(id=20), then Box(id=20) and Box(id=30) become detached and eligible.

What a collector may do, and what System.gc() cannot prove

At a useful abstraction level, a tracing collector starts from roots, identifies live objects and makes unreachable storage reusable. A collector may sweep, copy, compact, use generations or work concurrently. These mechanics are not universal JVM guarantees.

The accounting is simple: P1 has 3 reachable and 0 eligible boxes, P2 has 3 and 0, and P3 has 1 and 2. Reclamation happens only when the JVM performs the required work. Object sizes do not affect this result.

Calling System.gc() merely suggests that the JVM expend effort towards recycling unused objects. It does not prove boxes 20 and 30 are collected before the next statement.

Runnable cache leak: 3,000,000 byte elements stay reachable

GC cannot reclaim an object that your program still reaches accidentally:

java
import java.util.ArrayList;
import java.util.List;

public class CacheLeakDemo {
    static final List<byte[]> CACHE = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 1; i <= 3; i++) {
            CACHE.add(new byte[1_000_000]);
            System.out.println("after " + i + ": " + CACHE.size());
        }
        CACHE.clear();
        System.out.println("after clear: " + CACHE.size());
    }
}

The output is after 1: 1, after 2: 2, after 3: 3, then after clear: 0. The arrays contain exactly 3 x 1,000,000 = 3,000,000 byte elements. Their heap footprint is larger because headers, references, alignment and VM bookkeeping add overhead.

Before clear(), the static field reaches the list and all three arrays. Clearing a loop variable would not remove that path. Afterwards, the arrays are eligible if no other path reaches them.

A bounded teaching alternative uses this access-order map:

java
LinkedHashMap<Integer, byte[]> cache = new LinkedHashMap<>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, byte[]> eldest) {
        return size() > 2;
    }
};

Insert 101, 102 and 103 without intervening access. The third insertion evicts 101, so cache.keySet() prints [102, 103]. The limit of 2 demonstrates the policy; production limits depend on workload and memory.

Two-state retention graph shows a static cache retaining three one-million-byte arrays, then clearing them, alongside a bounded cache with keys 102 and 103.

Common GC mistakes and their repairs

Mistake

Consequence

Repair

Assign every local variable to null

A static list or another object may still retain the target

Remove the actual root path

Repeatedly call System.gc()

Timing remains outside the program contract, and requests can add overhead

Fix retention and size the heap from evidence

Assume OutOfMemoryError proves GC is broken

The application may retain more live data than the heap can hold

Inspect a heap dump and retention paths before tuning

Heap memory differs from external resources. GC does not deterministically close files, sockets or database handles. Use try-with-resources for AutoCloseable resources. Do not assume every collection fully stops all application threads, always compacts memory, or immediately returns freed heap to the operating system.

How interviews and exams test the model

  • Check A: How many boxes are eligible at P2 and P3? 0 and 2.

  • Check B: Why do the cache arrays survive a GC attempt? A strong path remains from static CACHE through the list.

  • Check C: Does System.gc() guarantee that a following weak-reference read is null? No, collection timing is not guaranteed by the call.

Insert alias.next = null; after P2 but before alias = null. Box 30 becomes eligible while 10 and 20 remain reachable. After alias = null, boxes 20 and 30 are eligible, while 10 remains reachable for the final print.

The JVM specification defines the heap but leaves the algorithm to implementors. Oracle's guide explains its reachability model, and the System.gc() API defines that call.

Exercises for the same reachability reasoning

  1. Start with Box first = new Box(40); first.next = new Box(50); Box saved = first.next; first.next = null;. Before saved = null, 40 and 50 remain reachable through different paths. Afterwards, 40 remains reachable and 50 is eligible if no other path exists.

  2. In the three-array list, CACHE.remove(1) detaches the second array, making it eligible if no other path exists. The first and third remain reachable, and CACHE.size() is 2. After CACHE.clear(), all three are eligible under the same condition.

  3. In the access-order map, put 101 and 102, then call cache.get(101). The order becomes [102, 101]. Put 103; the size-2 policy evicts 102, leaving [101, 103].

Short version and the next study action

  • GC follows reachability paths.

  • null removes one path, not an object.

  • Unreachable means eligible, not immediately reclaimed.

  • Static collections can create logical leaks.

  • System.gc() is not a completion guarantee.

Redraw P1, P2 and P3 and explain the eligible counts 0, 0 and 2. Then reproduce the cache output and derive why the bounded version ends with [102, 103].

Next, choose the Java Course for concepts, MCQs and coding questions for focused practice, or compare the wider Coding and Skill Development Courses for broader coverage.