Implementing Your Own Garbage Collector in Java: Memory Allocation, Reference Tracking, and GC Algorithms

This guide walks through designing a fixed-size memory pool, implementing reference counting with cycle detection, coding Mark & Sweep, Mark & Compact, and Copying GC algorithms, and wiring root object identification and sweeping into a working CustomGarbageCollector class.

Let me tell you why you should build a garbage collector even though you will never ship it to production.

Java gives you automatic memory management as a feature. You new something, use it, stop using it, and the GC cleans it up. For 99% of Java development, that's exactly the right abstraction. But here's the problem: when that abstraction breaks — when you're debugging a java.lang.OutOfMemoryError: GC overhead limit exceeded at 2 AM because some cache is holding references it shouldn't be, or when your API latency spikes to 3 seconds every few minutes because G1GC is compacting your old generation — you need to understand what's actually happening underneath. Not the JVM flag to paste in. What's actually happening.

Building your own GC, even a toy one, gives you that understanding. You stop thinking about memory as "something Java handles" and start thinking about object graphs, reachability, fragmentation, and the exact trade-offs between throughput and pause time. Those concepts transfer directly to debugging production systems.

I went deep on this when I was fighting heap issues on a high-traffic application and the JVM flags I was throwing at the problem weren't sticking — I didn't know why they'd work or not because I didn't have the mental model. If you want the full story on JVM heap tuning, GC selection, and every memory error you'll ever hit, go read Understanding Heap Memory in Java Applications — that's the companion piece to this one. What we're doing here is going one layer deeper: building the actual machinery.

Understanding Java's Memory Model First

Before we write a single line of GC code, you need to have two things locked in your head: the Heap and the Stack. These aren't optional prerequisites — misunderstanding them is the reason most GC implementations (and most GC debugging sessions) go sideways.

The Stack is per-thread memory that holds the current execution context: method call frames, local variables, and references to heap objects. It operates LIFO. When a method returns, its frame is popped and those local variables are gone. Stack memory is fast, bounded, and managed automatically by the JVM as methods are called and returned.

The Heap is where objects actually live. Every new SomeObject() puts that object on the heap. The heap is shared across threads. It's where garbage collection operates. And critically — an object on the heap stays alive as long as something is holding a reference to it, either from the stack (a local variable in an active method frame) or from another heap object.

The implication for garbage collection: finding dead objects means finding objects that nothing reachable can reach. You start from known-live roots (active stack frames, static fields, JNI references) and trace the entire object graph. Everything you can't reach from roots is garbage.

This is the fundamental insight every GC algorithm is built on.

Custom Allocation from Scratch

The JVM's allocator gives you new. We're going to build our own allocator from a fixed-size memory pool. This is a common pattern in embedded systems, game engines, and high-frequency trading systems where the cost of the default allocator's overhead is unacceptable, or where you need deterministic allocation time.

The concept is simple: pre-allocate a block of memory at startup and hand out fixed-size chunks from it. No OS calls during allocation. No unpredictable latency. But also no flexibility in block size — each block is the same size regardless of what you're storing in it.

Here's the full implementation:

public class MemoryBlock {
    private final byte[] data;
    private final int size;

    public MemoryBlock(byte[] data, int size) {
        this.data = data;
        this.size = size;
    }

    public byte[] getData() {
        return data;
    }

    public int getSize() {
        return size;
    }
}

byte[] data — The raw bytes backing this block. In a real allocator this would be a region of off-heap memory managed via java.nio.ByteBuffer or through JNI. Here we keep it simple with a heap array for demonstration.

int size — The capacity of this block in bytes. Fixed at construction time and immutable. In a variable-size allocator you'd track both capacity and actual used bytes separately.

public class MemoryPool {
    private final MemoryBlock[] blocks;
    private final boolean[] available;
    private final int blockSize;
    private final int numberOfBlocks;

