Skip to content
Java getting started 8 min read

Garbage Collection Deep-Dive

Java manages memory for you automatically — when an object is no longer reachable, the JVM’s garbage collector reclaims that memory so you don’t have to call free() like in C/C++. Understanding how that process works turns you from someone who hopes GC “just works” into someone who can diagnose pauses, tune heap sizes, and write GC-friendly code.

Generational heap young old gen

Why Garbage Collection Exists

In languages like C, you allocate memory with malloc and release it with free. Forget to call free and you leak memory; call it too early and you get a crash. Java avoids both problems with automatic GC — the runtime tracks which objects are still in use and periodically sweeps away everything else.

Note: Java does not have destructors in the C++ sense. The finalize() method (deprecated since Java 9, removed in Java 18) was the old hook for cleanup. Use try-with-resources and AutoCloseable instead.

How the JVM Decides What Is Garbage

The GC starts from a set of GC roots — objects it always considers live:

  • Local variables on thread stacks
  • Static fields
  • JNI references (native code pointers)
  • Active thread objects

From those roots, it performs a reachability trace (a graph traversal). Any object that can be reached through any chain of references is live. Everything else is garbage.

public class ReachabilityDemo {
    public static void main(String[] args) {
        String a = "hello";          // reachable via local variable
        String b = new String("hi"); // also reachable

        b = null;                    // b is now unreachable — eligible for GC
        // 'a' is still reachable
    }
}

Tip: Setting a reference to null does not immediately free memory — it only makes the object eligible for GC. The collector reclaims it on its own schedule.

The Generational Heap

Decades of research on real programs revealed a pattern called the generational hypothesis: most objects die young. Java’s heap is split into generations to exploit this:

┌──────────────────────────────────────────────────┐
│                      Heap                        │
│  ┌───────────────────────────┐  ┌─────────────┐ │
│  │        Young Gen          │  │   Old Gen   │ │
│  │  ┌────────┐ ┌───────────┐ │  │  (Tenured)  │ │
│  │  │  Eden  │ │ Survivor  │ │  │             │ │
│  │  │        │ │  S0 / S1  │ │  │             │ │
│  │  └────────┘ └───────────┘ │  │             │ │
│  └───────────────────────────┘  └─────────────┘ │
└──────────────────────────────────────────────────┘
RegionPurposeCollected by
EdenNew objects are allocated hereMinor GC
Survivor (S0 / S1)Objects that survived at least one Minor GCMinor GC
Old Gen (Tenured)Long-lived objects promoted from Young GenMajor / Full GC
Metaspace (Java 8+)Class metadata — lives outside the heapFull GC

Minor GC (Young Generation)

A Minor GC runs when Eden fills up. It is fast (milliseconds) because it only processes the small Young Gen. Surviving objects are copied to a Survivor space; objects that survive multiple Minor GCs (the threshold defaults to 15 cycles) are promoted to Old Gen.

// Every `new` expression allocates in Eden.
// Creating many short-lived objects triggers frequent Minor GCs.
for (int i = 0; i < 1_000_000; i++) {
    String temp = "value-" + i; // allocated in Eden, collected next Minor GC
}

Major / Full GC (Old Generation)

When Old Gen fills up, or when an explicit System.gc() hint is issued, a Full GC runs. Full GCs scan the entire heap and typically cause longer pauses — this is the GC you want to minimize in latency-sensitive apps.

Warning: Calling System.gc() is only a hint; the JVM can ignore it. In production code, avoid calling it unless you have a very specific reason (e.g., forcing GC before a benchmark).

GC Algorithms

Java ships with several collectors. You choose based on your workload’s priorities: throughput, latency, or footprint.

Serial GC (-XX:+UseSerialGC)

Single-threaded. Uses a stop-the-world pause for both Young and Old Gen. Best for small, single-core apps or microservices with tiny heaps (< 100 MB).

Parallel GC (-XX:+UseParallelGC) — Default before Java 9

Uses multiple threads for Minor and Major GC. Optimizes for throughput — great for batch jobs that can tolerate occasional pauses.

G1 GC (-XX:+UseG1GC) — Default since Java 9

G1 (Garbage First) divides the heap into equal-sized regions (~1–32 MB each) instead of fixed-size generations. Regions are labeled dynamically as Eden, Survivor, Old, or Humongous (for large objects). G1 predicts which regions contain the most garbage and collects those first — hence the name.

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200   # target pause time (soft goal)
-Xms512m -Xmx4g

G1 aims for predictable, low-latency pauses and is the right default for most server applications.

ZGC (-XX:+UseZGC) — Production-ready since Java 15

