Your application throws OutOfMemoryError at 3 a.m. You restart the pod. It runs fine for six hours, then dies again. You add RAM, and it just takes longer to die. If that loop sounds familiar, the problem almost certainly isn't your heap size. It's how your objects are being allocated, promoted, and collected — and most developers never look at a single GC log before reaching for -Xmx.
Here's the thing: garbage collection in Java isn't magic, and it isn't a black box you configure by superstition. It's a set of algorithms with predictable behavior, and once you understand what the collector is actually doing, you'll debug memory problems in an afternoon instead of a week. Let me walk you through how Java memory management and garbage collection really work, what the collectors actually do, and how to watch them do it.
Key Takeaways
- The JVM heap is divided into young and old generations, and most objects die young — the collector exploits this.
- An object is "garbage" when it's unreachable from GC roots, not when its reference variable goes out of scope.
- Different collectors (Serial, Parallel, G1, ZGC, Shenandoah) trade throughput against pause time. Picking wrong costs you latency you can't recover.
- G1 has been the default collector since Java 9 and remains the default in Java 17 and later.
- You can never force garbage collection.
System.gc()is a suggestion, and a bad one. - Unified GC logs (
-Xlog:gc*) tell you more in five minutes than an entire week of guessing.
Java memory management: how garbage collection works under the hood
The first thing to internalize: Java doesn't free memory by reference counting. If it did, two objects pointing at each other would leak forever. Instead, the collector works on reachability. It starts from a fixed set of roots — thread stacks, static fields, JNI references, active monitor objects — and walks every reference it can reach. Anything it can't reach is garbage.
What makes an object eligible for collection?
Every running thread has a stack, and every local variable on that stack that points to an object is a root. So is every static field. So is every object referenced by a live thread's monitor.
Now the interesting part. Consider this:
- An object with a live reference in a local variable → reachable, safe
- Two objects referencing each other but nothing else pointing to either → unreachable, both collected
- An object held in a
WeakReference→ collected at the next GC cycle if nothing else holds it - A
SoftReference→ kept until memory pressure forces eviction - A
PhantomReference→ a hook for cleanup before final reclamation
That reference hierarchy is the reason caches and listeners leak in ways that confound people. A listener registered on a long-lived singleton never becomes unreachable, no matter how many times you null out your own variables.
The young generation, the old generation, and why it matters
Here's the observation that shaped every modern collector: most objects die young. The vast majority of allocations in a typical request-handling application are short-lived — a DTO, a string, a temporary list. So the heap is split into a young generation (Eden plus two survivor spaces) and an old generation.
New objects land in Eden. When Eden fills, a minor GC runs: surviving objects are copied into a survivor space, and the Eden region is wiped clean. Objects that survive a few minor collections get promoted to the old generation. When the old generation fills, you get a major or full GC — usually much slower, and usually the source of your latency spikes.
Which brings me to a mistake I made for a whole year: I sized the young generation too small. Promotion rate went through the roof, the old generation filled every few minutes, and I was getting a full GC roughly every 90 seconds on a service handling maybe 400 requests per second. I blamed the framework. I blamed the database driver. It was -Xmn.
Types of garbage collection in Java: choosing the right collector
No single collector wins. Each one optimizes for a different goal, and the choice depends on your heap size, your latency budget, and whether you care more about throughput or pauses.
| Collector | Enabled with | Best for | Typical pause profile |
|---|---|---|---|
| Serial | -XX:+UseSerialGC | Small heaps, single-core containers, CLI tools | Stop-the-world, but short on tiny heaps |
| Parallel | -XX:+UseParallelGC | Batch jobs, maximum throughput | Longer pauses, best CPU efficiency |
| G1 | -XX:+UseG1GC (default since Java 9) | General-purpose server workloads, 4–16 GB heaps | Predictable, tunable pause target |
| ZGC | -XX:+UseZGC | Very large heaps, latency-critical services | Sub-millisecond pauses, even on huge heaps |
| Shenandoah | -XX:+UseShenandoahGC | Similar goals to ZGC, different implementation | Low pauses, concurrent compaction |
Default garbage collector in Java 17
If you're running Java 17 (or later, which by now you probably should be), the default is G1. It's been the default since Java 9, and nothing has displaced it for general workloads. G1 divides the heap into equal-sized regions rather than fixed contiguous generations, tracks how much garbage each region holds, and collects the ones with the most garbage first — hence "Garbage-First."
One G1 quirk that catches people out: humongous objects. Any allocation larger than half a region size gets its own dedicated region sequence and is treated as a humongous allocation. If you're allocating multi-megabyte arrays in a loop, you can trigger a humongous collection storm that looks nothing like normal GC pressure. I've seen this on a reporting endpoint that built huge in-memory CSV chunks. Reducing the chunk size dropped pause times from hundreds of milliseconds to under 20.
How to call the garbage collector in Java
You can't. Seriously. Not reliably.
System.gc() is a hint. The JVM is free to ignore it, and with -XX:+DisableExplicitGC it will. Runtime.getRuntime().gc() does the same thing. Some code paths call it in a loop hoping to solve memory pressure, which is roughly like opening a window to fix a flooded basement.
The only legitimate uses are in test harnesses and benchmarks where you want to normalize state between runs. In production code, if you're calling System.gc(), you're treating a symptom you haven't diagnosed.
Observing garbage collection in production
Guessing is the enemy. Here's what actually tells you what's happening. Turn on unified logging with -Xlog:gc*:file=gc.log:time,uptime,level,tags and you get timestamped events for every collection cycle. Watch four numbers:
- Pause time — how long the world stops. If your SLA is 200 ms, no single pause should approach it.
- Allocation rate — how fast you're creating objects. Rising allocation rate with flat traffic means a code regression.
- Promotion rate — how much is escaping to the old generation. High promotion on a request-scoped workload means your young gen is too small.
- Heap occupancy after GC — if it keeps climbing across cycles, you have a genuine leak, not a tuning problem.
That last one is the giveaway. A tuning issue oscillates. A leak trends upward and never comes back down. I spent three days tuning flags once before it clicked that the post-GC baseline was rising every cycle — a listener accumulating registrations on a static registry. No collector setting fixes that.
For deeper work, JFR (Java Flight Recorder) gives you allocation profiling without meaningful overhead. jstat gives you a ten-second snapshot. VisualVM is fine for local investigation.
A word on the Minecraft case
If you've landed here from a Minecraft server context, the same principles apply with very different tuning. Minecraft allocates aggressively and its tick loop is latency-sensitive. Server operators commonly run Aikar's flags, which tune G1 with a larger young generation and a target pause time suited to tick stability. The mechanism is identical to what I've described — reachability, promotion, pause budgets. Only the parameters differ.
What to actually do
Start by measuring. Turn on GC logging before you change a single flag. Look at post-GC heap occupancy first — if it's climbing, stop tuning and start hunting a leak. If it's stable but promotion rate is high, grow the young generation. If pause times dominate and your heap is large, consider ZGC.
And when someone tells you to just add more RAM, remember what that actually does: it delays the same problem and makes the eventual pause longer. The collector is not your adversary. It's a machine doing exactly what you configured it to do, and it's been telling you what's wrong in its logs the whole time.