☕ Java

Garbage Collection

Garbage collection is the automatic process by which the JVM reclaims memory occupied by objects that are no longer reachable from the running program. It is one of Java's most defining features — eliminating the manual memory management required in C and C++ and the entire class of bugs that comes with it: use-after-free, double-free, and memory leaks caused by forgotten deallocation. Understanding garbage collection means understanding reachability, GC roots, the collection process, what the GC guarantees and what it does not, how to work with it rather than against it, and how to diagnose and resolve GC-related performance problems. This entry covers the reachability model, GC triggers, what GC does not collect, finalization, the GC performance trade-off triangle, and practical guidance for writing GC-friendly code.

Reachability — What Gets Collected

Garbage collection is driven entirely by reachability. An object is live if it is reachable — if there exists at least one path of strong references from any GC root to that object. An object is garbage if it is unreachable — no such path exists. The garbage collector collects unreachable objects regardless of whether their fields contain valid data or whether any variable was ever assigned to them. GC roots are the anchors of the reachability graph. They are always considered live and are never collected. The JVM defines several categories of GC roots: local variables and operand stack entries in all active stack frames across all threads; static fields of all loaded classes; references held by JNI (native code); active monitors (objects used for synchronisation); objects in the string pool; class loader objects; and references held by system threads. Any object reachable through a chain of strong references from any of these roots is live. The reachability graph is traversed from GC roots outward. Objects discovered during traversal are marked live. When traversal is complete, any object not marked is unreachable and eligible for collection. This mark-and-sweep model underpins every modern garbage collector, though the details of how marking is performed, how it is made concurrent, and how memory is reclaimed vary substantially between collectors. A critical nuance: garbage collection is not deterministic in timing. The JVM decides when to collect, triggered by memory pressure, elapsed time, or explicit requests. Objects may sit unreachable for arbitrary time before being collected. Code that depends on GC running at a predictable time — for example, code that expects finalizers to run soon after an object becomes unreachable — is inherently unreliable.
Java
// ── Reachability examples ────────────────────────────────────────────
public class ReachabilityDemo {

    // Static field — GC root — objects reachable from here are always live
    static List<byte[]> staticCache = new ArrayList<>();

    public static void main(String[] args) {

        // ── Case 1: Local variable creates strong reference ───────────
        byte[] data = new byte[1024];  // reachable via local var 'data'
        // 'data' is a GC root (local variable in main's stack frame)

        // ── Case 2: Reassignment makes original unreachable ───────────
        byte[] a = new byte[1024];     // Object A — reachable
        byte[] b = new byte[1024];     // Object B — reachable
        a = b;                          // Object A is now unreachable → garbage
        // Only b (and a, both pointing to Object B) remain reachable

        // ── Case 3: null makes object eligible for GC ─────────────────
        byte[] c = new byte[1024];     // reachable
        c = null;                       // unreachable → eligible for GC

        // ── Case 4: Circular references — STILL collected ────────────
        Node n1 = new Node();
        Node n2 = new Node();
        n1.next = n2;
        n2.next = n1;   // circular reference
        n1 = null;
        n2 = null;
        // Both n1 and n2 objects are unreachable from GC roots
        // Even though they reference each other — GC handles cycles correctly
        // (Unlike reference counting which cannot handle cycles)

        // ── Case 5: Collection holds objects alive ────────────────────
        List<byte[]> list = new ArrayList<>();
        list.add(new byte[1024]);  // byte array reachable via list
        // Even if no other reference to the byte array exists,
        // it's alive while 'list' is alive and holds it

        list.clear();              // now byte array has no more strong references
        // byte array becomes eligible for GC
    }
}

// ── GC root categories ────────────────────────────────────────────────
// 1. Thread stacks — all local vars in all active frames of all threads
// 2. Static fields — every static field of every loaded class
// 3. JNI global references — references held by native code
// 4. Active monitors — objects locked by synchronized()
// 5. String pool — interned strings
// 6. ClassLoader objects — and transitively, all loaded classes
// 7. System classes — java.lang.Class objects for primitive types, arrays

What GC Guarantees — and What It Does Not

