JVM Internals & Performance

Garbage Collection Fundamentals

20 min Lesson 3 of 13

Garbage Collection Fundamentals

Java's garbage collector (GC) is one of the defining features of the JVM. It automatically reclaims memory occupied by objects that are no longer reachable by your program, eliminating entire classes of bugs — dangling pointers, double-frees — that plague C and C++ developers. Understanding how the GC makes its decisions is essential for writing high-throughput, low-latency Java applications.

Reachability: What the GC Actually Tracks

The GC does not track every object independently. Instead it works backward from a set of definite starting points called GC roots and traverses every reference those roots can reach, directly or transitively. Any object reachable from a GC root is live and must not be collected. Any object that cannot be reached from any GC root is unreachable and is eligible for collection.

The JVM recognises several kinds of GC roots:

  • Local variables and parameters on the call stack of any active thread.
  • Static fields of loaded classes — they live as long as the class is loaded.
  • Active threads themselves (a Thread object is always a root while running).
  • JNI references — objects held by native code via the JNI.
  • Monitor locks — objects currently held by a synchronized block.
Reachability is transitive. If a root holds a reference to object A, and A holds a reference to B, both A and B are live — regardless of whether any application code will actually use B again. This is exactly how memory leaks happen: a forgotten root (a static Map, an unclosed resource) keeps an entire object graph alive.

Consider the following example:

public class ReachabilityDemo { // Static field — a GC root; everything it references stays alive private static final List<String> CACHE = new ArrayList<>(); public static void main(String[] args) { // 'local' is a GC root (stack variable) while main() is on the call stack String local = "hello"; String unreachable = new String("bye"); // allocated … unreachable = null; // … now unreachable — eligible for GC CACHE.add("persistent"); // held via static field — never collected } }

Generational Garbage Collection

Tracing every object on every GC cycle is prohibitively expensive for large heaps. The JVM exploits a powerful empirical observation called the generational hypothesis:

Most objects die young.

Profiling of real Java applications consistently shows that the vast majority of objects — temporary results, request-scoped data, iterator objects — become unreachable within milliseconds of allocation. A tiny minority of objects (caches, connection pools, class-level state) survive for the lifetime of the application. Generational GC exploits this by dividing the heap into regions and collecting the young region far more frequently than the old region.

The Heap Layout

The JVM heap is split into two main regions:

  • Young Generation (Eden + Survivor spaces S0 and S1): All new objects are allocated here. The Young GC (also called minor GC) runs frequently and collects only this region — because most objects here are already dead, it is very fast.
  • Old Generation (Tenured space): Objects that survive enough minor GCs are promoted to the Old Generation. The Old GC (also called major or full GC) runs infrequently and is far more expensive.
Metaspace (introduced in Java 8, replacing PermGen) holds class metadata — method bytecode, constant pools. It lives in native memory, not the Java heap, and is collected only when a class is unloaded. It is rarely the source of GC pressure in typical applications.

The Young Generation in Detail

The Young Generation uses a copy-collection (semi-space) algorithm:

  1. Allocation — new objects go into Eden using a simple bump-pointer allocator (extremely fast).
  2. Minor GC triggers — when Eden fills up, a minor GC starts. All threads are stopped (a stop-the-world pause).
  3. Live tracing — the GC traces from GC roots and identifies live objects in Eden and the currently active Survivor space.
  4. Copy — live objects are copied into the other (empty) Survivor space. Dead objects are simply abandoned — there is no per-object deallocation.
  5. Promotion — objects that have survived a configurable number of GC cycles (the tenuring threshold, default 15 for most collectors) are promoted to the Old Generation.
  6. Swap — the Survivor spaces swap roles; the just-emptied space becomes the new active one.
// Tuning flags (shown for context — do not change without profiling data) // -XX:NewRatio=2 → Old Gen twice the size of Young Gen (default) // -XX:SurvivorRatio=8 → Eden : each Survivor = 8:1 (default) // -XX:MaxTenuringThreshold=15 → promote after 15 survivals (default)

Promotion and the Old Generation

Objects graduate to the Old Generation in two ways:

  • They exceed the tenuring threshold.
  • The Survivor space is too full to accommodate them (premature promotion).

Premature promotion is a common performance problem: if your application creates large numbers of medium-lived objects, Survivor spaces fill quickly, objects get promoted early, and the Old Generation fills faster than necessary — triggering expensive major GC cycles.

Stop-the-World Pauses

Most GC work requires a stop-the-world (STW) pause: all application threads are halted while the GC runs, then resumed. Minor GC STW pauses are typically a few milliseconds. Major GC STW pauses can be hundreds of milliseconds or longer for large heaps with older collectors.

Allocation rate matters more than heap size. A large heap delays GC but does not reduce the total work done. An application that allocates 1 GB/s will still need to collect 1 GB/s worth of garbage — it will just do so in larger, rarer, and potentially longer pauses. Reducing unnecessary allocation is always the first lever to pull before tuning GC flags.

Reference Strength and Collectibility

Java has four reference strengths that influence GC eligibility:

  • Strong reference — a normal Java reference; the object is never collected while this reference is reachable.
  • SoftReference<T> — collected only when the JVM is about to throw OutOfMemoryError. Useful for memory-sensitive caches.
  • WeakReference<T> — collected at the next GC cycle when no strong or soft references exist. Used by WeakHashMap.
  • PhantomReference<T> — enqueued after finalization; used for post-mortem cleanup (prefer Cleaner in modern Java).
import java.lang.ref.WeakReference; import java.util.WeakHashMap; public class ReferenceDemo { public static void main(String[] args) throws InterruptedException { // WeakHashMap: entries are removed when the key is GC-collected WeakHashMap<Object, String> cache = new WeakHashMap<>(); Object key = new Object(); cache.put(key, "value tied to key lifetime"); key = null; // strong reference gone System.gc(); // suggest GC (not guaranteed to run immediately) Thread.sleep(100); // Entry may have been removed — do not rely on weakly-held values being present System.out.println("Cache size: " + cache.size()); // likely 0 } }

Practical Takeaways

Understanding generational GC and reachability lets you reason about performance without guessing:

  • Objects that die before the next minor GC are essentially "free" — they never touch the Old Generation.
  • Long-lived objects (singletons, caches) should be truly long-lived — avoid patterns that repeatedly promote and then discard objects from the Old Generation.
  • Static collections are GC roots; anything added to them lives until the collection is cleared or the entry removed.
  • Use WeakReference or SoftReference for caches whose entries should not prevent GC.
  • Monitor -Xlog:gc* output to see actual minor vs. major GC frequency and pause times before tuning flags.

The next lesson extends this foundation to the specific GC algorithms available in the JVM — Serial, Parallel, G1, and ZGC — and shows how to choose and tune them for different workload profiles.