    public MemoryPool(int blockSize, int numberOfBlocks) {
        this.blockSize = blockSize;
        this.numberOfBlocks = numberOfBlocks;
        this.blocks = new MemoryBlock[numberOfBlocks];
        this.available = new boolean[numberOfBlocks];

        for (int i = 0; i < numberOfBlocks; i++) {
            blocks[i] = new MemoryBlock(new byte[blockSize], blockSize);
            available[i] = true;
        }
    }

    public MemoryBlock allocate() {
        for (int i = 0; i < numberOfBlocks; i++) {
            if (available[i]) {
                available[i] = false;
                return blocks[i];
            }
        }
        return null; // Pool exhausted
    }

    public void deallocate(MemoryBlock block) {
        for (int i = 0; i < numberOfBlocks; i++) {
            if (blocks[i] == block) {
                available[i] = true;
                return;
            }
        }
        throw new IllegalArgumentException("Block not managed by this pool.");
    }
}

boolean[] available — A parallel array to blocks[] tracking which blocks are free. Index i in available corresponds to index i in blocks. When available[i] is true, blocks[i] is free to allocate.

allocate() — Linear scan through the available array looking for the first free block. O(n) worst case. A production allocator would maintain a free list (a linked list of free block indices) to make this O(1), but linear scan is fine for demonstration.

deallocate(MemoryBlock block) — Reference equality scan (blocks[i] == block) to find the block to release. We throw IllegalArgumentException if the block isn't in our pool — this is important. A double-free (calling deallocate twice on the same block) is one of the most dangerous memory bugs in manual allocation, and we should catch it. In this implementation, a double-free would find the block in the scan with available[i] already true, and set it true again — silently doing nothing. A more defensive implementation would check for and throw on double-frees explicitly.

Putting it together:

public class MemoryAllocatorDemo {
    public static void main(String[] args) {
        MemoryPool pool = new MemoryPool(128, 10); // 10 blocks, 128 bytes each

        MemoryBlock block1 = pool.allocate();
        if (block1 != null) {
            System.out.println("Allocated block, size: " + block1.getSize() + " bytes");

            // Write something into the block
            byte[] data = block1.getData();
            data[0] = 42;
            data[1] = 99;
            System.out.println("Wrote to block: " + data[0] + ", " + data[1]);
        }

        // Simulate exhausting the pool
        MemoryBlock[] blocks = new MemoryBlock[10];
        for (int i = 0; i < 10; i++) {
            blocks[i] = pool.allocate();
        }
        MemoryBlock overflow = pool.allocate(); // This will return null
        System.out.println("Pool exhausted, allocation returned: " + overflow); // null

        // Release and reallocate
        pool.deallocate(blocks[0]);
        MemoryBlock reused = pool.allocate();
        System.out.println("Reallocated block: " + (reused != null ? reused.getSize() + " bytes" : "failed"));
    }
}

Real-world context: This fixed-size pool pattern shows up directly in netty's PooledByteBufAllocator, in the JVM's own TLAB (Thread-Local Allocation Buffer) design, and in any system where you need allocation to take nanoseconds rather than microseconds. The tradeoff is internal fragmentation — if your objects are smaller than blockSize, you waste the difference, and if they're larger, they simply don't fit.

Reference Tracking: Knowing When an Object Is Dead

You have a memory pool. You can allocate and deallocate blocks. But how do you know when to deallocate? That's the reference tracking problem.

There are two families of approaches: reference counting and tracing. The JVM uses tracing (specifically, various forms of mark-and-trace). Reference counting is simpler to understand and implement, so we'll build that first — and then show exactly why it's not sufficient on its own.

Reference Counting

Every object carries a counter of how many live references point to it. Increment when a reference is created, decrement when one is destroyed. When the count hits zero, the object is unreachable and can be reclaimed.

public class RefCountedObject {
    private volatile int refCount;
    private volatile String data;

    public RefCountedObject(String data) {
        this.data = data;
        this.refCount = 1; // The creator holds the first reference
    }

    public synchronized void addReference() {
        if (refCount <= 0) {
            throw new IllegalStateException(
                "Attempted to add reference to already-destroyed object: " + data
            );
        }
        refCount++;
        System.out.println("Reference added. Count now: " + refCount);
    }

