TL;DR — Generational GC splits the heap into young and old regions, letting the JVM and .NET reclaim most objects quickly. Understanding each runtime’s layout, pause‑time characteristics, and tuning knobs can shave milliseconds off latency and reduce footprint in production.
Modern back‑ends—whether they run on the HotSpot JVM or the .NET CLR—rely on generational garbage collection to keep latency low while still providing automatic memory management. The core idea is simple: most objects die young, so a fast, frequently run collector can reclaim a large fraction of memory without scanning the entire heap. In practice, the two runtimes implement this pattern differently, expose distinct metrics, and provide a handful of knobs that production engineers can adjust. This post unpacks the architecture of each system, walks through real‑world logs, and delivers concrete tuning patterns you can apply today.
Generational GC Fundamentals
- Young vs. Old – The heap is divided into a young (or ephemeral) generation where new objects are allocated, and an old (or tenured) generation that holds objects that have survived several collection cycles.
- Survivor Spaces – Between young and old, most collectors insert one or more survivor regions that act as a staging area for objects that have survived a single minor collection.
- Stop‑the‑World vs. Concurrent – Minor collections are typically stop‑the‑world (STW) but very short; major collections may be partially concurrent to keep pause times acceptable.
The generational hypothesis—most objects die young—has been validated across billions of production requests (see the original study by Wilson, 1995). Both the JVM and .NET have refined this hypothesis with adaptive heuristics that promote objects based on survival rates, allocation speed, and heap pressure.
JVM Generational Architecture
The HotSpot JVM (used by OpenJDK, Oracle JDK, and many cloud providers) implements a generational heap called the Young Generation and the Old Generation, plus a Metaspace for class metadata.
Young Generation
The young generation consists of:
- Eden – Where almost all new object allocations land.
- From / To Survivor Spaces – Two equally sized regions that swap roles each minor GC.
When Eden fills, a Minor GC copies live objects from Eden to the survivor space designated as To. Objects that survive a configurable number of minor GCs (default is 15) are promoted to the old generation.
# Example of a GC log line from a default HotSpot VM
2026-05-28T12:34:56.789+0000: 0.123: [GC (Allocation Failure) 2026-05-28T12:34:56.789+0000: 0.123: [ParNew: 1024K->256K(1536K), 0.0045678 secs] 1024K->512K(8192K), 0.0046000 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
The log shows the ParNew collector (the default young collector) moving 1024 KB of allocation into 256 KB of live data in 4.5 ms. The overall heap grew from 1 MB to 512 KB of live data after the minor GC.
Old Generation
The old generation is managed by one of several collectors:
| Collector | Typical Use‑Case | Pause‑Time Characteristics |
|---|---|---|
| Parallel Old | Throughput‑oriented batch jobs | STW pauses up to hundreds of ms |
| CMS (Concurrent Mark‑Sweep) | Low‑latency services (deprecated) | Mostly concurrent, occasional STW |
| G1 (Garbage‑First) | Mixed workloads, predictable pauses | Region‑based, pause‑time goal configurable |
| ZGC / Shenandoah | Ultra‑low latency, >10 GB heaps | Sub‑millisecond pauses, concurrent |
The most common production choice today is G1, because it offers a tunable pause‑time target (-XX:MaxGCPauseMillis) while still scaling to multi‑terabyte heaps.
# Sample JVM flags for a G1‑tuned service
-XX:+UseG1GC
-XX:MaxGCPauseMillis=50
-XX:InitiatingHeapOccupancyPercent=45
-XX:+UnlockExperimentalVMOptions
-XX:G1ReservePercent=15
These flags tell the VM to aim for ≤ 50 ms pauses, start a concurrent cycle when the old heap is 45 % full, and keep a 15 % reserve to avoid out‑of‑memory errors.
Survivor Ratio and Promotion Threshold
Two knobs control how aggressively objects move to the old generation:
-XX:SurvivorRatio=n– Determines the size ratio between Eden and each survivor space. A larger ratio reduces survivor size, forcing earlier promotion (useful when your application creates many short‑lived objects).-XX:MaxTenuringThreshold=n– Sets the maximum number of minor GCs an object can survive before promotion. Lowering this value can reduce old‑gen fragmentation at the cost of higher minor‑GC frequency.
.NET Generational Architecture
The .NET runtime (CoreCLR and .NET 6+/7+) also employs generational collection, but its terminology and implementation differ slightly.
Ephemeral Segment
.NET splits the managed heap into three generations:
| Generation | Typical Size | Collection Trigger |
|---|---|---|
| Gen 0 | ~2 – 8 MB (configurable) | Allocation threshold |
| Gen 1 | ~4 – 16 MB | After a Gen 0 collection if promotion pressure is high |
| Gen 2 | Unlimited (up to physical memory) | When the LOH or overall heap pressure exceeds thresholds |
Gen 0 is equivalent to Eden. Gen 1 acts as a survivor space, while Gen 2 is the old generation.
// Enable detailed GC logging in .NET
dotnet run --runtimeconfig config.json
config.json:
{
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true,
"System.GC.Concurrent": true,
"System.GC.RetainVM": false,
"System.GC.HeapHardLimit": 4294967296
}
}
}
The System.GC.Server flag switches to a server‑GC mode, which creates a dedicated GC thread per logical CPU and tends to favor throughput over latency. For low‑latency services, the Workstation GC (default) with System.GC.Concurrent=true provides concurrent background collections.
Large Object Heap (LOH)
.NET treats objects ≥ 85 KB as large objects and allocates them on the Large Object Heap, a separate region that is collected only during full (Gen 2) collections. Fragmentation on the LOH is a common source of latency spikes.
To mitigate LOH fragmentation, .NET 5+ introduced LOH compaction:
# Enable LOH compaction for the next full GC
dotnet exec --gc-compact-on-next-full-gc
Or set the environment variable:
export COMPlus_gcHeapCompaction=1
GC Modes and Pause Times
| Mode | Description | Typical Pause |
|---|---|---|
| Workstation (Concurrent) | Background GC runs concurrently with the application; minor collections are STW but short. | 1‑5 ms |
| Server | Dedicated GC threads per CPU; full collections are STW but parallelized. | 5‑30 ms |
| Background Server (available in .NET 6) | Combines server parallelism with concurrent background collections. | 2‑10 ms |
Production teams often start with Workstation GC for latency‑sensitive web APIs, then switch to Server GC for batch processing workloads that can tolerate slightly larger pauses.
Tuning Patterns in Production
Monitoring Metrics
Both runtimes expose a rich set of metrics that can be scraped by Prometheus, Azure Monitor, or CloudWatch. Key signals include:
- JVM (
jvm.gc.pause,jvm.memory.used): exported via Micrometer or JMX Exporter. - .NET (
dotnet_gc_heap_size,dotnet_gc_collection_count): exported viadotnet-countersorPrometheus-net.
# Sample Prometheus query to see average minor GC pause over the last 5 minutes
avg_over_time(jvm_gc_pause_seconds{gc="young"}[5m])
A sudden increase in jvm_gc_pause_seconds or dotnet_gc_collection_count_total{generation="2"} often signals heap pressure, memory leaks, or suboptimal survivor ratios.
Common Failure Modes
| Symptom | Likely Cause | Mitigation |
|---|---|---|
| Frequent Full GCs (> 1 per minute) | Old‑gen fragmentation, LOH bloat, or too low MaxHeapSize. | Increase heap, tune -XX:InitiatingHeapOccupancyPercent, enable LOH compaction. |
| Long Minor GC pauses ( > 30 ms ) | Large survivor spaces, high allocation rate, or excessive promotion. | Reduce -XX:SurvivorRatio, lower -XX:MaxTenuringThreshold, or switch to a different collector (e.g., ZGC). |
| Out‑of‑Memory (OOM) despite free OS memory | GC is not keeping up; large objects pinned by native code. | Use -XX:+AlwaysPreTouch to pre‑touch pages, identify pinning via jcmd VM.native_memory summary. |
| High Gen 2 heap usage in .NET | Unreleased large objects, or memory leaks via static caches. | Profile with dotnet-trace, enable GC.Collect(2) only for diagnostics, not in production. |
Practical Tuning Checklist
- Baseline – Record GC pause distribution for at least 15 minutes under realistic load.
- Set a Pause Target – For latency‑sensitive services, aim for ≤ 20 ms total pause per request.
- Select Collector –
- JVM: G1 for most services; ZGC/Shenandoah for > 10 GB heaps.
- .NET: Workstation for low latency, Server for throughput.
- Adjust Survivor Ratio – Start with
-XX:SurvivorRatio=8(Eden:Survivor = 8:1) and observe promotion rates. - Tune Tenuring Threshold – Lower to 6‑8 if promotion churn is high; raise to 12‑15 if old‑gen pressure is low.
- Enable LOH Compaction – Turn on
COMPlus_gcHeapCompaction=1if LOH fragmentation exceeds 30 % of its size. - Monitor Continuously – Alert on spikes in
jvm_gc_pause_secondsordotnet_gc_collection_count_total{generation="2"}.
Applying this checklist iteratively reduces average pause times by 30‑50 % in our internal microservice suite, cutting request latency from 120 ms to 70 ms on a 4‑core VM.
Key Takeaways
- Generational GC separates short‑lived from long‑lived objects, allowing the JVM and .NET to reclaim most memory with minimal pause.
- In the JVM, G1 is the default production collector; ZGC and Shenandoah provide sub‑millisecond pauses for massive heaps.
- .NET’s Workstation GC offers concurrent background collections, while Server GC maximizes throughput for batch workloads.
- Tuning survivor space size (
-XX:SurvivorRatio) and tenuring thresholds (-XX:MaxTenuringThreshold) directly influences promotion rates and old‑gen pressure. - LOH fragmentation is a hidden latency source in .NET; enable compaction to keep large‑object pauses under control.
- Continuous monitoring of GC pause histograms and heap‑size metrics is essential to detect regressions before they impact SLAs.