Java GC — Unbounded Cache Full GC Spiral
G1 Full GC from unbounded cache spikes p99 latency to 30s+ and kills Kubernetes pods.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- Java GC automatically reclaims memory from unreachable objects by tracing from GC roots
- The heap is divided into young (Eden + Survivor) and old generations — most objects die young
- G1 is the default collector, using region-based evacuation with tunable pause targets
- ZGC and Shenandoah provide sub-10ms pauses at the cost of higher CPU and native memory overhead
- Biggest production mistake: treating GC as a black box without enabling GC logging or measuring allocation rate
Imagine you're at a big party and everyone keeps leaving empty cups on tables. You hired a cleaner (the Garbage Collector) whose only job is to walk around, spot cups nobody is holding anymore, and throw them away so there's room for fresh drinks. The cleaner doesn't interrupt the party every second — they work in bursts, and sometimes they have to pause everything to do a deep clean. That pause is what Java developers are always trying to shrink. Java's GC is exactly that cleaner: it automatically finds objects your program no longer references and reclaims their memory so you never have to call free() yourself.
Every Java application runs a second program inside the JVM — the Garbage Collector. It decides when memory gets freed, how long your threads pause, and whether your latency SLAs hold up under load. Most developers treat it like a black box and then wonder why their microservice spikes to 500ms every few seconds in production.
Before automatic memory management, C and C++ developers had to manually allocate and free every byte. Java solved this with a managed heap and a runtime that tracks object reachability — if nothing in your program can reach an object, its memory can be reclaimed. That single idea eliminated an entire class of bugs but introduced a new challenge: the collector itself consumes CPU and introduces pauses.
The core misconception: GC pauses are inevitable and unfixable. They are not. Modern collectors offer pause-time guarantees independent of heap size — but only if you understand the trade-offs and tune correctly for your workload.
What is Garbage Collection in Java?
Garbage Collection in Java is the JVM's automatic memory management mechanism. The GC periodically identifies objects that are no longer reachable from any GC root (thread stacks, static fields, JNI references) and reclaims their heap memory. This eliminates manual memory management but introduces pauses and CPU overhead that must be managed in production.
The JVM determines object reachability through a reachability analysis starting from GC roots. An object is considered dead — and eligible for collection — when no chain of references from any root can reach it. This is fundamentally different from reference counting (used in early Python/PHP) which cannot handle cyclic references. Java's tracing GC handles cycles naturally because it only cares about reachability, not reference count.
The key production insight: GC does not run when memory is low. GC runs when allocation pressure triggers it. This means a service with a large heap but low allocation rate may run GC infrequently, while a service with a small heap and high allocation rate may run GC constantly. Allocation rate, not heap size, is the primary driver of GC frequency.
- Local variables on thread stacks — every active method frame holds references to objects it is using
- Static fields of loaded classes — ClassLoader roots keep static objects alive for the lifetime of the class
- JNI references — native code can hold references that the JVM must respect
- Active monitors — objects currently locked by a thread are temporarily rooted during GC
free() calls.The Generational Heap — Why Most Objects Die Young
The JVM heap is divided into generations based on the weak generational hypothesis: most objects die young, and objects that survive one collection are likely to survive many more. This observation drives the generational heap design that every modern JVM collector uses.
The young generation consists of eden space (where new objects are allocated) and two survivor spaces (S0 and S1). New objects are allocated in eden. When eden fills up, a minor GC (young collection) runs: live objects in eden are copied to one survivor space, and live objects in the other survivor space are also copied and aged. Objects that survive enough young collections (controlled by -XX:MaxTenuringThreshold) are promoted to the old generation.
The old generation holds long-lived objects. When the old generation fills up or a collection threshold is reached, a major GC runs. In G1, this is a mixed GC that collects both young and old regions. In extreme cases, a full GC (stop-the-world compaction of the entire heap) is triggered — this is the catastrophic failure mode you must avoid.
The critical production insight: the tenuring threshold determines how quickly objects move to old generation. Too low, and short-lived objects pollute old generation, increasing old gen GC frequency. Too high, and survivor spaces overflow, forcing premature promotion. Both paths degrade performance.
- If 90% of objects die in eden, collecting eden reclaims 90% of garbage with minimal work
- Young collection only scans eden + survivor spaces — not the entire heap. This is fast.
- Old generation collection is expensive because it must handle long-lived object graphs
- The hypothesis fails for workloads with uniform object lifetimes — batch processing, data pipelines
- When the hypothesis fails, you see high promotion rates and frequent old gen collections
GC Algorithms — Mark-Sweep, Copying, and Compaction
All GC algorithms are built on three fundamental operations: marking (identifying live objects), sweeping (reclaiming dead objects' memory), and compacting (defragmenting live objects to create contiguous free space). Different collectors combine these operations differently to optimize for pause time, throughput, or memory efficiency.
Mark-and-sweep identifies live objects (mark phase) then reclaims unmarked memory (sweep phase). The problem: it creates fragmentation. After many allocation-deallocation cycles, free memory is scattered in small chunks. Large object allocations may fail even when total free memory is sufficient — this is external fragmentation.
Copying collectors solve fragmentation by copying live objects to a fresh region and discarding the old region entirely. This is inherently compacting — live objects end up contiguous. The cost: copying live objects takes time proportional to the live data set, and you need double the memory (from-space and to-space). The generational heap reduces this cost by only copying in young generation.
Mark-and-compact identifies live objects then slides them to one end of the heap, creating one contiguous free region. This avoids the double-memory cost of copying but requires updating every reference to moved objects — a potentially expensive operation that must be done during a stop-the-world pause or with complex concurrent mechanisms.
- Serial GC: mark-sweep-compact, all stop-the-world. Simple but pauses grow with heap.
- Parallel GC: same algorithm as Serial but uses multiple threads. Faster but same pause characteristics.
- G1: mark + concurrent sweep via region evacuation. Compaction happens per-region, not whole-heap.
- ZGC: concurrent mark + concurrent compact via colored pointers. All phases concurrent except initial/final mark.
- Shenandoah: concurrent mark + concurrent compact via Brooks pointers. Similar to ZGC with different implementation.
G1 GC — The Default Workhorse
G1 (Garbage-First) has been the default JVM collector since Java 9. It divides the heap into equal-sized regions (1MB to 32MB) and prioritizes collecting regions with the most garbage — hence 'garbage-first'. G1 maintains a remembered set per region tracking incoming references, enabling independent region collection without scanning the entire heap.
G1 operates in young-only and mixed collection cycles. Young GC collects survivor and eden regions. When the heap occupancy exceeds the Initiating Heap Occupancy Percent (IHOP), G1 triggers a concurrent marking cycle. After marking completes, subsequent mixed GCs collect both young and old regions identified as mostly garbage.
The critical production insight: G1's pause time is primarily driven by the number of regions it must collect in a single pause, not heap size. A 64GB heap with aggressive evacuation can pause longer than a 4GB heap with conservative settings. This is the opposite of what most engineers assume.
- Pause time scales with live data in collected regions, not total heap size
- Humongous objects break this model — they span multiple regions and cannot be partially evacuated
- Remembered sets consume 5-10% of heap as off-heap overhead — budget for this when setting -Xmx
- To-space exhausted means G1 literally ran out of regions to evacuate into — this is a full GC fallback
ZGC — Sub-Millisecond Pause Collector
ZGC (Z Garbage Collector) was introduced as experimental in JDK 11 and became production-ready in JDK 15. Its defining characteristic: pause times stay below 10ms regardless of heap size — tested up to 16TB heaps. ZGC achieves this through concurrent everything: marking, relocation, and reference processing all happen while application threads run.
ZGC uses load barriers with colored pointers. Every object reference carries metadata bits (marked0, marked1, remap, finalize) embedded in the pointer itself. The load barrier intercepts every object access to check if the reference needs remapping. This is the fundamental trade-off: ZGC replaces long GC pauses with per-access overhead on every object load.
As of JDK 21, ZGC supports generational mode (-XX:+ZGenerational) which dramatically improves throughput by focusing collection on young objects. Non-generational ZGC collects the entire heap every cycle, which limits throughput on allocation-heavy workloads.
- Pause times are truly independent of heap size and live data size — tested to 16TB
- The trade-off is per-access CPU overhead, not pause time — you pay on every object load
- ZGC cannot use compressed object pointers (UseCompressedOops) — increases memory usage by ~15% on heaps < 32GB
- Generational ZGC (JDK 21+) reduces overhead dramatically by focusing on young generation
Shenandoah — Red Hat's Low-Pause Contender
Shenandoah is Red Hat's concurrent compacting collector, available as production-ready since JDK 12. It achieves low pause times through concurrent evacuation — moving live objects while application threads run — using Brooks pointers (an indirection layer on every object).
Shenandoah differs from ZGC in a critical way: it uses Brooks pointers (every object has a forwarding pointer field) instead of colored pointers. This means Shenandoah does not require specific pointer bit layouts and works with compressed oops, reducing memory overhead compared to ZGC on heaps under 32GB.
Shenandoah operates in three concurrent phases: concurrent mark, concurrent evacuate, and concurrent update-refs. The initial mark and final mark phases are short stop-the-world pauses, typically under 10ms. Shenandoah's pacing mechanism backpressures allocation threads proportionally when the collector falls behind, creating smoother degradation than ZGC's hard allocation stalls.
- No load barrier overhead — Shenandoah uses store barriers instead, which fire less frequently
- Works with compressed oops — saves ~15% memory compared to ZGC on heaps under 32GB
- Per-object overhead of 8 bytes — significant for workloads with many small objects
- Pacing mechanism creates graceful degradation instead of hard allocation stalls
JVM Flags That Actually Matter
Most JVM GC flags have sensible defaults. A small subset moves the needle in production. Understanding which flags to adjust — and when — prevents the common anti-pattern of blindly copying flags from blog posts without understanding their impact on your specific workload.
Flags fall into three categories: heap sizing, collector behavior, and logging. Heap sizing flags (-Xms, -Xmx, -XX:NewRatio) control memory layout. Collector behavior flags (-XX:MaxGCPauseMillis, -XX:InitiatingHeapOccupancyPercent) control collection strategy. Logging flags (-Xlog:gc) enable observability. The third category is the most important — you cannot tune what you cannot measure.
📚 RELATED NEXT STEPS
→ JVM Memory Model — Understand the heap regions these flags operate on
→ JVM GC Tuning Guide: G1, ZGC, Shenandoah Explained with Real Trade-offs — Production flag selection and GC algorithm trade-offs
→ JVM Memory Issues in Production: Debugging Guide (OOM, GC, Leaks) — When flags alone are not enough and you need live incident triage
- First: Set -Xms = -Xmx to prevent resize overhead. Size heap based on container limits, not guesswork.
- Second: Enable GC logging. You cannot tune what you cannot measure. This alone solves 50% of debugging issues.
- Third: Adjust collector-specific flags only after measuring with logging enabled.
- Never: Copy flags from blog posts without understanding your workload's allocation profile.
GC Algorithm Comparison: Serial, Parallel, G1, ZGC, Shenandoah
Choosing the right garbage collector depends on your workload's pause-time sensitivity, heap size, and throughput requirements. The table below summarizes the key characteristics of each major collector available in the JVM.
| Collector | Pause Model | Heap Size | Primary Use Case | Java Version |
|---|---|---|---|---|
| Serial | Stop-the-world (STW) single-thread | <1GB | Small applications, client-side, embedded | Since JDK 1.2 |
| Parallel | STW multi-thread | 1-8GB | Throughput-oriented batch jobs, analytics | Since JDK 1.2 (default JDK 5-8) |
| G1 | Region-based STW + concurrent marking | 1GB-64GB+ | General-purpose server applications | Since JDK 7 (default JDK 9+) |
| ZGC | Concurrent (STW < 10ms) | 4GB-16TB | Ultra-low latency, large heaps | Experimental JDK 11, prod JDK 15+ |
| Shenandoah | Concurrent (STW < 10ms) | 1GB-64GB | Low latency with memory efficiency | Since JDK 12 (backported to 8, 11) |
Key takeaway: For most web services, start with G1. Only move to ZGC or Shenandoah when your measured p99 latency exceeds 100ms after tuning G1. Serial and Parallel are legacy choices for resource-constrained or batch workloads.
System.gc() and finalize() — Patterns to Avoid
Two legacy Java mechanisms that should be avoided in production: System.gc() and . Both degrade GC performance and unpredictability.finalize()
System.gc() — An explicit request to run the garbage collector. It's a hint, not a command, but JVM often treats it as a full GC trigger (especially with -XX:+DisableExplicitGC disabled). Calling it frequently causes unnecessary full GC pauses, wrecking latency. Also, some frameworks like RMI, NIO, and JNDI call it internally. Always set -XX:+DisableExplicitGC in production to mitigate accidental calls.
finalize() — The method, defined in finalize()Object, runs before an object is reclaimed. It's unpredictable — the JVM may never call it before exit, and GC threads can finalize objects out of order. Additionally, finalize() can resurrect objects by assigning this to a reachable reference. The method also introduces latency as the JVM must finalize objects in a separate pass. Since Java 9, is deprecated. Use finalize()Cleaner (JDK 9+), PhantomReference with a cleanup thread, or AutoCloseable / try-with-resources instead.
System.gc() internally. Without -XX:+DisableExplicitGC, these calls trigger full GC in your application, causing latency spikes. Always disable explicit GC in production, but test thoroughly — some frameworks rely on it for cleanup.System.gc() is silently ignored. Best practice: always set this flag in production. For resource cleanup (file handles, sockets), use try-with-resources or Cleaner. Never rely on finalize() — it's deprecated and removed in future JDK versions (proposed for removal in JDK 18+).System.gc() and finalize() at all costs in production code. Use -XX:+DisableExplicitGC to ignore explicit GC calls. Prefer try-with-resources for deterministic cleanup, and Cleaner for native resource cleanup.Advantages and Disadvantages of Garbage Collection
Garbage Collection is a mixed blessing. It eliminates manual memory management bugs but introduces new operational challenges. The table below summarizes the trade-offs.
| Advantages | Disadvantages |
|---|---|
Eliminates memory leaks caused by forgotten free() calls | Introduces pauses (stop-the-world) that affect latency |
| Prevents dangling pointer bugs - objects are only reused after being unreachable | CPU overhead – GC threads consume processor time |
| Handles cyclic references automatically (unlike reference counting) | Memory overhead – additional per-object metadata (mark bits, forwarding pointers) |
| Reduces developer cognitive load – no manual memory management | Performance unpredictability – pauses vary with allocation pattern |
| Enables memory-safe concurrent programming with bounded overhead | Full GC occasionally compacts the entire heap, causing multi-second pauses |
| Provides tools for analysis (heap dumps, GC logs) to diagnose issues | Tuning requires deep understanding of collector algorithms and application behavior |
| Monitored at runtime – GC logs give insight into object lifetimes | Cannot control exactly when memory is reclaimed – objects may linger in old gen |
Key takeaway: The disadvantages can be mitigated with proper collector selection and tuning. For most production services, the benefits far outweigh the costs, but ignore the downsides at your peril.
GC Tuning Flags Reference Table
This table lists the most important GC tuning flags along with their purpose and typical values. Use it as a quick reference when configuring JVM options for production.
| Flag | Affects | Purpose | Typical Value / Range |
|---|---|---|---|
-Xms, -Xmx | Heap size | Set initial and maximum heap | Equal values, e.g., -Xms4g -Xmx4g |
-XX:MaxGCPauseMillis | G1 | Soft target for maximum pause time | 50–200ms (default 200) |
-XX:G1HeapRegionSize | G1 | Size of each region (humongous threshold = 50% of region) | 1–32MB, power of 2 |
-XX:InitiatingHeapOccupancyPercent | G1 | Heap occupancy % to trigger concurrent marking | 30–45 (default 45) |
-XX:G1ReservePercent | G1 | Reserve % of heap for evacuation failures | 10–20 (default 10) |
-XX:ConcGCThreads | All concurrent | Number of threads for concurrent GC work | Auto-detected, typically n-1 cores |
-XX:+DisableExplicitGC | All | Ignore System.gc() calls | Always enable in production |
-XX:+UseContainerSupport | All | Respect container memory limits | Enabled by default JDK 10+ |
-XX:MaxRAMPercentage | All | Set max heap as % of container memory | 75–85 (default 25 if not set!) |
-XX:+AlwaysPreTouch | All | Commit heap pages at startup to reduce runtime latency | Enable for large heaps |
-XX:NativeMemoryTracking | All | Track off-heap memory usage | summary or detail |
-XX:+HeapDumpOnOutOfMemoryError | All | Generate heap dump on OOM | Enable for diagnosis |
-XX:+ZGenerational | ZGC | Enable generational mode (JDK 21+) | Always enable on JDK 21+ |
-XX:SoftMaxHeapSize | ZGC | Target heap occupancy for ZGC (hints GC to cycle earlier) | 75–90% of Xmx |
-XX:ShenandoahGCHeuristics | Shenandoah | Collection policy: adaptive, compact, or static | adaptive |
-Xlog:gc* | Logging | Enable GC logging with details | -Xlog:gc*,gc+phases=debug:file=gc.log:time,uptime |
Key takeaway: The most impactful flags are GC logging (for observability) and heap sizing. Tuning collector-specific flags without enabling logs is like fixing a car engine blindfolded – possible but wasteful.
Practice Problems: GC Diagnosis and Tuning
Test your understanding of GC concepts with these five practical problems. Each problem presents a real-world scenario; identify the issue and propose a fix or tuning change.
Problem 1: Unbounded Cache Scenario: A user service caches profile objects in a HashMap. Over a weekend spike, GC logs show rising old-gen occupancy followed by frequent full GCs. P99 latency jumps from 50ms to 5s. Question: What is the likely cause and the immediate fix? Answer: Unbounded cache retains all entries, filling old gen. Immediate fix: apply size and time-based eviction (e.g., Caffeine with maximumSize and expireAfterWrite).
Problem 2: Large Object Allocation Scenario: A service using G1 with default region size (1MB) allocates many 800KB byte arrays. GC logs show numerous humongous allocation warnings and to-space exhaustion. Question: What tuning change can reduce humongous objects? Answer: Increase G1HeapRegionSize (e.g., -XX:G1HeapRegionSize=4m) so 800KB objects are under the 50% humongous threshold. Alternatively, chunk large allocations.
Problem 3: Metaspace OOM Scenario: After deploying a new microservice, pods restart every few hours with OutOfMemoryError. Heap is not full; metaspace shows steady growth. Thread count is stable. Question: What is the likely root cause and how to diagnose? Answer: Class loader leak (e.g., from repeated dynamic class generation or redeployment). Use -XX:NativeMemoryTracking=detail and jcmd to monitor metaspace. Consider -XX:MaxMetaspaceSize to limit, but fix the leak.
Problem 4: Long Pauses on 64GB Heap Scenario: A data processing service uses Parallel GC on a 64GB heap. Full GC pauses exceed 60 seconds. Changing to G1 reduces pauses but they are still >2s. Question: What should be the next step? Answer: G1 pauses scale with live data. If live data is >30GB, G1 cannot meet sub-second pauses. Consider switching to ZGC or Shenandoah, which have pause times independent of heap size.
Problem 5: Allocation Rate Spike Scenario: During a flash sale, the order service's allocation rate spikes from 100 MB/s to 2 GB/s. GC is triggered every few hundred milliseconds, CPU at 80% GC threads. Question: What is the best approach to reduce GC pressure? Answer: Optimize application code to reduce allocation (use object pooling, reuse buffers, avoid String concatenation in loops). If spikes are unavoidable, adjust heap sizing and consider ZGC for concurrent collection. Also, increase NewSize to absorb young allocation spikes.
Why Objects Become Unreachable (And Why That Matters)
Every production outage I've debugged that boiled down to a GC problem started with one thing: an object that should have died but didn't. Or worse, an object that died too late.
Unreachable means zero active references. Not "I think it's done." Not "nobody should need it." Zero references on the stack or from any GC root (static fields, JNI handles, active threads). The JVM doesn't care about your intentions. It traces live references from roots outward. Everything not reached during that trace is dead.
Here's the kicker: objects can become unreachable faster than you expect. A local reference inside a method block? Gone after the method returns. An object passed to a collection that gets cleared? Eligible immediately. But the reverse is also true — a single stray reference keeps an entire object graph alive. That's how "small" memory leaks bring down production services.
Understanding reachability isn't academic. It's the difference between writing code that the GC can efficiently reclaim and code that forces full GCs every hour.
The Two Types of GC Activity: Minor vs. Major
You can't tune GC properly if you don't understand that garbage collection runs on two distinct modes: minor and major. They're not the same thing, and confusing them gets you fired.
Minor GC happens in the Young Generation. It's fast. The JVM stops the world, copies all live objects from Eden to a survivor space, clears Eden, and resumes. Typical pause: 1-10 milliseconds. This is your friend. A healthy application should survive on minor GCs alone for 99% of its lifetime.
Major GC (or Full GC) hits the Old Generation. This is where the JVM does mark-sweep-compact across the entire heap. Pause times balloon: 100ms, 500ms, even seconds. A full GC every few hours? Fine. Every few minutes? You have a problem — either your survivor space sizing is wrong, or you're creating long-lived objects that should be short-lived.
The critical insight: you want to avoid promoting objects to Old Generation prematurely. Each object that survives a minor GC gets an age increment. When it exceeds tenuring threshold (default 15 for G1), it's promoted. If your survivor spaces are too small, objects get promoted early, fill Old Gen, and trigger frequent full GCs.
Monitor your promotion rate. If it's higher than expected, your objects are living too long.
Requesting GC: System.gc() Is a Hint, Not a Command
I've seen junior devs sprinkle System.gc() like seasoning. "The app's memory is high, I'll tell GC to run." Stop. Please.
System.gc() is a suggestion. The JVM can ignore it entirely. Modern collectors like G1 and ZGC often do. But even when they run it, you're paying for a full GC — and you just threw away all of the collector's adaptive sizing data. The JVM has been monitoring allocation rates, promotion patterns, and pause times to optimize future GCs. Calling System.gc() resets those heuristics. Your app will run slower for minutes afterward.
There are three legitimate reasons to call System.gc(): 1. Right before a heap dump (to minimize garbage in the dump) 2. During testing, to verify GC behavior under controlled conditions 3. After a known burst of short-lived object creation that the collector hasn't processed yet
That's it. If you think you need it in production, you almost certainly have a different problem: a memory leak, oversized heap, or wrong collector choice. Fix the real problem, don't call System.gc().
And for heaven's sake, never call System.gc() in a loop, in a request handler, or inside a timer thread. I've seen all three. Each time it caused a production incident.
System.gc(), you'll see a latency spike immediately. The GC trigger is synchronous — it blocks the calling thread until GC completes. Don't. Just don't.System.gc() is a hint that most modern JVMs ignore or throttle. If you think you need it, you have a real problem elsewhere. Fix that instead.Customizing GC Settings in Jelastic PaaS
Jelastic PaaS exposes heap and collector flags via topology manifests and environment variables, not raw JVM arguments. You set JAVA_OPTS or use the Cloud Scripting env block to override GC strategies per node. For example, switching from G1 to Shenandoah on a production layer requires adding JAVA_OPTS=-XX:+UseShenandoahGC to the manifest. Memory limits are tied to cloudlet quotas: the heap maximum defaults to 85% of the container's RAM, which you can cap with -Xmx inside the same variable. The trap is that Jelastic's auto-tuning may silently revert your flags on node restart if you edit them via the admin panel instead of the jelastic.env file. Always commit GC changes through version-controlled manifest sections — every cloudlet restart will read from there. This prevents production surprises when horizontal scaling spawns new nodes that inherit the wrong collector.
GC Implementations: HotSpot's Historical Lineage
The JVM isn't one GC — it's seven major implementations baked into HotSpot. The Serial collector uses a single thread for both minor and full GCs, suitable for single-core or client machines. Parallel (Throughput) GC employs multiple threads but stops-the-world for both young and old collection — good for batch jobs. G1 splits the heap into regions and predicts pause targets, becoming the default in JDK 9. ZGC uses load barriers and colored pointers to achieve sub-millisecond pauses regardless of heap size; it starts scanning live objects before pausing. Shenandoah evolved differently — it uses a Brooks pointer forwarding technique to relocate objects concurrently, even during the compaction phase. Each implementation betrays a different trade-off: Serial sacrifices throughput for footprint, Parallel sacrifices latency for throughput, and ZGC/Shenandoah sacrifices throughput for latency. Choose based on your application's tolerance for pause time versus raw processing speed.
Overview
Garbage collection in Java is an automatic memory management process that reclaims heap space occupied by objects no longer referenced by the application. It frees developers from manual memory deallocation, preventing two critical bugs: dangling pointers and memory leaks. However, GC is not free—it consumes CPU cycles and introduces pauses (stop-the-world events). The JVM’s heap is divided into young generation (Eden, Survivor spaces) and old generation. Most objects die young; a Minor GC in Eden is cheap. Objects that survive multiple cycles get promoted to the old generation, where a Major GC (full collection) is more expensive. Understanding when and why objects become unreachable is essential: losing all strong references, circular references between unreachable objects, or references from cleared weak/soft references. The choice of GC algorithm—Serial, Parallel, G1, ZGC, or Shenandoah—depends on latency vs. throughput trade-offs. Java’s GC has evolved from a simple mark-sweep to ultra-low-pause collectors that handle terabytes of heap without freezing applications.
System.gc() can trigger full GCs that pause all threads. Modern collectors like G1 ignore it by default with -XX:+DisableExplicitGC.Conclusion
Java’s garbage collection is a powerful abstraction that eliminates manual memory management, but it requires thoughtful tuning to avoid latency spikes and throughput degradation. The key insight is that GC behavior is determined by object reachability: as long as references exist from live roots (stack, static fields, JNI handles), objects remain alive. Understanding why objects become unreachable—scope exit, null assignment, weak reference clearing—lets you predict GC load. The two types of GC activity, Minor and Major, have drastically different pause profiles; optimizing object allocation rates reduces Minor GC frequency, while avoiding accidental retention prevents expensive Major collections. Modern collectors like ZGC and Shenandoah achieve sub-millisecond pauses even on multi-terabyte heaps by performing most work concurrently. However, no collector is a silver bullet: low-latency collectors trade CPU overhead for responsiveness. The future of Java GC includes generational ZGC and continued improvements to G1. Effective GC tuning starts with monitoring (GC logs, JFR), identifying pause patterns, and then adjusting flags like heap size, survivor ratio, and concurrent threads. Always test changes under realistic production load, and favor default settings from Java 17+ unless metrics prove otherwise.
Full GC Spiral Crashes Order Processing Service During Flash Sale
- Unbounded caches are the #1 cause of GC-related production incidents in Java services
- Full GC 'Allocation Failure' means the collector cannot free enough space — it is not a tuning problem, it is an application memory management problem
- Doubling heap without fixing the allocation pattern just delays the same failure with a longer full GC pause
- Every production service must have a bounded eviction strategy for any in-memory data structure
- Monitor old generation utilization sustained above 85% as a leading indicator of full GC risk
jcmd <pid> GC.heap_infojstat -gcutil <pid> 1000 10Key takeaways
Common mistakes to avoid
5 patternsUsing System.gc() in application code or relying on finalize() for cleanup
finalize() with Cleaner or try-with-resources.Setting -Xmx equal to container memory limit without considering native overhead
Tuning collector-specific flags without first enabling GC logging
Blindly copying JVM flags from blog posts or other services
Ignoring humongous allocations in G1 until to-space exhaustion occurs
Interview Questions on This Topic
Explain the difference between a young GC, mixed GC, and full GC in G1. When does each occur?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Advanced Java. Mark it forged?
19 min read · try the examples if you haven't