Garbage collection makes one guarantee: an object that is unreachable will eventually be collected before the JVM throws OutOfMemoryError. It makes no guarantee about timing — "eventually" is the entire guarantee. Objects may sit unreachable for milliseconds, seconds, or the entire remaining lifetime of the application if memory pressure never demands their collection. This timing non-guarantee has several practical implications. Code must not rely on garbage collection to release non-memory resources. File handles, network connections, database connections, locks, and native memory are not released by GC — they are released only when explicit close() or free() calls are made. Java's try-with-resources exists precisely because GC is unreliable for these resources. An object that holds a file handle open through an instance field will keep that file handle open until the close() method is called, not until the object is garbage collected. System.gc() is a suggestion to the JVM that now might be a good time to run a GC cycle. The JVM may ignore it entirely (-XX:+DisableExplicitGC makes it a no-op) or may run a partial or full collection. Production code should never call System.gc() — it represents a fundamental misunderstanding of GC semantics and its effect is unpredictable. Some uses in library code (NIO buffer cleaner) and testing are defensible, but application business logic should never call it. The null assignment pattern — setting references to null explicitly to "help the GC" — is almost always unnecessary and often counterproductive. When a method returns or a block exits, all local variables in that scope become unreachable automatically as their stack frame is popped. Explicitly setting them to null before the scope exits is redundant and adds code noise. The only legitimate use is within a long-running method or a long-lived object where a reference was set early but should be eligible for GC before the method returns or the object is collected.
Java
// ── GC timing is not guaranteed ──────────────────────────────────────

// WRONG: relying on GC to close a file (unreliable)
public void processFile(String path) {
    FileInputStream fis = new FileInputStream(path);
    // ... process ...
    // fis goes out of scope, but file handle NOT closed until GC runs
    // and finalizer (if any) executes — timing unpredictable
}

// CORRECT: explicit close with try-with-resources (guaranteed)
public void processFile_correct(String path) throws IOException {
    try (FileInputStream fis = new FileInputStream(path)) {
        // ... process ...
    }   // fis.close() called here — guaranteed, immediate
}

// ── System.gc() — do not call in application code ────────────────────
// WRONG: calling System.gc() to "free memory"
public void doWork() {
    processLargeData();
    System.gc();   // WRONG — suggestion only, may be ignored,
                   // may cause unnecessary full GC pause, unpredictable
}

// ── Explicit null assignment — usually unnecessary ────────────────────
public void method() {
    byte[] largeArray = new byte[10_000_000];
    process(largeArray);
    largeArray = null;   // UNNECESSARY — largeArray becomes unreachable
                         // when method returns anyway
    doMoreWork();        // largeArray is unreachable here regardless
}

// The rare legitimate caselong method, large object used early:
public void longRunningMethod() {
    byte[] largeArray = new byte[10_000_000];
    process(largeArray);
    largeArray = null;    // LEGITIMATE — eligible for GC now
    // ... hours of other work below ...
    // Without null, largeArray would be reachable for the entire method
    Thread.sleep(10_000);
    moreWork();
}

// ── GC does NOT collect objects with strong references ────────────────
public class LeakyCache {
    // Objects put here are NEVER collected — strong reference in static field
    static Map<String, byte[]> cache = new HashMap<>();

    public static void addToCache(String key, byte[] data) {
        cache.put(key, data);   // data held alive indefinitely
    }
    // This will eventually cause OutOfMemoryError
}

GC Triggers and Collection Types

The JVM triggers garbage collection based on several conditions, not only when memory runs out. Minor GC (young generation collection) is triggered when Eden space fills. This is the most frequent type of collection and is typically fast (milliseconds). The frequency depends on the allocation rate: an application that allocates 1GB of objects per second with a 512MB Eden will trigger minor GC twice per second. Major GC (old generation collection) is triggered when the old generation cannot accept promotions from a young generation collection, or when the old generation reaches a fill threshold. Major GC involves the entire heap and is significantly more expensive. Modern collectors like G1GC try to make major collection mostly concurrent, but some stop-the-world work remains. A Full GC — where both young and old generations are collected simultaneously in a stop-the-world pause — is triggered in emergency situations and should be rare or absent in a well-tuned application. Concurrent GC work happens alongside application threads, reducing pause times at the cost of throughput. The GC threads consume CPU cycles that would otherwise be available to the application. The GC throughput overhead is typically 5-10% for well-tuned modern collectors. Poorly tuned applications spending 30%+ of CPU time in GC are a sign of excessive allocation, too-small a heap, or a memory leak. The GC trade-off triangle is the fundamental constraint: throughput (percentage of time application threads run), latency (maximum pause duration), and footprint (memory consumed by the GC itself and heap overhead). Optimising one typically hurts another. Parallel GC maximises throughput at the cost of longer pauses. ZGC maximises latency at the cost of memory overhead. G1GC balances all three for most applications.
Java
// ── GC log analysis — understanding what the JVM reports ─────────────
// Enable GC logging (Java 9+):
// java -Xlog:gc*:file=gc.log:time,uptime,level,tags MyApp
//
// Sample output lines and their meanings:
//
// [2.345s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause)
//                           42M->18M(256M) 8.234ms
// ↑ time  ↑ type            ↑before↑after↑heap  ↑ STW pause duration
// Normal young GC: 24MB collected in 8ms — healthy
//
// [45.678s][info][gc] GC(67) Pause Full (G1 Compaction Pause)
//                            198M->45M(256M) 423.456ms
// FULL GC: 423ms pause — investigate! Should not happen regularly
//
// [0.123s][info][gc,heap] Eden regions: 12->0(25)  ← Eden emptied
//                         Survivor regions: 3->2(3) ← Some objects survived
//                         Old regions: 8->9(40)    ← Some promoted to Old