    public synchronized void removeReference() {
        if (refCount <= 0) {
            throw new IllegalStateException(
                "Attempted to remove reference from already-destroyed object."
            );
        }
        refCount--;
        System.out.println("Reference removed. Count now: " + refCount);
        if (refCount == 0) {
            destroy();
        }
    }

    private void destroy() {
        System.out.println("Destroying object with data: '" + data + "'");
        this.data = null;
    }

    public String getData() {
        return data;
    }

    public int getRefCount() {
        return refCount;
    }
}

The synchronized on addReference and removeReference is necessary — multiple threads may be adding and removing references concurrently, and a non-atomic decrement-and-check-for-zero is a classic race condition. The guards against negative reference counts (refCount <= 0 checks) are important — they catch use-after-free scenarios where code tries to reference an object that's already been destroyed.

Using it correctly requires discipline:

public class ReferenceManagement {
    public static void main(String[] args) {
        RefCountedObject obj = new RefCountedObject("Important Data");
        // refCount = 1 (creator holds the reference)

        // Sharing the object: explicitly increment before assigning
        RefCountedObject sharedRef = obj;
        sharedRef.addReference();
        // refCount = 2

        System.out.println("Thread A processing: " + obj.getData());
        processData(sharedRef);

        // Done with sharedRef in this scope
        sharedRef.removeReference();
        // refCount = 1

        // Done with original reference
        obj.removeReference();
        // refCount = 0 → destroy() called
        System.out.println("obj.getData() after destroy: " + obj.getData()); // null
    }

    private static void processData(RefCountedObject obj) {
        System.out.println("Processing: " + obj.getData());
        // obj.removeReference() NOT called here — the caller manages lifetime
    }
}

The Circular Reference Problem

Reference counting has a fatal flaw that makes it insufficient as a standalone GC strategy: it cannot collect cycles.

public class CycleDemo {
    // Simplified node class to demonstrate the problem
    static class Node {
        String name;
        Node next; // Reference to another node

        Node(String name) {
            this.name = name;
        }
    }

    public static void main(String[] args) {
        Node a = new Node("A");
        Node b = new Node("B");

        a.next = b; // A → B
        b.next = a; // B → A  (cycle!)

        // Now we "release" both from the outside world
        a = null;
        b = null;

        // Both nodes are unreachable from any root.
        // But their internal reference counts are:
        //   Node A: count = 1 (from B.next)
        //   Node B: count = 1 (from A.next)
        // Neither ever reaches zero. Neither is ever collected.
        // This is a memory leak.
    }
}

