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.

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. Usetry-with-resourcesandAutoCloseableinstead.
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
nulldoes 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 │ │ │ │ │
│ │ └────────┘ └───────────┘ │ │ │ │
│ └───────────────────────────┘ └─────────────┘ │
└──────────────────────────────────────────────────┘
| Region | Purpose | Collected by |
|---|---|---|
| Eden | New objects are allocated here | Minor GC |
| Survivor (S0 / S1) | Objects that survived at least one Minor GC | Minor GC |
| Old Gen (Tenured) | Long-lived objects promoted from Young Gen | Major / Full GC |
| Metaspace (Java 8+) | Class metadata — lives outside the heap | Full 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
| Collector | Threads | Pauses | Best For |
|---|---|---|---|
| Serial | 1 | Stop-the-world | Tiny apps / single CPU |
| Parallel | Many | Stop-the-world (shorter) | High-throughput batch |
| G1 | Many | Short, bounded | General-purpose servers |
| ZGC | Many | Sub-millisecond | Low-latency, huge heaps |
| Shenandoah | Many | Sub-millisecond | Low-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:
- Mark — traverse the object graph from GC roots, marking every reachable object.
- Sweep — scan the heap and reclaim unmarked (dead) objects.
- 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
-Xmsequal to-Xmxin 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 Type | Cleared by GC | Use Case |
|---|---|---|
Strong (Object ref = new ...) | Never (while reachable) | Normal objects |
SoftReference | When memory is low | Memory-sensitive caches |
WeakReference | Next GC cycle | Canonicalizing mappings |
PhantomReference | After finalization | Resource 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
Related Topics
- JVM Architecture — understand the heap, stack, and method area that GC operates on
- Class Loaders & Class Loading — how classes reach the JVM before objects are ever allocated
- JIT Compilation & Bytecode — the execution engine that runs alongside GC at runtime
- Java Memory Model — visibility and ordering guarantees that GC must respect in multithreaded code
- virtual Keyword & volatile — how the
volatilekeyword interacts with memory visibility and GC - String Pool & intern() — a concrete example of a JVM-managed cache that affects GC behavior