// ── Monitoring GC programmatically ───────────────────────────────────
import java.lang.management.*;

List<GarbageCollectorMXBean> gcBeans =
    ManagementFactory.getGarbageCollectorMXBeans();

for (GarbageCollectorMXBean gcBean : gcBeans) {
    System.out.printf("GC: %-25s count=%-6d time=%dms%n",
        gcBean.getName(),
        gcBean.getCollectionCount(),
        gcBean.getCollectionTime());
}
// Output might show:
// GC: G1 Young Generation       count=1234   time=4567ms
// GC: G1 Old Generation         count=2      time=890ms

// ── Allocation rate measurement ───────────────────────────────────────
// High allocation rate → frequent young GC → CPU overhead
// Measure with: jstat -gcnew <pid> 1000  (every 1 second)
//
// jstat output:
// S0C    S1C    S0U    S1U   TT MTT  DSS      EC       EU     YGC     YGCT
// 0.0  3072.0    0.0   512.0  3  15 2048.0 60416.0 42048.0   892    4.234
// EU (Eden Used) growing fast = high allocation rate
// YGC (Young GC count) high = frequent collections

// ── GC trade-off: throughput vs latency vs footprint ─────────────────
//
// Parallel GC (-XX:+UseParallelGC):
//   Throughput: BEST (multiple threads, less overhead)
//   Latency:    POOR (long STW pauses, hundreds of ms)
//   Footprint:  LOW
//   Use case:   Batch jobs, offline processing
//
// G1GC (-XX:+UseG1GC, default Java 9+):
//   Throughput: GOOD
//   Latency:    GOOD (targets <200ms via MaxGCPauseMillis)
//   Footprint:  MEDIUM
//   Use case:   Most web applications
//
// ZGC (-XX:+UseZGC, Java 15+):
//   Throughput: GOOD (modest overhead for concurrent work)
//   Latency:    BEST (<1ms pauses even at terabyte scale)
//   Footprint:  HIGHER (colored pointers overhead)
//   Use case:   Latency-sensitive services, large heaps

Writing GC-Friendly Code

The most effective way to improve GC performance is to reduce allocation. Every byte allocated must eventually be collected. High allocation rates cause frequent young generation GC, which consumes CPU and introduces latency. The strategies for reducing allocation are: prefer primitive types over boxed types, reuse objects rather than creating new ones, use StringBuilder for string building rather than concatenation, avoid unnecessary intermediate collection creation in stream pipelines, and understand the allocation cost of common patterns (for-each on arrays creates no allocations; for-each on ArrayList creates an iterator; streams create several objects). Object pooling is the most direct allocation reduction strategy, but it has costs of its own: pool management complexity, pool exhaustion risk, and the risk of using a pooled object that was not properly reset. The net benefit is only positive when object creation is genuinely expensive — JVM thread creation, database connections, network connections, compiled regex patterns. For ordinary objects that take nanoseconds to create, pooling overhead often exceeds the benefit. The allocation-free hot path is the gold standard for latency-sensitive code: the fast path through performance-critical code should make no allocations at all, ensuring minor GC cannot be triggered by that code path. Allocations in warm-up, initialisation, and error paths are acceptable. This discipline is common in high-frequency trading, game engines, and other real-time systems. For most applications, this level of discipline is unnecessary — reducing allocation by 10x through straightforward improvements has more practical impact than eliminating it entirely.
Java
// ── Reducing allocation in hot paths ─────────────────────────────────

// ALLOCATING: boxes int, creates Iterator, creates Optional
public Optional<Integer> findFirst(List<Integer> list, int threshold) {
    return list.stream()
        .filter(n -> n > threshold)   // lambda object (may be optimized)
        .findFirst();                  // Optional allocation
}

// ALLOCATION-REDUCED: no boxing, no iterator, no Optional
public int findFirstRaw(int[] data, int threshold) {
    for (int i = 0; i < data.length; i++) {
        if (data[i] > threshold) return data[i];
    }
    return -1;  // sentinel instead of Optional
}