CPython (Python's reference-counting implementation) has this exact problem and handles it with a separate cycle detector that runs periodically. Every reference-counting system that supports mutable object graphs needs something equivalent.

In Java, you can partially address this with WeakReference for back-pointers:

import java.lang.ref.WeakReference;

public class NodeWithWeakBackRef {
    String name;
    NodeWithWeakBackRef next;                   // strong forward reference
    WeakReference<NodeWithWeakBackRef> parent;  // weak back reference (won't prevent GC)

    NodeWithWeakBackRef(String name) {
        this.name = name;
    }
}

A WeakReference doesn't count toward GC reachability. If the only references to an object are weak, the GC is free to collect it. This is the right pattern for parent pointers in trees, observer patterns, and caches where entries should disappear when nothing else references them. Java gives you three reference strength levels beyond strong: WeakReference, SoftReference, and PhantomReference — each with different lifetime guarantees.

The fundamental limitation of reference counting is why the JVM uses tracing GC instead — tracing-based collectors handle cycles naturally because they never count references at all. They just ask: "Can I reach this object from any root?"

GC Algorithms: The Three Families

Every garbage collection algorithm in existence is a variation of three core approaches. Understanding each one's trade-offs explains why G1GC, ZGC, and Shenandoah make the choices they make.

Algorithm 1: Mark and Sweep

The simplest tracing GC. Two phases: traverse the live object graph and mark everything reachable, then scan the entire heap and free everything that isn't marked.

import java.util.*;

// Our simplified object model for the GC algorithms
class ManagedObject {
    String name;
    boolean marked = false;
    List<ManagedObject> references = new ArrayList<>();

    ManagedObject(String name) {
        this.name = name;
    }

    void addReference(ManagedObject other) {
        references.add(other);
    }
}

public class MarkAndSweepGC {

    // Phase 1: Mark all objects reachable from roots
    public static void mark(ManagedObject root) {
        if (root == null || root.marked) return;
        root.marked = true;
        System.out.println("Marked: " + root.name);
        for (ManagedObject ref : root.references) {
            mark(ref); // Depth-first traversal of the object graph
        }
    }

    // Phase 2: Sweep unreachable objects, reset marks on live objects
    public static List<String> sweep(List<ManagedObject> allObjects) {
        List<String> collected = new ArrayList<>();
        Iterator<ManagedObject> it = allObjects.iterator();

        while (it.hasNext()) {
            ManagedObject obj = it.next();
            if (!obj.marked) {
                collected.add(obj.name);
                it.remove(); // Simulate freeing the object
                System.out.println("Collected: " + obj.name);
            } else {
                obj.marked = false; // Reset mark for next GC cycle
            }
        }
        return collected;
    }

    public static void main(String[] args) {
        // Build an object graph:
        // root → A → B
        //          → C
        //   D (unreachable — not connected to root)
        ManagedObject root = new ManagedObject("root");
        ManagedObject a = new ManagedObject("A");
        ManagedObject b = new ManagedObject("B");
        ManagedObject c = new ManagedObject("C");
        ManagedObject d = new ManagedObject("D"); // unreachable

        root.addReference(a);
        a.addReference(b);
        a.addReference(c);
        // d is not referenced by anyone

        List<ManagedObject> heap = new ArrayList<>(Arrays.asList(root, a, b, c, d));

        System.out.println("=== Before GC: " + heap.stream().map(o -> o.name).toList());

        mark(root);
        List<String> collected = sweep(heap);

        System.out.println("=== After GC: " + heap.stream().map(o -> o.name).toList());
        System.out.println("=== Collected: " + collected);
        // Should collect only D
    }
}

Why it.remove() and not heap.remove(obj)? Because removing from a List while iterating it without an Iterator throws ConcurrentModificationException. The Iterator.remove() call removes the current element safely mid-iteration.

The mark reset: Note obj.marked = false in the sweep phase for live objects. If we don't reset, every subsequent mark phase will immediately see all live objects as already-marked and short-circuit. The reset happens during sweep rather than at the start of mark to keep the mark phase clean.

Trade-offs of Mark and Sweep:

  • Simple. Both phases are conceptually straightforward.
  • Handles cycles (because we trace, not count).
  • Creates fragmentation. After sweeping, live objects are scattered throughout the heap with holes where dead objects were. Future allocations have to fit into these holes, eventually leading to a situation where there's technically enough total free space but no contiguous region large enough for a large object. This is the fragmentation problem that the next algorithm solves.

Algorithm 2: Mark and Compact

Same mark phase. Different sweep phase: instead of just freeing dead objects, we slide all live objects together toward the front of the heap. No fragmentation. Clean linear allocation pointer.

public class MarkAndCompactGC {

    public static void mark(ManagedObject root) {
        if (root == null || root.marked) return;
        root.marked = true;
        for (ManagedObject ref : root.references) {
            mark(ref);
        }
    }

    // Compact: slide live objects to the front, null out the rest
    public static void compact(ManagedObject[] heap) {
        // First: mark phase must have already run

        int writeIndex = 0; // Next position to write a live object

        // Pass 1: Move live objects forward
        for (int i = 0; i < heap.length; i++) {
            if (heap[i] != null && heap[i].marked) {
                heap[i].marked = false; // Reset mark for next cycle
                if (i != writeIndex) {
                    heap[writeIndex] = heap[i];
                    heap[i] = null; // Clear original position
                    System.out.println("Moved '" + heap[writeIndex].name + 
                        "' from slot " + i + " to slot " + writeIndex);
                }
                writeIndex++;
            }
        }

        // Pass 2: Null out all slots after the last live object
        for (int i = writeIndex; i < heap.length; i++) {
            if (heap[i] != null) {
                System.out.println("Collected: " + heap[i].name);
                heap[i] = null;
            }
        }

        System.out.println("Compaction complete. Live objects occupy slots 0-" + (writeIndex - 1));
    }

    public static void main(String[] args) {
        // Heap with gaps simulating previous collections:
        // [root, null, A, null, B, D, null, C]
        ManagedObject root = new ManagedObject("root");
        ManagedObject a = new ManagedObject("A");
        ManagedObject b = new ManagedObject("B");
        ManagedObject c = new ManagedObject("C");
        ManagedObject d = new ManagedObject("D"); // unreachable

        root.addReference(a);
        a.addReference(b);
        b.addReference(c);
        // d is isolated

        ManagedObject[] heap = {root, null, a, null, b, d, null, c};

        System.out.println("=== Before compact:");
        for (int i = 0; i < heap.length; i++) {
            System.out.println("  slot[" + i + "] = " + (heap[i] != null ? heap[i].name : "null"));
        }

        // Mark from roots
        mark(root);

        compact(heap);

        System.out.println("=== After compact:");
        for (int i = 0; i < heap.length; i++) {
            System.out.println("  slot[" + i + "] = " + (heap[i] != null ? heap[i].name : "null"));
        }
    }
}

The big win of compaction: After this runs, all live objects are contiguous starting from slot 0. New allocations just increment a pointer — no searching for a free hole. This is called bump-pointer allocation, and it's why compacting collectors can allocate faster than malloc-style free list allocators.

The cost: Compaction requires updating every reference to moved objects. If object A moves from address 0x100 to address 0x050, every pointer in every other object that pointed to 0x100 needs to be updated to 0x050. This is called pointer fixup, and it requires either a second pass through the entire heap or a forwarding pointer left at the old location. This is expensive, which is why the JVM doesn't compact on every GC cycle — it compacts during Full GC (Major GC) and uses other strategies during Minor GC.

Algorithm 3: Copying Collector

Instead of compacting in-place, divide the heap into two equal halves (from-space and to-space). Use only from-space for allocation. During GC, copy all live objects to to-space in compact order. Then flip the roles: to-space becomes the new from-space, old from-space is entirely wiped.

import java.util.*;

public class CopyingGC {

    // Returns a deep-copy mapping: originalObject → copiedObject
    // In a real implementation, this would be forwarding pointers into
    // a separate memory region. Here we simulate it with a Map.
    private static final Map<ManagedObject, ManagedObject> forwardingPointers = new LinkedHashMap<>();

    public static ManagedObject copy(ManagedObject obj) {
        if (obj == null) return null;

        // Already copied? Return the forwarding pointer.
        if (forwardingPointers.containsKey(obj)) {
            return forwardingPointers.get(obj);
        }

        // Copy the object to "to-space"
        ManagedObject copy = new ManagedObject(obj.name + "'"); // prime notation marks copy
        forwardingPointers.put(obj, copy);
        System.out.println("Copied: " + obj.name + " → " + copy.name);

        // Recursively copy all referenced objects and update the copy's references
        for (ManagedObject ref : obj.references) {
            copy.references.add(copy(ref));
        }

        return copy;
    }

    public static List<ManagedObject> collectFromRoots(List<ManagedObject> roots) {
        forwardingPointers.clear();

        for (ManagedObject root : roots) {
            copy(root);
        }

        // "to-space" is the values in our forwarding pointer map
        return new ArrayList<>(forwardingPointers.values());
    }

    public static void main(String[] args) {
        ManagedObject root = new ManagedObject("root");
        ManagedObject a = new ManagedObject("A");
        ManagedObject b = new ManagedObject("B");
        ManagedObject dead = new ManagedObject("Dead");

        root.addReference(a);
        a.addReference(b);
        // dead is not referenced

        List<ManagedObject> fromSpace = new ArrayList<>(Arrays.asList(root, a, b, dead));
        System.out.println("=== from-space: " + fromSpace.stream().map(o -> o.name).toList());

        List<ManagedObject> toSpace = collectFromRoots(Collections.singletonList(root));

        System.out.println("=== to-space (new live heap): " + toSpace.stream().map(o -> o.name).toList());
        // dead never gets copied
    }
}

The forwarding pointer trick: When an object is copied, we leave a forwarding pointer at the old location pointing to the new copy. Any subsequent reference to the old object routes through the forwarding pointer to the copy. This is how the JVM's Semispace collector (and the young generation collector in most production GCs) work.

Trade-offs of Copying:

  • No fragmentation ever — to-space is always compacted by construction
  • Allocation is bump-pointer: just increment a pointer
  • Only half the heap is usable — you're paying 2x memory for the space overhead
  • All live objects are touched on every collection — even long-lived objects get copied, which wastes work

This last point is the core motivation for generational GC: most objects die young (the Generational Hypothesis). The JVM applies a Copying collector to the young generation (cheap, fast, most garbage) and a different strategy (Mark & Compact or concurrent marking) to the old generation (fewer collections, larger scope).

Root Object Identification

All three algorithms above need a starting point: the GC roots. In a real JVM, roots are:

  • Local variables in all active stack frames across all threads
  • Static fields of loaded classes
  • References from JNI code
  • References held by the JVM itself (class loaders, thread objects)

In our custom system, we simulate this with explicit registration:

import java.util.*;

public class MemoryManager {
    private final Set<Object> roots = new HashSet<>();
    private final Set<Object> allObjects = new HashSet<>();

    public void registerRoot(Object obj) {
        if (obj == null) throw new IllegalArgumentException("Cannot register null as a root.");
        roots.add(obj);
        allObjects.add(obj);
    }

    public void unregisterRoot(Object obj) {
        roots.remove(obj);
        // Note: obj stays in allObjects until it becomes unreachable and is swept
    }

    public void trackObject(Object obj) {
        allObjects.add(obj);
    }

    // Mark phase: trace all objects reachable from roots
    private Set<Object> markPhase() {
        Set<Object> reachable = new HashSet<>();
        for (Object root : roots) {
            trace(root, reachable);
        }
        return reachable;
    }

    private void trace(Object obj, Set<Object> reachable) {
        if (obj == null || reachable.contains(obj)) return;
        reachable.add(obj);
        // In production: use reflection or a custom object layout to find references
        // Here we use a hook that subclasses/wrappers can implement
        for (Object ref : getReferences(obj)) {
            trace(ref, reachable);
        }
    }

    // Override this with real reference discovery logic in practice
    protected Set<Object> getReferences(Object obj) {
        // Placeholder — a real implementation uses reflection or a custom visitor
        return Collections.emptySet();
    }

    // Sweep phase: remove unreachable objects
    private int sweepPhase(Set<Object> reachable) {
        Set<Object> dead = new HashSet<>(allObjects);
        dead.removeAll(reachable);
        allObjects.removeAll(dead);
        System.out.println("Swept " + dead.size() + " unreachable object(s).");
        return dead.size();
    }

    public int garbageCollect() {
        System.out.println("GC starting. Tracking " + allObjects.size() + " objects, " 
            + roots.size() + " roots.");
        Set<Object> reachable = markPhase();
        System.out.println("Mark phase complete. " + reachable.size() + " reachable objects.");
        return sweepPhase(reachable);
    }

    public int getManagedObjectCount() {
        return allObjects.size();
    }
}

A real implementation of getReferences using reflection would look like this:

import java.lang.reflect.Field;
import java.util.*;

protected Set<Object> getReferences(Object obj) {
    Set<Object> refs = new HashSet<>();
    Class<?> clazz = obj.getClass();

    while (clazz != null) {
        for (Field field : clazz.getDeclaredFields()) {
            // Skip primitives — they can't hold object references
            if (field.getType().isPrimitive()) continue;
            // Skip static fields — those are GC roots, not heap references
            if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) continue;

            field.setAccessible(true);
            try {
                Object value = field.get(obj);
                if (value != null) {
                    refs.add(value);
                }
            } catch (IllegalAccessException e) {
                // Module system may block access — handle gracefully
            }
        }
        clazz = clazz.getSuperclass();
    }
    return refs;
}