ZGC targets sub-millisecond pauses by doing almost all its work concurrently with the running application. It uses colored pointers and load barriers to relocate objects without stopping threads for long. ZGC is ideal for very large heaps (hundreds of GB) and latency-sensitive services.

Shenandoah GC (-XX:+UseShenandoahGC)

Similar goals to ZGC — concurrent compaction, very low pauses. Available in OpenJDK distributions.

Comparison Table

CollectorThreadsPausesBest For
Serial1Stop-the-worldTiny apps / single CPU
ParallelManyStop-the-world (shorter)High-throughput batch
G1ManyShort, boundedGeneral-purpose servers
ZGCManySub-millisecondLow-latency, huge heaps
ShenandoahManySub-millisecondLow-latency, large heaps

Writing GC-Friendly Code

Small coding habits can significantly reduce GC pressure.

Prefer short-lived, small objects. They are cheap — Eden is fast to collect. Long-lived objects in Old Gen are the expensive ones to reclaim.

Use StringBuilder for string concatenation in loops instead of repeated + operators, which create many intermediate String objects.

// Bad: creates many intermediate String objects
String result = "";
for (int i = 0; i < 1000; i++) {
    result += i; // each += allocates a new String
}

// Good: single StringBuilder, much less GC pressure
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);
}
String result2 = sb.toString();

Release references you no longer need. In long-running code, holding references in static collections or caches prevents GC from reclaiming objects.

import java.util.WeakHashMap;

// WeakHashMap lets the GC reclaim values when no other strong
// references to the keys exist — great for caches.
WeakHashMap<String, byte[]> cache = new WeakHashMap<>();

Avoid finalizers. Objects with finalizers (finalize()) require an extra GC cycle before memory is freed — they go through a finalization queue. Use try-with-resources instead.

// Preferred pattern — AutoCloseable ensures cleanup without finalization
try (var stream = new java.io.FileInputStream("data.txt")) {
    // use stream
} // stream.close() called automatically

Under the Hood

Mark-and-Sweep vs. Copying vs. Compaction

Most modern collectors combine three techniques:

  1. Mark — traverse the object graph from GC roots, marking every reachable object.
  2. Sweep — scan the heap and reclaim unmarked (dead) objects.
  3. Compact — slide live objects together to eliminate fragmentation. This is expensive but lets the allocator use simple pointer bumping for new objects.

Young Gen collectors in G1 and older collectors use a copying strategy (copying live objects between Survivor spaces and Eden) which is fast and inherently compacting. Old Gen collections typically use mark-sweep-compact.

TLAB — Thread-Local Allocation Buffers

Allocating objects concurrently from a shared heap would require locks. Instead, each thread owns a TLAB — a private slice of Eden. Allocation is just an atomic pointer bump within the TLAB — extremely fast (close to stack allocation speed). When a TLAB fills up, the thread gets a fresh one from Eden.

Card Tables and Remembered Sets

A Minor GC should not need to scan Old Gen just to find references into Young Gen. The JVM maintains a card table (a bitmap where each bit represents a 512-byte card of heap memory). When an Old Gen object’s reference field is updated, the corresponding card is dirtied. During Minor GC, only dirty cards are scanned — keeping Minor GC fast.

G1 uses per-region remembered sets (RSets) for the same purpose, tracking which other regions hold references into each region.

GC Logging and Tuning Flags

Java 9+ unified GC logging under -Xlog:

# Print GC events with timestamps
java -Xlog:gc*:file=gc.log:time,uptime -jar myapp.jar

# Common heap sizing flags
-Xms256m       # initial heap size
-Xmx2g         # maximum heap size
-XX:NewRatio=3 # Old Gen : Young Gen ratio (3:1)
-XX:SurvivorRatio=8 # Eden : each Survivor ratio (8:1:1)

Tip: Set -Xms equal to -Xmx in containerized (Docker/Kubernetes) deployments to avoid heap resizing overhead and to make memory usage predictable.

Object Reachability Strengths

Java provides four reference types in java.lang.ref that give you finer control over GC eligibility:

Reference TypeCleared by GCUse Case
Strong (Object ref = new ...)Never (while reachable)Normal objects
SoftReferenceWhen memory is lowMemory-sensitive caches
WeakReferenceNext GC cycleCanonicalizing mappings
PhantomReferenceAfter finalizationResource cleanup hooks
import java.lang.ref.WeakReference;

String original = new String("ephemeral");
WeakReference<String> weak = new WeakReference<>(original);

original = null; // remove strong reference

// GC may now collect the String; weak.get() will return null after that
System.gc();
System.out.println(weak.get()); // may print null
Last updated June 13, 2026
Was this helpful?