In languages with automatic memory management (Java, C#, Go, JavaScript), the Garbage Collector (GC) is responsible for freeing unused memory. While it's a huge convenience for developers, the GC can be a silent performance villain.
This article explores how the GC works, its impact on latency and throughput, and strategies to minimize problems.
The GC is like a janitor cleaning while you work. Sometimes it needs to stop everything for a deep clean.
How the Garbage Collector Works
Basic concept
The GC identifies objects in memory that are no longer referenced and frees that space for reuse.
Allocation → Use → Reference removed → GC identifies → Memory freed
Types of collection
Minor/Young GC
- Collects short-lived objects
- Fast (milliseconds)
- Frequent
Major/Full GC
- Collects entire heap
- Slow (hundreds of ms to seconds)
- Less frequent
- Usually causes "stop-the-world"
Stop-the-World (STW)
During certain GC phases, the application is completely paused. No application code executes.
Time
│
│ App │ GC │ App │ GC │ App
│───────│████│───────────│████│──────
│STW │ │STW │
These pauses appear as latency spikes.
Impact on Performance
Latency
Symptoms:
- Periodic latency spikes
- p99 much higher than p50
- Erratic latency
Example:
p50: 10ms
p95: 15ms
p99: 500ms ← Probably GC
Throughput
Time spent in GC is time not spent processing requests.
Rule of thumb:
- GC < 5% of total time: acceptable
- GC 5-10%: attention
- GC > 10%: serious problem
Predictability
Even if the average is good, GC pauses destroy predictability — essential for latency SLOs.
Factors that Increase GC Pressure
1. High allocation rate
The more objects you create, the more work for the GC.
Common culprits:
- Intermediate strings in loops
- Boxed primitives (Integer vs int)
- Streams and lambdas creating temporary objects
- Serialization/deserialization
2. Medium-lived objects
Objects that survive a few minor collections but die before becoming permanent create extra work.
3. Large heap
Paradoxically, very large heaps can worsen GC:
- Full GC takes longer
- More objects to check
4. Fragmentation
Objects of varying sizes leave holes in memory, forcing more frequent GCs.
Mitigation Strategies
1. Reduce allocations
Before:
for (User user : users) {
String key = "user:" + user.getId(); // New String each iteration
cache.get(key);
}
After:
StringBuilder sb = new StringBuilder("user:");
int prefixLen = sb.length();
for (User user : users) {
sb.setLength(prefixLen);
sb.append(user.getId());
cache.get(sb.toString());
}
2. Object pooling
Reuse expensive objects instead of creating new ones.
// Pool of reusable buffers
ByteBuffer buffer = bufferPool.acquire();
try {
// use buffer
} finally {
bufferPool.release(buffer);
}
3. Use primitive types
// Avoid
List<Integer> numbers; // Each Integer is an object
// Prefer
int[] numbers; // Array of primitives
// or use libraries like Eclipse Collections, Trove
4. Choose the right GC
JVM offers multiple GCs:
| GC | Focus | When to use |
|---|---|---|
| G1 | Balanced | Default, good general choice |
| ZGC | Low latency | Pause-sensitive applications |
| Shenandoah | Low latency | Similar to ZGC |
| Parallel | Throughput | Batch processing |
5. Tune the GC
Common parameters (JVM):
# Heap size
-Xms4g -Xmx4g
# G1 pause goal
-XX:MaxGCPauseMillis=200
# Use ZGC for low latency
-XX:+UseZGC
Warning: premature tuning can make things worse. First measure, then adjust.
6. Monitor GC
Enable GC logs:
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=10M
Important metrics:
- GC frequency
- Pause duration
- Total time in GC
- Memory recovered per cycle
GC in Other Languages
Go
Go has concurrent GC with very short pauses (< 1ms typical).
Optimizations:
- Use sync.Pool for temporary objects
- Pre-allocate slices when size is known
- Avoid creating closures in hot loops
Node.js (V8)
V8 has generational GC similar to JVM.
Optimizations:
- Avoid creating functions inside loops
- Reuse objects when possible
- Use typed arrays for numeric data
.NET
.NET has generational GC with different modes (Workstation, Server).
Optimizations:
- Use structs for small, short-lived objects
- Span
to avoid allocations - ArrayPool
for buffers
Identifying GC Problems
Symptoms
- Periodic latency spikes
- High CPU without application code using it
- Sawtooth memory pattern
- Application momentarily freezes
Diagnosis
- Enable GC logs
- Correlate pauses with latency spikes
- Analyze frequency and duration
- Identify if it's minor or major GC
- Profile to find allocation sources
Conclusion
The Garbage Collector is a double-edged sword:
- Benefit: frees developers from managing memory manually
- Cost: can cause unpredictable pauses and degrade performance
For latency-sensitive systems:
- Reduce allocations — less garbage = less collection
- Choose the right GC — different GCs have different trade-offs
- Monitor continuously — GC is a constant source of latency problems
- Tune carefully — based on data, not intuition
The best GC is one you don't even notice is running.