This reflection-based approach is how tools like Eclipse MAT and VisualVM traverse the object graph in heap dumps. It's not how a real JVM GC does it — the JVM has direct access to the object layout without reflection overhead — but it demonstrates the concept correctly.

Putting It All Together: A Complete Custom GC

Here's the full CustomGarbageCollector that wires together everything we've built: the memory pool for allocation, reference counting for explicit lifetime management, and a mark-sweep-compact cycle:

import java.util.*;

public class CustomGarbageCollector {

    // ----- Memory Pool -----
    private final MemoryPool memoryPool;

    // ----- Object Graph Tracking -----
    private final Set<Object> roots = new HashSet<>();
    private final List<Object> heap = new ArrayList<>(); // Ordered for compaction simulation
    private final Set<Object> reachable = new HashSet<>();

    public CustomGarbageCollector(int blockSize, int numberOfBlocks) {
        this.memoryPool = new MemoryPool(blockSize, numberOfBlocks);
    }

    // ----- Allocation -----
    public RefCountedObject allocate(String data) {
        MemoryBlock block = memoryPool.allocate();
        if (block == null) {
            System.out.println("Pool full — triggering GC to reclaim memory...");
            runGC();
            block = memoryPool.allocate();
            if (block == null) {
                throw new OutOfMemoryError("Memory pool exhausted even after GC.");
            }
        }
        RefCountedObject obj = new RefCountedObject(data);
        heap.add(obj);
        System.out.println("Allocated: '" + data + "' (heap size: " + heap.size() + ")");
        return obj;
    }

