Java Garbage Collection Under the Hood: A Step-by-Step Walkthrough

Java Garbage Collection Under the Hood: A Step-by-Step Walkthrough

A comprehensive, deep dive into JVM Garbage Collection internals — covering generational hypothesis heap layouts, promotion lifecycles, mark-sweep-copy-compact algorithms, G1 and ZGC mechanics, memory leaks, and interactive object promotion simulations.

One of the greatest benefits of the Java Virtual Machine (JVM) is automatic memory management. Instead of manually tracking allocations and deallocations using malloc and free or new and delete, Java developers simply create objects and trust the JVM's **Garbage Collector (GC)** to reclaim the memory when those objects are no longer needed. But this convenience comes with a trade-off: GC pauses can introduce latency spikes, affect throughput, and disrupt real-time systems. Understanding how GC works is critical for optimizing performance and avoiding memory leaks.

This walkthrough will open the hood of the JVM memory engine. We will analyze the **Generational Hypothesis** that governs heap design, trace the path of an object from creation in Eden to promotion in the Tenured generation, and compare the mathematical trade-offs of modern GC algorithms (Parallel, G1, and ZGC). By the end, you will write a simulation to trace object lifetimes, understand GC logs, and see garbage collection operate dynamically through our interactive heap visualizer.


1. The Core Problem: Manual vs Automatic Memory Management

1.1 Why Automatic Memory Management Exists

In systems programming languages like C or C++, memory management is explicit and manual. The developer allocates memory on the heap (e.g., int* arr = new int[100];) and must remember to deallocate it (e.g., delete[] arr;) when it is no longer needed. If memory is deallocated too early, any remaining pointers become **dangling pointers**, leading to undefined behavior or security crashes. If memory is never deallocated, it causes a **memory leak**, gradually consuming all available system memory until the operating system terminates the process. Manual management is highly efficient but notoriously prone to human error.

Java eliminates this category of bugs by delegating memory management to the JVM's Garbage Collector. The GC runs in the background, identifying objects that are no longer accessible by the application and reclaiming their heap space. This lets developers focus on business logic rather than pointer management, dramatically increasing productivity and safety. However, the runtime must periodically stop application threads (known as **Stop-The-World (STW)** pauses) to scan memory safely, introducing latency trade-offs that system architects must understand.

1.2 Stop-the-World Pauses and Safepoints

To identify garbage objects, the GC must walk the graph of object references starting from known root points (like thread stacks, static variables, and JNI handles). If the application threads are actively modifying references while the GC is scanning, the GC could miss accessible objects, leading to premature deallocation. To prevent this, the JVM brings all application threads to a halt at predefined **Safepoints** (points in execution where thread states are consistent, like loop boundaries or method calls) before performing a collection. The duration of this Stop-The-World (STW) pause is the primary performance bottleneck in GC-tuned applications.

Common Misconception — GC Reclaims Memory Instantly: A common misconception is that when a reference variable is set to null, the referenced object is instantly deallocated. In reality, setting a reference to null merely makes the object *eligible* for garbage collection. The object remains in heap memory until the next GC cycle runs, which could be seconds, minutes, or even hours later, depending on heap sizing and allocation rates.


2. JVM Heap Architecture: Generational Hypothesis

2.1 The Generational Hypothesis

The JVM heap is structured around a fundamental empirical observation called the **Generational Hypothesis**: most objects die young. In typical applications, objects are created to perform short-lived tasks (like local variables in a method or temporary HTTP request contexts) and become unreachable within milliseconds. A very small percentage of objects (like database connection pools, application configuration, or long-lived caches) survive for long periods. If the GC scanned the entire heap on every collection, it would spend 99% of its time scanning long-lived objects that are still active, which is highly inefficient.

To exploit this hypothesis, the JVM divides the heap into two main regions: the **Young Generation** (where new objects are allocated) and the **Old Generation** (or Tenured Generation, where surviving objects are promoted). By separating these regions, the GC can perform frequent, fast scans on the Young Generation (where the density of dead objects is high) while scanning the Old Generation far less frequently. This generational partition is what makes JVM garbage collection highly performant.

2.2 Young Gen (Eden, S0, S1) and Old Gen Sizing

