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.

AspectJava (G1/ZGC)Python (CPython)Go
Core algorithmGenerational + region mark-sweepReference counting + generational cycle detectorConcurrent tri-color mark-sweep
When memory is reclaimedAt GC cyclesRefcount → zero, plus occasional cyclesAt GC cycles
Typical STWG1 tens of ms / ZGC sub-msMostly none; short during cycle runsSub-ms goal
ThroughputG1 strong / ZGC slightly lowerRefcount update overheadRuns alongside the mutator
Tuning surfaceGC choice + per-collector flagsgc thresholds, disable cyclesGOGC + GOMEMLIMIT
Multithreading impactGC runs in its own threadsCycle detector holds the GILConcurrent, 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