All three languages ship with a garbage collector, but the algorithms behind them are completely different. Java places a region-based GC on top of the generational hypothesis. Python reclaims memory eagerly via reference counting and falls back to a cycle detector for the rest. Go runs a single concurrent tri-color mark-and-sweep tuned for low pause times. On the same backend workload, throughput and latency profiles diverge at this choice.
Java GC
Java GC builds on the weak generational hypothesis — the empirical observation that most objects die young. With that assumption the heap is split into Young (Eden + Survivor) and Old, short-lived objects are collected cheaply in Young, and only survivors get promoted to Old.
block-beta
columns 4
eden["Eden
(new allocations)"]
s0["Survivor 0"]
s1["Survivor 1"]
old["Old (Tenured)
(promoted long-lived objects)"]
style eden fill:#FFE0B2
style s0 fill:#FFF59D
style s1 fill:#FFF59D
style old fill:#90CAF9
Minor GC sweeps Young only and finishes quickly. Major GC (or Full GC) touches Old too and is slower. Most operational cost comes from how long Major GC stops the world.
Since Java 9 the default GC is G1 GC (Garbage-First, JEP 248). G1 divides the heap into equal-sized regions and prioritizes the regions with the most garbage. The Young/Old split is decided per region, dynamically. Set a target pause time with -XX:MaxGCPauseMillis and G1 plans which regions to collect within that budget.
If pause time matters more, switch to ZGC (Z Garbage Collector). Production-ready since Java 15 (JEP 377), with a generational variant added in Java 21 (JEP 439). ZGC uses colored pointers and load barriers to do most of its work concurrently, holding pause times below a millisecond. Throughput is somewhat lower than G1 in return.
The characteristic feature of Java GC is that picking a GC is itself a tuning knob. G1 for throughput, ZGC for latency, Serial for tiny heaps — the same application code yields different operational profiles depending on which collector runs underneath.
Python GC
CPython starts from reference counting. Every object stores a count of references pointing at it. Each new reference bumps the count up by one; each removed reference brings it down. The instant the count hits zero, the object is freed.
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2 (a + getrefcount's own argument)
b = a
print(sys.getrefcount(a)) # 3 (a + b + argument)
del b
print(sys.getrefcount(a)) # 2
Reference counting has clear strengths. Reclamation is deterministic, there is no STW pause, and finalizers like __del__ run at predictable points. The weaknesses are also clear. Every reference change pays for a counter update, and cycles aren’t reclaimed.
class Node:
pass
a = Node()
b = Node()
a.next = b
b.next = a # cycle
del a
del b # the outer references are gone, but a and b reference each other,
# so neither refcount drops to zero
To close that gap, CPython adds a cycle detector in the gc module. Objects are grouped into three generations (0, 1, 2), and the detector periodically walks each generation looking for cycles. The algorithm temporarily strips internal references from the counts and reclaims anything that ends up with zero external references (see the gc module docs).
The signature is that most reclamation happens immediately via refcounts, with the cycle detector running occasionally as a backstop. STW is mostly invisible, but while the cycle detector runs it holds the GIL, which can show up as a sudden latency spike in multithreaded workloads.
Go GC
Go uses a single algorithm: concurrent tri-color mark-and-sweep. No generations. Every cycle walks the entire heap. The design priority from day one was low latency.
flowchart LR
W[White
not yet seen] -->|reachable from root| G[Gray
seen / children unexplored]
G -->|all children explored| B[Black
fully explored]
W -->|stays white at end| X[reclaimed]
style W fill:#FAFAFA
style G fill:#BDBDBD
style B fill:#424242,color:#fff
style X fill:#EF9A9A
Once the scan finishes, whatever is still white gets reclaimed. The hard part of tri-color is that the application (the mutator) keeps changing references while marking runs. If a Black object newly points at a White one, that White object should survive but gets dropped.
Go closes that hole with a write barrier. When the mutator writes a reference, the GC intercepts the write and marks the newly-referenced White object as Gray. Go 1.5 introduced the concurrent collector; 1.8 switched to a hybrid write barrier to shrink STW further.
There are effectively two knobs. GOGC (default 100) sets the heap-growth percentage that triggers the next cycle — at 100, GC runs again when the heap reaches twice the live set after the previous cycle. GOMEMLIMIT (Go 1.19+) caps the heap, forcing the GC to run more aggressively when memory pressure is high.
Unlike Java’s “pick a GC”, Go offers a single collector tuned with two environment variables. Fewer choices, simpler decisions.
Three Models Compared
The trade-offs collapse into a table.
| Aspect | Java (G1/ZGC) | Python (CPython) | Go |
|---|---|---|---|
| Core algorithm | Generational + region mark-sweep | Reference counting + generational cycle detector | Concurrent tri-color mark-sweep |
| When memory is reclaimed | At GC cycles | Refcount → zero, plus occasional cycles | At GC cycles |
| Typical STW | G1 tens of ms / ZGC sub-ms | Mostly none; short during cycle runs | Sub-ms goal |
| Throughput | G1 strong / ZGC slightly lower | Refcount update overhead | Runs alongside the mutator |
| Tuning surface | GC choice + per-collector flags | gc thresholds, disable cycles | GOGC + GOMEMLIMIT |
| Multithreading impact | GC runs in its own threads | Cycle detector holds the GIL | Concurrent, runs with the mutator |
Java GC fits when:
- Heaps run large (tens of GB+) and the team wants to dial in throughput vs. latency by GC choice
- The operations team can tune GC options in depth
- Consistent throughput matters more than sub-millisecond latency
Python GC fits when:
- Deterministic object lifetimes matter (auto-cleanup of files, resources)
- The workload is single-threaded or I/O bound
- Cycles are rare in the domain
Go GC fits when:
- Sub-ms latency is part of the SLA (Service Level Agreement)
- Tuning simplicity is preferred
- Heaps stay moderate (a few GB) and responsiveness wins over peak throughput
The three GCs do the same job under different priorities. Java multiplied GC choices so the workload picks one. Python bought deterministic reclamation at the cost of multithread scalability tied to the GIL. Go went with a single collector, gave up some throughput, and bought low latency. Which one fits comes down to which priority your operating environment cares about most.
References
- Everything Is Pass by Value — Memory Transfer in Go, Java, Python — How function-argument passing follows the same pattern across the three languages. The memory model there decides where the objects that the GC eventually reclaims actually live.