The Young Generation is further subdivided into three spaces: **Eden**, **Survivor Space 0 (S0)**, and **Survivor Space 1 (S1)**. By default, Eden takes up 80% of the Young Gen space, while S0 and S1 take up 10% each. When a thread allocates an object, it is placed in Eden. S0 and S1 act as temporary holding areas during collection. At any given time, one of the survivor spaces is empty. During a Minor GC, surviving objects in Eden and the active survivor space are copied to the empty survivor space, and their age (the number of GC cycles survived) is incremented. If an object survives enough cycles (the **tenuring threshold**, default 15), it is promoted to the Old Generation.

graph LR subgraph "Young Generation" Eden["Eden (New Allocations)"] S0["Survivor S0"] S1["Survivor S1"] end subgraph "Old Generation" Old["Tenured (Long-Lived Objects)"] end Eden -->|"Minor GC Copy"| S0 S0 -->|"Minor GC Copy"| S1 S1 -->|"Minor GC Copy"| S0 S0 -->|"Promotion (Age > Threshold)"| Old S1 -->|"Promotion (Age > Threshold)"| Old

Mermaid Diagram: The promotion path of objects through the JVM heap spaces.

Pitfall — Premature Promotion: If the Young Generation size is too small, Eden and Survivor spaces fill up rapidly. During Minor GC, if the survivor space is too small to hold all surviving objects, the JVM is forced to bypass the tenuring threshold and promote those young objects directly to the Old Generation. This is called **premature promotion**. It fills the Old Generation with short-lived objects, triggering frequent, expensive Major or Full GCs that degrade application throughput. Ensure your survivor spaces are sized appropriately to let young objects die in the Young Gen.


3. The GC Lifecycle: Minor vs Major vs Full GC

3.1 Minor GC Mechanics

A **Minor GC** (or Young GC) is triggered automatically when the Eden space becomes full. During a Minor GC, the collector scans only the Young Generation. Because most young objects are dead, Minor GC is extremely fast (often taking only 2–10 milliseconds) and reclaims a massive amount of memory. Application threads are stopped during Minor GC, but the pause is so short it is barely noticeable in most applications. The surviving objects are copied to the empty survivor space, and the empty survivor and Eden spaces are cleared completely in a single pass.

3.2 Major GC and Full GC

A **Major GC** cleans only the Old Generation. A **Full GC** cleans the entire heap — Young Generation, Old Generation, and Metaspace (where class metadata is stored). Full GCs are triggered when: (1) the Old Generation runs out of space, (2) Metaspace runs out of space, (3) the application explicitly calls System.gc(), or (4) the G1 collector cannot keep up with allocations, falling back to a single-threaded full collection. Because the Old Generation is large and contains many active objects, Full GCs require scanning the entire heap memory, resulting in STW pauses ranging from hundreds of milliseconds to several seconds, which can severely degrade performance.

Pitfall — Running System.gc() in Production: Calling System.gc() in your code is a performance anti-pattern. It suggests to the JVM that a Full GC should run immediately. The JVM will halt all threads and perform a complete heap sweep, regardless of whether a collection is actually needed. This adds massive, unnecessary STW latency. Always run JVMs with the flag -XX:+DisableExplicitGC to make the JVM ignore any manual System.gc() calls in your application or third-party libraries.


4. Garbage Collection Algorithms: Mark, Sweep, Copy, and Compact

4.1 Mark and Sweep

The foundation of garbage collection is the **Mark-Sweep** algorithm. It runs in two phases: (1) **Mark**: the GC starts from reference roots (thread stacks, static contexts) and traverses the object graph using depth-first or breadth-first search. Every object reached is "marked" as active (usually by setting a bit in the object header or a separate bitmap). (2) **Sweep**: the GC scans the entire heap linearly. Any object that is *not* marked is unreachable and has its memory reclaimed by adding its address block to a **Free List** (a linked list tracking free memory blocks). Mark-Sweep is simple but causes severe memory fragmentation over time.

4.2 Copying and Compacting

To prevent memory fragmentation, collectors use **Copying** or **Compacting** algorithms. A **Copying** algorithm (used in Young Gen Minor GC) divides space into two halves. It sweeps the active half, copies all surviving objects to the start of the empty half (packing them contiguously), and clears the old half completely. This eliminates fragmentation and makes allocation extremely fast (just incrementing a pointer, called **Bump-the-Pointer** allocation). However, it requires double the memory space. A **Compacting** algorithm (used in Old Gen Major GC) marks active objects, slides them all to the beginning of the heap contiguously, and clears the remaining space. This avoids the 2x memory penalty of copying but is computationally expensive because it must update every reference pointer in the application to point to the object's new memory address.