    // ----- Root Management -----
    public void registerRoot(Object obj) {
        roots.add(obj);
    }

    public void unregisterRoot(Object obj) {
        roots.remove(obj);
    }

    // ----- GC Cycle: Mark → Sweep → Compact -----
    public void runGC() {
        System.out.println("\n=== GC Cycle Start (heap: " + heap.size() + " objects) ===");
        markPhase();
        int swept = sweepPhase();
        compactPhase();
        reachable.clear();
        System.out.println("=== GC Cycle End (swept: " + swept + 
            ", remaining: " + heap.size() + ") ===\n");
    }

    private void markPhase() {
        reachable.clear();
        for (Object root : roots) {
            trace(root);
        }
        System.out.println("Mark: " + reachable.size() + " reachable objects.");
    }

    private void trace(Object obj) {
        if (obj == null || reachable.contains(obj)) return;
        reachable.add(obj);
        // Placeholder — extend with reflection-based reference discovery
        for (Object ref : getReferences(obj)) {
            trace(ref);
        }
    }

    protected Set<Object> getReferences(Object obj) {
        return Collections.emptySet();
    }

    private int sweepPhase() {
        int before = heap.size();
        Iterator<Object> it = heap.iterator();
        while (it.hasNext()) {
            Object obj = it.next();
            if (!reachable.contains(obj)) {
                it.remove();
                // Return block to pool if it was pool-allocated
                // (simplified — in practice track block→object mapping)
                System.out.println("Swept: " + objectLabel(obj));
            }
        }
        return before - heap.size();
    }