// ── String building ───────────────────────────────────────────────────
// HIGH allocation: + in loop creates N-1 intermediate Strings
String result = "";
for (String item : items) {
    result += item + ", ";   // N intermediate String objects!
}

// LOW allocation: one StringBuilder, one final String
StringBuilder sb = new StringBuilder(items.size() * 16);
for (String item : items) {
    sb.append(item).append(", ");
}
String result2 = sb.toString();  // one allocation

// ── Reuse vs recreate ─────────────────────────────────────────────────
// HIGH allocation: new StringBuilder per call
public String formatAmount(BigDecimal amount) {
    return new StringBuilder()           // new object every call
        .append("$").append(amount)
        .toString();
}

// REUSE via ThreadLocal:
private static final ThreadLocal<StringBuilder> SB =
    ThreadLocal.withInitial(() -> new StringBuilder(64));

public String formatAmountReuse(BigDecimal amount) {
    StringBuilder sb = SB.get();
    sb.setLength(0);    // reset without reallocation
    return sb.append("$").append(amount).toString();
}

// ── Prefer primitives to avoid autoboxing allocations ─────────────────
// HIGH allocation: each int boxed to Integer
Map<String, Integer> counters = new HashMap<>();
counters.merge("key", 1, Integer::sum);  // Integer boxing on each update

// LOW allocation: use IntStream.sum() or primitive maps (Eclipse Collections, etc.)
int[] counter = {0};
counter[0]++;   // no allocation

// ── Object pooling — only for expensive objects ────────────────────────
// Appropriate: connection pools (socket + auth overhead)
// Appropriate: compiled Pattern objects (regex compilation overhead)
// NOT appropriate: ordinary POJOs (nanosecond creation, no benefit)

private static final Pattern PHONE_PATTERN =
    Pattern.compile("^\+?[\d\s\-()]{10,}$");  // compile once, reuse

public boolean isValidPhone(String phone) {
    return PHONE_PATTERN.matcher(phone).matches();  // matcher is cheap
}

Related Topics in Java Memory Management

Stack Memory
Stack memory is the region of memory where the JVM stores method invocation frames, local variables, and partial results. Every thread has its own private stack created at thread creation, and the stack grows and shrinks as methods are called and return. Stack memory operates on a last-in, first-out discipline — the frame for the most recently called method sits on top, and when that method returns its frame is immediately discarded. Understanding stack memory explains why local variables are thread-safe by default, why recursive algorithms can cause StackOverflowError, why primitive values behave differently from objects, and what the JVM does at every method call and return. This entry covers stack frame structure, the stack pointer, local variable storage, operand stacks, frame lifecycle, thread isolation, and the performance characteristics that make stack allocation extremely fast.
Heap Memory
The heap is the runtime data area from which all Java object instances and arrays are allocated. It is shared across all threads in a JVM process, grows dynamically up to a configured maximum, and is managed entirely by the garbage collector. Understanding heap memory means understanding how objects are allocated, how the generational hypothesis drives GC design, how the major garbage collectors (G1, ZGC, Shenandoah, Parallel) partition and manage the heap, what triggers garbage collection, how to tune heap size and GC behaviour, and how to diagnose heap-related problems including OutOfMemoryError and excessive GC pause times. This entry covers heap structure, generational design, object allocation, garbage collection triggers, common collectors, heap tuning, and memory leak detection.
Metaspace
Metaspace is the JVM memory region that stores class metadata — the internal representations of loaded classes, methods, fields, constant pools, and annotations. It replaced PermGen (Permanent Generation) in Java 8. Unlike PermGen which was a fixed-size heap region, Metaspace is allocated from native memory (outside the Java heap) and grows dynamically up to an optional maximum. Understanding Metaspace means understanding what class metadata contains, what causes Metaspace to grow, how class unloading reclaims Metaspace, what OutOfMemoryError from Metaspace looks like, how to monitor and limit it, and what the practical implications are for application servers, OSGi containers, and frameworks that generate classes dynamically.
JVM Memory Model
The JVM Memory Model encompasses two related but distinct concepts. The first is the runtime memory architecture — how the JVM partitions memory into regions (stack, heap, Metaspace, code cache, native memory) and what each region stores. The second, more precise meaning is the Java Memory Model (JMM) defined in the Java Language Specification — the formal set of rules that govern how multithreaded programs observe memory: when writes made by one thread become visible to other threads, what ordering guarantees exist, and how volatile, synchronized, and final provide those guarantees. This entry covers both: the complete runtime memory architecture with all regions explained, and the Java Memory Model's happens-before relationship, visibility rules, and the practical consequences for concurrent code.