☕ 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

GC Algorithms
Garbage collection algorithms are the strategies the JVM uses to identify and reclaim unreachable objects. Different algorithms make different trade-offs between throughput, latency, and memory overhead. The foundational algorithms are mark-sweep, mark-compact, and copying — each with distinct properties that make them suitable for different parts of the heap and different application requirements. Modern production collectors (G1GC, ZGC, Shenandoah) combine these algorithms with concurrency, parallelism, and incremental collection to achieve the best overall performance. This entry covers the core algorithms in depth, their correctness properties, performance characteristics, why generational collection is effective, and how the major production collectors implement these algorithms.
Memory Leak
A memory leak in Java occurs when objects that are no longer needed by the application remain reachable through strong references, preventing the garbage collector from reclaiming their memory. Unlike C or C++, Java cannot leak memory through forgotten deallocation — the GC handles that. Java leaks memory through forgotten references: objects that the application logic no longer needs but that are still reachable through data structures, caches, listeners, or static fields. Memory leaks manifest as growing heap usage, increasing GC frequency and duration, and ultimately OutOfMemoryError. This entry covers every major leak pattern in Java, how to detect and diagnose leaks, how to fix each pattern, and tools for prevention.
finalize()
The finalize() method was a mechanism for performing cleanup on objects before they were garbage collected. It was deprecated in Java 9, deprecated for removal in Java 18, and removed entirely in Java 21. Understanding why finalize() was designed, why it was deprecated, what is wrong with it, and what replaces it is essential both for maintaining legacy code that uses it and for writing correct cleanup code going forward. This entry covers finalize() mechanics, its fundamental problems, why it was removed, and the correct modern alternatives: try-with-resources, Cleaner, and PhantomReference.
Reference Types (Weak, Soft, Phantom)
Java provides four reference strengths beyond the ordinary strong reference: strong (the default), soft, weak, and phantom. Each has a different policy for when the garbage collector is permitted to reclaim the referenced object. Soft references are collected when the JVM is low on memory — ideal for memory-sensitive caches. Weak references are collected at the next GC cycle — ideal for canonicalising maps and listeners. Phantom references are enqueued after the object is finalised but before its memory is reclaimed — ideal for post-collection cleanup actions. Understanding these reference types enables building caches that do not cause OutOfMemoryError, listeners that automatically deregister themselves, and deterministic cleanup of native resources. This entry covers all four strengths with their precise semantics, the ReferenceQueue mechanism, and the canonical use case for each type.