    private void compactPhase() {
        // Simulate compaction: reorder heap list so live objects are contiguous
        // In a real heap, this would update all pointers to moved objects
        int liveCount = heap.size(); // After sweep, all remaining are live
        System.out.println("Compact: " + liveCount + " live objects consolidated.");
    }

    private String objectLabel(Object obj) {
        if (obj instanceof RefCountedObject rco) {
            return "RefCountedObject(" + (rco.getData() != null ? rco.getData() : "destroyed") + ")";
        }
        return obj.getClass().getSimpleName() + "@" + Integer.toHexString(System.identityHashCode(obj));
    }

    public int heapSize() {
        return heap.size();
    }

    // ----- Demo -----
    public static void main(String[] args) {
        CustomGarbageCollector gc = new CustomGarbageCollector(128, 5);

        // Allocate and register some roots
        RefCountedObject a = gc.allocate("Root Object A");
        RefCountedObject b = gc.allocate("Root Object B");
        RefCountedObject temp = gc.allocate("Temporary Object");

        gc.registerRoot(a);
        gc.registerRoot(b);
        // temp is NOT registered as a root — it should be collected

        RefCountedObject c = gc.allocate("Root Object C");
        gc.registerRoot(c);

        System.out.println("Before GC heap size: " + gc.heapSize()); // 4

        gc.runGC();

        System.out.println("After GC heap size: " + gc.heapSize());  // 3 (temp collected)

        // Unregister a root and collect again
        gc.unregisterRoot(b);
        gc.runGC();

        System.out.println("Final heap size: " + gc.heapSize()); // 2 (b collected)
    }
}

