Garbage collection is the runtime mechanism that reclaims heap memory your program no longer references. Without it, every allocation would require a matching manual free — and in practice, humans forget. Languages from Java to Go to Python to JavaScript all ship with GC, but they differ significantly in how they find unreachable objects, when they run, and what the latency cost is.
What changed in 2026
- Sub-millisecond GC pauses are mainstream. Go's GC targets < 0.5 ms. Java's ZGC and Shenandoah target < 1 ms on terabyte heaps. .NET's Server GC with regions achieves similar results.
- JavaScript engines (V8, SpiderMonkey) ship incremental+concurrent collectors. Orinoco (V8's GC) has been fully concurrent since 2021; in 2026 it handles WebAssembly heap objects too.
- Rust proved the "no GC" alternative is viable at scale. Rust's ownership model eliminates GC entirely by making lifetime analysis a compile-time check — at the cost of a steeper learning curve.
- GC-aware hardware (Intel MPX successor, CHERI) is in research but not yet production.
How tracing GC works
The two fundamental techniques are mark-and-sweep and copying/compacting. Both start with the same root scan:
Roots: global variables, stack frames, CPU registers
↓ Follow every reference
Mark: paint every reachable object "live"
↓
Sweep/Compact: reclaim or move all unmarked objects
Mark-and-sweep leaves objects in place (no compaction). Fast, but causes heap fragmentation over time.
Copying GC moves live objects to a fresh "to-space," leaving the old "from-space" entirely free. Compacts automatically, but needs 2× the heap headroom.
Compacting GC (like .NET) moves live objects in-place to eliminate fragmentation without doubling memory.
Generational hypothesis
Empirically, the vast majority of allocated objects become unreachable within milliseconds of allocation — function return values, loop temporaries, and so on. Generational collectors exploit this:
Young Gen (nursery) → collected frequently, cheaply (~1–5 ms)
Old Gen (tenured) → collected infrequently (~10–500 ms, or concurrent)
Objects that survive a few young-gen collections get "promoted" to the old gen. Full ("major") GC scans the whole heap and is typically triggered by old-gen pressure.
| Runtime |
Young gen |
Old gen / Tenured |
| JVM (G1 / ZGC) |
Eden + Survivor spaces |
Regions or tenured heap |
| V8 |
Scavenger (copying) |
Mark-compact (concurrent) |
| .NET |
Gen0 / Gen1 |
Gen2 (LOH for large objects) |
| Python CPython |
Refcount + generational cycle detector |
— |
Reference counting (Python, Swift)
Reference counting increments a counter on every assignment and decrements on every drop. When the counter reaches zero, the object is freed immediately.
import sys
x = [1, 2, 3]
print(sys.getrefcount(x)) # 2 (x + getrefcount arg)
y = x
print(sys.getrefcount(x)) # 3
del y
print(sys.getrefcount(x)) # 2
Advantage: deterministic, immediate deallocation; no stop-the-world pauses.
Problem: reference cycles (A → B → A) never reach zero. CPython adds a generational cycle detector that runs periodically to handle this. Swift uses Automatic Reference Counting (ARC) at compile time — no runtime overhead for cycle detection, but you must break cycles manually with weak or unowned references.
Profiling GC in Go
import "runtime"
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("NumGC: %d, PauseTotal: %v ms\n",
stats.NumGC, time.Duration(stats.PauseTotalNs))
Use GODEBUG=gctrace=1 to log every GC cycle to stderr:
gc 14 @3.141s 0%: 0.021+1.2+0.018 ms clock, ...
For Java ZGC:
java -XX:+UseZGC -Xlog:gc* -jar app.jar
How to pick your GC strategy
| Runtime |
Best GC choice in 2026 |
| JVM |
ZGC (< 1 ms pause) or G1 (balanced) |
| Go |
Default GC is good; tune GOGC and GOMEMLIMIT |
| .NET |
Server GC with regions (default in .NET 7+) |
| Node.js / V8 |
Default concurrent GC; tune --max-old-space-size |
| Python |
Consider PyPy for GC-sensitive workloads |
- Profile before tuning. Most GC pauses are not your bottleneck.
- Set
GOMEMLIMIT in Go (introduced 1.19) to give the GC a soft memory cap — it will collect more aggressively rather than OOM.
- Avoid large object allocation in hot loops — large objects often skip the young gen and land directly in the old gen or LOH.
- Reduce allocation rate — fewer allocations means fewer GC cycles; prefer value types and pool reuse where profiling shows hotspots.
Common mistakes
Ignoring GC metrics in production. GC pause time and allocation rate are first-class SLIs for latency-sensitive services. Add them to your dashboard.
Object pooling everywhere. sync.Pool (Go), object pools (Java), and ArrayPool (.NET) help in specific hot paths. Applied broadly they add complexity and hurt cache locality.
Holding references to large objects longer than needed. A reference in a long-lived map keeps the entire object alive. Null / delete the reference as soon as you are done.
Setting heap too small. A tiny heap forces constant GC. Rule of thumb: size the heap to 2–3× your live set for comfortable throughput.
Confusing GC pause with GC overhead. Pause = stop-the-world latency per cycle. Overhead = total CPU spent on GC. Both matter; they require different fixes.
What to skip
- Manually calling
System.gc() (Java) or runtime.GC() (Go) in production — it rarely helps and often hurts by triggering a full collection at the wrong time.
- Assuming GC-free means Rust for everything — Rust's ownership model has a learning curve and compile time cost; measure first.
- Disabling the cycle detector in Python (
gc.disable()) without understanding your allocation patterns — you will leak memory on any circular data structure.
FAQ
Does GC cause latency spikes in production?
With modern concurrent collectors (ZGC, Go GC, V8 Orinoco) in 2026, pauses are typically < 1 ms. If you are seeing > 10 ms pauses, you likely have a heap sizing or allocation rate problem.
What is the difference between GC and a destructor?
A destructor (C++) runs immediately when an object goes out of scope. A GC finalizer runs at some unspecified future time when the collector finds the object unreachable — you cannot rely on it for prompt resource release.
Does Rust really have no GC?
Correct. Rust's borrow checker enforces ownership and lifetime rules at compile time, so the compiler inserts dealloc calls deterministically. No runtime collector needed, but you must follow the ownership rules.
How do I reduce GC pressure in Node.js?
Reduce heap allocation rate: reuse buffers (Buffer.allocUnsafe + manual reset), avoid creating closures in hot loops, and use streaming APIs rather than loading full payloads into memory.
Where to go next