$$ \text{Compacted Address: } \text{Addr}_{\text{new}}(obj) = \text{Addr}_{\text{start}} + \sum_{k < obj} \text{Size}(k) $$

5. Modern GC Collectors: Serial, Parallel, G1, and ZGC

5.1 G1 (Garbage-First) Collector

The **G1 Collector** (default since Java 9) replaces the traditional Young/Old Gen physical boundaries with a grid of thousands of equal-sized memory **Regions** (1 MB to 32 MB). Each region is assigned a logical role (Eden, Survivor, Old) dynamically. G1 tracks the density of dead objects in each region and collects regions that contain the most garbage first — hence "Garbage-First". By collecting regions incrementally in parallel, G1 keeps STW pauses short (typically under 100 milliseconds) while handling heaps ranging from 4 GB to 64 GB+.

5.2 ZGC (Z Garbage Collector)

The **ZGC Collector** (introduced in Java 11, production-ready in Java 15) is a scalable, low-latency concurrent garbage collector. It performs all core GC work — marking, copying, and compaction — **concurrently with application threads**. ZGC achieves this using **Colored Pointers** (storing metadata bits inside the object reference pointer itself) and **Load Barriers** (interceptor code executed whenever the application reads a reference from the heap, updating pointers on the fly if the object is being moved). ZGC guarantees STW pauses under 1 millisecond, even on heaps of several terabytes, making it the ideal choice for high-frequency trading, real-time gaming, and ultra-low-latency backend APIs.


6. JVM Memory Allocation and TLABs

6.1 Thread Local Allocation Buffers

Because the JVM is a multithreaded environment, having all threads allocate memory from a single heap pool would require synchronization locks, slowing down allocation speed. To solve this, the JVM uses **Thread Local Allocation Buffers (TLABs)**. A TLAB is a small region of the Eden space allocated exclusively to a single thread. When a thread creates an object, it allocates it inside its own TLAB without any locks, using simple pointer bumping. Locks are only required when a thread's TLAB becomes full and it must request a new TLAB block from the shared heap pool. This keeps Java object allocation as fast as C/C++ stack allocation.


7. Advanced: Garbage Collection Tuning and Performance Flags

7.1 Key Tuning Flags

Optimizing JVM performance requires configuring GC flags. The most critical flags are:

  • -Xms and -Xmx: Sets the minimum and maximum heap sizes. It is best practice to set these equal (e.g., -Xms4g -Xmx4g) to prevent the JVM from constantly resizing the heap, which adds performance overhead.
  • -XX:+UseG1GC / -XX:+UseZGC: Selects the garbage collector.
  • -XX:MaxGCPauseMillis=n: Sets a target for maximum STW pause time. G1 will adjust its region selection and collection rates dynamically to meet this target.

8. Advanced: Memory Leaks in Java

8.1 How Memory Leaks Occur

Even though memory is managed automatically, Java applications can still leak memory. A Java memory leak occurs when an object is **no longer used by the application logic, but remains reachable from reference roots**. Because the object is reachable, the GC cannot deallocate it. The most common cause is holding references to short-lived objects inside long-lived structures (like static collections, thread pools, or unclosed resource streams).

To fix memory leaks, developers use **WeakReferences** or **SoftReferences** (which allow the GC to deallocate the object if no strong references remain) and profile their applications using tools like VisualVM or Eclipse Memory Analyzer (MAT) to analyze heap dumps and find the path of strong references keeping dead objects active.


9. Complete Java GC Trace and Simulation Code

9.1 Promotion Simulation Script

The following complete Python script simulates the promotion of objects through Eden, Survivor, and Old Gen spaces under constant allocation and collection cycles, tracing their age promotion path:

class HeapObject:
    def __init__(self, obj_id, size):
        self.id = obj_id
        self.size = size
        self.age = 0
 