Production Reality: What This Teaches You About the JVM

Building this toy GC reveals exactly why the real JVM collectors are designed the way they are.

Why generational GC? Our copying collector touches every live object on every collection. The JVM's insight is that 90%+ of objects die in their first GC cycle — so collecting the young generation frequently (where the garbage density is highest) with a fast copying collector, while only occasionally collecting the old generation with a more expensive algorithm, dramatically reduces total GC work. The cost of promotion (moving an object from young to old gen) is worth it because old-gen collections are rare.

Why concurrent GC? Our mark-sweep implementation stops the world: the application pauses during mark and sweep. For a 4GB heap with millions of objects, that pause might be seconds. G1GC's concurrent marking, ZGC's concurrent compaction, and Shenandoah's concurrent evacuation all exist to do GC work while the application keeps running, reducing stop-the-world pauses to single-digit milliseconds even on large heaps. This connects directly to what we covered in Async Timeouts with CompletableFuture — long GC pauses are one of the hidden causes of async timeout failures that have nothing to do with your application logic.

Why write barriers? When the application runs concurrently with the GC's mark phase, the application can create new references that the GC hasn't seen yet. Write barriers are hooks that run every time a reference field is written — they notify the GC "hey, this object now points at that one." Without write barriers, a concurrent GC would incorrectly collect live objects. This is one of the hardest parts of concurrent GC implementation, and it's why the JVM JIT has to instrument every reference write.

Why does compaction pause everything? Compaction moves objects and then has to update every reference to them. During this update pass, no thread can be reading those references — otherwise it might see a stale address. This is why Mark & Compact is a stop-the-world operation, and why ZGC's "load barriers" (which reroute reads through forwarding pointers rather than stopping the world to fix all pointers) are such a significant engineering achievement.

If you want to go further with the Java memory story — understanding the heap regions, the JVM flags that control all of this, and how to diagnose memory problems in production — Understanding Heap Memory in Java Applications is where to go next. And if you're interested in how Java's stream processing connects to memory efficiency and concurrent execution, Implementing Virtual Threads in Java Streams covers that territory.

The Conclusion

You now understand what a garbage collector actually does at the algorithm level: trace reachability from roots, identify dead objects, reclaim or compact their memory. You understand why reference counting isn't sufficient on its own (cycles), what the three canonical GC algorithms trade off (fragmentation, throughput, memory overhead), and how a custom memory pool works as the foundation everything else builds on.

The toy GC we built here doesn't handle concurrent modification, doesn't do write barriers, doesn't have a generational split, and doesn't do pointer fixup after compaction. A production-ready GC requires all of those things — which is why OpenJDK's GC codebase is hundreds of thousands of lines.

But that's not the point. The point is that you now have the mental model that makes JVM GC behavior legible. The next time you see GC overhead limit exceeded, or Full GC firing every 30 seconds, or latency spikes that correlate with GC logs — you'll know what's actually happening, and you'll know what levers to reach for.