Every long-running program allocates memory it eventually stops using, and someone has to reclaim that memory or the process eventually runs out. In languages like C, that someone is the programmer, and getting it wrong produces leaks, double-frees, and use-after-free bugs. Java, Go, and Python all take that job away from the programmer, but they do it with genuinely different mechanisms, tuned for different trade-offs between pause time, throughput, and memory overhead. This explainer sticks to what each language’s own official documentation and engineering blogs say about how their collectors actually work.
At a Glance
| Runtime | Primary Strategy | Pause Behavior | Typical Use Case |
|---|---|---|---|
| JVM (HotSpot) — G1 | Generational, mostly-concurrent tracing collector | Targets a pause-time goal “with high probability”, default on most hardware | General-purpose server applications where response time matters more than raw throughput |
| JVM (HotSpot) — ZGC | Fully concurrent, generational tracing collector | Max pauses under one millisecond, independent of heap size | Low-latency applications with heaps from a few hundred MB up to 16 TB |
| Go runtime | Concurrent tracing mark-sweep collector, parallelized across cores | Runs concurrently with your goroutines; short stop-the-world phases for setup | General-purpose networked/cloud services |
| CPython | Reference counting, backed by a generational cyclic collector | Refcounting frees objects immediately; the cyclic collector runs periodically and can be tuned or disabled | General-purpose scripting; the cyclic collector exists specifically to catch reference cycles |
Why Automatic Memory Management Exists
In a language with manual memory management, every allocation needs a matching, correctly-timed deallocation. Free too early and you get a use-after-free; never free and you get a leak; free twice and you get undefined behavior. Garbage collectors exist to make these bugs structurally impossible (or at least much rarer) by having the runtime itself figure out when memory is no longer reachable and reclaiming it automatically. The three ecosystems covered here solve that problem with different core techniques: tracing (follow references from a set of roots and reclaim what you don’t reach) and reference counting (track how many references point at an object and reclaim it when that count hits zero). Java and Go use tracing; Python uses reference counting as its primary mechanism, with a tracing collector as a backstop.
The JVM’s Collectors: G1 and ZGC
Oracle’s HotSpot documentation describes several collectors, but two matter most for modern server workloads. G1 (Garbage-First), enabled with -XX:+UseG1GC and the default on most hardware and OS configurations, is described by Oracle as “mostly concurrent”: it performs some of its most expensive work concurrently with the running application, and is designed to meet a pause-time goal with high probability while still achieving high throughput. It scales from small machines up to large multiprocessors with lots of memory, and Oracle recommends it when response time matters more than peak throughput.
ZGC, enabled with -XX:+UseZGC -XX:+ZGenerational for its generational mode, is fully concurrent rather than mostly concurrent. Oracle’s documentation states ZGC keeps maximum pause times under one millisecond, independent of heap size, and that it’s designed for low-latency applications with heap sizes ranging from a few hundred megabytes up to 16 terabytes — at some cost in throughput compared to G1. Oracle’s general tuning principle applies to both: throughput is inversely proportional to how much memory is available, because collections happen when a generation fills up, so total heap size is the single most important factor in GC performance. Oracle’s own starting-point guidance is straightforward: if peak throughput matters most and pauses of a second or more are tolerable, use the Parallel collector; if response time matters more than throughput, use G1; if response time is a high priority, use ZGC.
Go’s Concurrent Mark-Sweep Collector
Go’s runtime uses a tracing mark-sweep collector. According to the Go team’s own blog, the mark phase walks the object graph starting from roots (global and local variables) and marks every object it reaches; the sweep phase then frees everything that was never marked. Critically, this runs concurrently with your regular Go code and is parallelized across CPU cores, rather than stopping your whole program for the full collection the way older or simpler collectors do. The Go team’s own analysis found that marking accounts for roughly 90% of total GC cost, with sweeping making up the rest, and that the graph walk’s unpredictable jumps between objects and memory pages hurt CPU cache performance — a cost that’s grown worse as hardware trends (NUMA, lower per-core memory bandwidth, more cores contending on shared work queues) have progressed. Some Go programs reportedly spend 20% or more of their total CPU time in the garbage collector as a result.
To address this, the Go team has been developing “Green Tea GC,” an experimental redesign whose core idea, per the Go blog, is to “work with pages, not objects” — tracking whole memory pages on the work list instead of individual objects, with per-object “seen” and “scanned” bits on each page so the collector can scan a page’s pending objects in one in-memory-order pass. In Google’s internal benchmark suite, this cut GC CPU time by 10% to 40% (about 10% typical), though some workloads saw no benefit or slightly regressed. Green Tea GC is experimental in Go 1.25 (opt-in via GOEXPERIMENT=greenteagc) with plans to make it the default in Go 1.26.
CPython: Reference Counting Plus a Generational Cyclic Collector
Python’s approach is structurally different from Java’s and Go’s. CPython’s primary mechanism is reference counting: every object tracks how many references point at it, and when that count reaches zero, CPython frees the object immediately, with no separate “sweep” pass required. The problem reference counting cannot solve on its own is a reference cycle — two or more objects that reference each other (directly or indirectly) so their counts never reach zero, even after nothing outside the cycle can reach them. This is where CPython’s gc module comes in: Python’s own documentation describes it as an “optional cyclic garbage collector” that supplements reference counting specifically to catch these cycles.
A minimal example of the problem the cyclic collector exists to solve:
import gc
class Node:
def __init__(self):
self.other = None
a = Node()
b = Node()
a.other = b
b.other = a # a reference cycle: a -> b -> a
del a, b # reference counts never reach zero on their own
print(gc.collect()) # the cyclic collector finds and frees them
Per the official docs, the cyclic collector sorts tracked objects into three generations: new objects start in generation 0, and any object that survives a collection is promoted to the next older generation, with generation 2 being the oldest. A collection is triggered once the count of allocations minus deallocations exceeds threshold0; generation 1 is examined once generation 0 has been checked more than threshold1 times since generation 1 was last checked, with generation 2 following its own, more involved rules. The thresholds are tunable via gc.set_threshold(), and automatic collection can be disabled entirely by setting threshold0 to 0 — something the docs note is reasonable only if you’re confident your program creates no reference cycles. Unreachable objects that still can’t be freed (typically from C extension types with certain finalizer slots) land in the gc.garbage list rather than silently leaking without a trace.
Comparing the Mechanisms
| Mechanism | Core Technique | Concurrent With App? | Generational? | Handles Reference Cycles? |
|---|---|---|---|---|
| JVM G1 | Tracing (mark-and-reclaim) | Mostly — some work runs concurrently | Yes | Yes, inherently (tracing collectors don’t rely on counts) |
| JVM ZGC | Tracing (mark-and-reclaim) | Fully concurrent | Yes (generational mode) | Yes, inherently |
| Go runtime | Tracing mark-sweep | Yes, concurrent and parallel across cores | No (Go’s collector is not generational) | Yes, inherently |
| CPython | Reference counting + tracing backstop | Refcounting: immediate, synchronous; cyclic GC: runs periodically, not continuously concurrent | Yes, for the cyclic collector (3 generations) | Only via the separate cyclic collector — refcounting alone cannot |
Trade-offs
- Pause time vs. throughput: ZGC buys sub-millisecond pauses at some throughput cost versus G1; Oracle’s own guidance frames this explicitly as a trade a team should choose deliberately, not a free upgrade.
- Cache behavior vs. simplicity: Go’s object-by-object mark phase is conceptually simple but, per the Go team’s own analysis, jumps unpredictably through memory in ways that hurt CPU cache performance — which is exactly the problem the experimental Green Tea redesign targets by working in whole pages instead.
- Immediacy vs. completeness: CPython’s reference counting frees most objects the instant they become unreachable, which is great for predictability and for releasing OS resources (file handles, sockets) promptly — but it cannot, by itself, free a reference cycle, which is why the generational cyclic collector exists as a second, separate mechanism layered on top.
If you’re interested in other core engineering mechanisms we’ve covered in depth, see our explainers on Unicode for developers and CRDTs, both of which deal with a similar theme: a runtime or protocol doing complex bookkeeping so application code doesn’t have to.
The Bottom Line
There’s no single “best” garbage collector, only a best fit for a workload’s latency and throughput requirements. The JVM gives you an explicit choice (G1 for balanced throughput and response time, ZGC when sub-millisecond pauses matter more than raw speed). Go gives you one well-tuned, concurrent mark-sweep collector with no manual collector selection, and active engineering work (Green Tea) aimed at reducing its CPU overhead without changing its external behavior. Python gives you fast, deterministic reference counting for the common case, with a separate, tunable cyclic collector that exists solely to catch the one class of object that counting can’t: reference cycles. Understanding which mechanism your runtime uses — and what it’s bad at — is usually more useful for diagnosing a memory problem than reaching for a tuning flag first.
FAQ
Which Java garbage collector is the default?
G1 is the default on most hardware and OS configurations in modern HotSpot JVMs; Serial is the default only on certain limited configurations.
Does Go have generational garbage collection like Java?
No. Go’s collector is a concurrent, parallelized mark-sweep collector, but per the Go team’s own description it is not generational the way G1 or ZGC are.
Can I turn off Python’s garbage collector?
You can disable the cyclic collector with gc.disable() or by setting threshold0 to 0. Reference counting itself cannot be disabled — it’s built into how CPython manages every object.
Why does a reference cycle leak memory in Python if nothing can reach it?
Because reference counting only looks at each object’s own count of incoming references. Two objects referencing each other keep each other’s count above zero even when nothing outside the cycle can reach either one — which is exactly the gap the generational cyclic collector is designed to close.
Is ZGC always better than G1 because its pauses are shorter?
No. Oracle’s own documentation frames this as a trade-off, not a strict upgrade: ZGC’s sub-millisecond pauses come at some cost to overall throughput compared to G1, so the right choice depends on whether your application cares more about response time or total work done per second.