class GCSystem:
    def __init__(self, eden_cap, surv_cap, tenure_threshold):
        self.eden_cap = eden_cap
        self.surv_cap = surv_cap
        self.tenure_threshold = tenure_threshold
        
        self.eden = []
        self.s0 = []
        self.s1 = []
        self.old_gen = []
        self.active_surv = 0 # 0 for s0, 1 for s1
 
    def allocate(self, obj):
        eden_used = sum(o.size for o in self.eden)
        if eden_used + obj.size > self.eden_cap:
            self.minor_gc()
        self.eden.append(obj)
 
    def minor_gc(self):
        print("\n--- Minor GC Triggered ---")
        source_surv = self.s0 if self.active_surv == 0 else self.s1
        dest_surv = self.s1 if self.active_surv == 0 else self.s0
        
        # Collect Eden and active survivor
        survivors = [o for o in self.eden + source_surv]
        self.eden = []
        source_surv.clear()
 
        for obj in survivors:
            obj.age += 1
            if obj.age >= self.tenure_threshold:
                self.old_gen.append(obj)
                print(f"Promoted Object {obj.id} to Old Gen (Age: {obj.age})")
            else:
                dest_surv.append(obj)
                print(f"Kept Object {obj.id} in Survivor Space (Age: {obj.age})")
        
        self.active_surv = 1 - self.active_surv # Swap active survivor
 
# Demo execution
gc = GCSystem(eden_cap=100, surv_cap=30, tenure_threshold=3)
for i in range(15):
    gc.allocate(HeapObject(obj_id=i, size=30))

10. Interactive: JVM Heap Space Visualizer

Click "Step minor GC" to allocate objects in Eden. When Eden fills, watch the GC move surviving objects (green circles) into Survivor Space and increment their ages, eventually promoting them to the Old Generation (grey box):

Status: Ready
Eden capacity: 3 objects. Survivor capacity: 2. Tenure threshold: age 2.
Click "Step minor GC" to start...
Eden Space Survivor S0 Survivor S1 Old Gen

11. JVM Collectors Pause Time Latency Comparison

The chart below compares the Stop-The-World pause latency of major JVM garbage collectors as the heap size increases. Notice how ZGC maintains sub-millisecond pauses across all sizes:


12. Frequently Asked Questions

Q1: What is the tenuring threshold?

The tenuring threshold is the maximum age (number of Minor GC cycles survived) an object can reach in the Young Generation before being promoted to the Old Generation. The default threshold in CPython-based JVMs is 15. You can adjust this value using the flag -XX:MaxTenuringThreshold=n to optimize object lifetimes.

Q2: How does the G1 collector avoid memory fragmentation?

The G1 collector avoids memory fragmentation by collecting regions incrementally using a copying/compaction algorithm. Instead of sweeping the entire heap, G1 selects a subset of regions containing the most garbage, copies their surviving objects to a fresh empty region contiguously, and clears the old regions completely in a single pass.

Q3: Why are G1 and ZGC preferred over Parallel GC for large heaps?

Parallel GC sweeps the entire heap during collections, resulting in STW pause times that scale linearly with the size of the heap (taking seconds on 32 GB+ heaps). G1 and ZGC decouple pause times from heap size by collecting incrementally or concurrently, guaranteeing predictable latency even on massive heaps.

Q4: What is a safepoint in JVM threads?

A safepoint is a point in thread execution where the JVM has complete control and thread states are consistent (e.g., method calls or loop boundaries). The JVM forces all application threads to block at safepoints before running GC cycles to ensure reference graphs do not change during scanning.

Q5: How do memory leaks occur in Java if there is a GC?

Java memory leaks occur when objects are no longer needed by the program logic, but remain reachable from reference roots (like static fields or thread-local contexts). Since they are reachable, the GC cannot deallocate them, causing heap usage to grow gradually until an OutOfMemoryError occurs.

Q6: What is the difference between Minor, Major, and Full GC?

Minor GC sweeps only the Young Generation. Major GC sweeps only the Old Generation. Full GC sweeps the entire heap (Young, Old, and Metaspace) and is the most expensive type of collection, requiring significant STW pause times.

Q7: Can I disable explicit System.gc() calls?

Yes. You should run the JVM with the flag -XX:+DisableExplicitGC. This forces the JVM to ignore manual System.gc() calls in your application or third-party libraries, preventing unnecessary, expensive Full GCs from degrading performance.

Q8: How do I profile Java memory leaks?

Profile memory leaks by generating a heap dump (using jmap) and loading it into tools like Eclipse Memory Analyzer (MAT) or VisualVM. These tools calculate the reference path from the heap roots to the leaking objects, allowing you to find the strong references preventing deallocation.

Post a Comment

Previous Post Next Post