Skip to main content
the invisible-layer how abstraction is making software engineers dumber

The Hidden Cost of Garbage Collection

9 min read Chapter 15 of 56
Summary

Dissects garbage collection algorithms from reference counting to...

Dissects garbage collection algorithms from reference counting to generational collectors, shows how to read GC logs in Java, Python, and Go, demonstrates a real memory leak pattern, and explains why GC trades one class of bugs for another.

The Hidden Cost of Garbage Collection

The Promise and the Trade

Garbage collection was supposed to be the end of memory bugs. No more free() on a dangling pointer. No more use-after-free. No more double-free crashes at 3 AM. The runtime would handle it, and you’d never think about memory again.

That promise was half-kept. GC did eliminate specific classes of memory corruption bugs. But it replaced them with something else: unpredictable pauses, invisible memory leaks, allocation pressure that degrades throughput under load, and a false sense of security that makes you sloppy about object lifetimes.

You traded segfaults for latency spikes. Whether that’s a good trade depends on whether you understand what you gave up.

How Garbage Collectors Actually Work

Every garbage collector answers the same question: which objects are still reachable, and which can be reclaimed? The algorithms differ in how they answer it.

Reference Counting

The simplest approach. Every object carries a counter. When something points to the object, increment the counter. When a reference goes away, decrement it. When the counter hits zero, free the memory immediately.

import sys

a = []          # refcount: 1 (a points to it)
b = a           # refcount: 2 (a and b point to it)
print(sys.getrefcount(a))  # 3 — includes the temporary ref from getrefcount's argument

del b           # refcount: 1
del a           # refcount: 0 → freed immediately

CPython uses reference counting as its primary GC mechanism. The advantage is deterministic: objects are freed the instant they become unreachable. No pauses. No batching.

The fatal flaw: cycles. If object A references object B and object B references object A, both reference counts are at least 1 forever, even if nothing else in the program can reach either object.

class Node:
    def __init__(self):
        self.ref = None

a = Node()
b = Node()
a.ref = b   # a→b
b.ref = a   # b→a

del a        # a's refcount: 1 (b.ref still points to it)
del b        # b's refcount: 1 (a.ref still points to it)
# Both objects are unreachable but not freed. Leaked.

This is why CPython has a second garbage collector on top of reference counting — a cyclic GC that periodically scans for unreachable reference cycles.

Mark-and-Sweep

The classic tracing algorithm. Start from a set of roots — global variables, stack variables, CPU registers. Follow every reference. Mark every object you can reach. Then sweep through all allocated objects and free anything that isn’t marked.

The problem is the pause. While the collector is tracing references, the program’s object graph can’t be mutating, or the collector will miss references or free live objects. A naive mark-and-sweep stops the world — all application threads freeze while GC runs.

Generational Collection

An empirical observation called the generational hypothesis: most objects die young. A temporary string built for a log message, a loop variable, a short-lived HTTP request object — these are allocated and become garbage within milliseconds.

Generational collectors exploit this by dividing objects into generations:

  • Young generation (Gen 0): Newly allocated objects. Collected frequently and quickly, because most are already dead.
  • Old generation (Gen 1, Gen 2): Objects that survived multiple young collections. Collected rarely, because they’re likely long-lived.

This is a throughput optimization: instead of scanning all objects every time, you scan the small young generation frequently and the large old generation infrequently.

Java’s G1GC, Go’s collector, .NET’s GC, and Python’s cyclic collector all use generational strategies.

Reading Java’s G1GC Logs

Java’s G1 (Garbage-First) collector divides the heap into regions and collects the regions with the most garbage first. When things go wrong — and they will — the GC logs tell you exactly what happened.

Enable detailed GC logging:

java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar myapp.jar

Here’s what a young generation pause looks like:

[2026-02-27T10:15:32.451+0000][12.442s][info][gc] GC(14) Pause Young
    (Normal) (G1 Evacuation Pause) 512M->128M(2048M) 8.234ms

Translation: GC event #14, a young generation pause, reduced heap usage from 512 MB to 128 MB (heap size 2048 MB), and it took 8.234 milliseconds. Eight milliseconds where your application threads did nothing.

Here’s a mixed collection — when G1 also collects some old generation regions:

[2026-02-27T10:15:45.891+0000][25.882s][info][gc] GC(31) Pause Young
    (Mixed) (G1 Evacuation Pause) 1024M->384M(2048M) 42.891ms

Forty-three milliseconds. If your API latency target is 50 ms, a single GC pause just consumed 86% of your budget.

And here’s the event you never want to see:

[2026-02-27T10:16:01.234+0000][41.225s][info][gc] GC(45) Pause Full
    (G1 Evacuation Failure) 1920M->890M(2048M) 1842.567ms

A full GC. 1.8 seconds. Every application thread was frozen. Your users got timeouts. Your load balancer marked the instance unhealthy. This happens when the young generation can’t evacuate objects fast enough — there isn’t enough free space in the old generation to promote survivors.

The key metrics to watch:

MetricWhat it meansDanger sign
Pause timeDuration of stop-the-world pause>50ms for latency-sensitive apps
Allocation rateMB/s of new objects>1 GB/s can overwhelm the collector
Promotion rateMB/s moved to old generationHigh rate means old gen fills fast
Full GC eventsOld generation ran out of spaceAny full GC in production is a fire

Python’s Dual System

CPython runs two garbage collectors simultaneously, and most Python programmers don’t know either exists.

Reference counting handles the common case: objects freed immediately when their refcount drops to zero. Deterministic, fast, no pauses.

Cyclic GC runs periodically to find reference cycles that refcounting misses. It scans objects in three generations:

import gc

# See how many objects are in each generation
print(gc.get_count())  # e.g., (687, 8, 2)
# (gen0_count, gen1_count, gen2_count)

# See the thresholds that trigger collection
print(gc.get_threshold())  # (700, 10, 10)
# Gen 0 collected every 700 allocations
# Gen 1 collected every 10 Gen 0 collections
# Gen 2 collected every 10 Gen 1 collections

# Get collection statistics
stats = gc.get_stats()
for i, gen in enumerate(stats):
    print(f"Gen {i}: {gen['collections']} collections, "
          f"{gen['collected']} objects collected, "
          f"{gen['uncollectable']} uncollectable")

The cyclic collector is a stop-the-world mark-and-sweep limited to container objects (lists, dicts, classes — things that can hold references). It doesn’t scan integers or strings because they can’t form cycles.

You can measure the cost directly:

import gc
import time

gc.collect()  # Clear everything first
gc.disable()  # Disable automatic GC

# Allocate a lot of cyclic garbage
for _ in range(100_000):
    a = {}
    b = {}
    a['ref'] = b
    b['ref'] = a

start = time.perf_counter()
collected = gc.collect()
elapsed = time.perf_counter() - start
print(f"Collected {collected} objects in {elapsed*1000:.1f}ms")

gc.enable()

On a typical machine, collecting 200,000 cyclically-linked objects takes 15-40 ms. In a web server handling requests, that’s a 15-40 ms latency spike for some unlucky request.

The Memory Leak You’re Probably Writing Right Now

GC doesn’t prevent memory leaks. It prevents one kind of memory leak — forgetting to call free(). It does nothing about the more common kind in managed languages: holding a reference you forgot about.

Here’s a pattern that appears in almost every long-running Python application:

class RequestCache:
    """Cache recent API responses for deduplication."""

    def __init__(self):
        self._cache = {}

    def get_or_fetch(self, url):
        if url in self._cache:
            return self._cache[url]

        response = self._fetch(url)
        self._cache[url] = response  # Stored forever
        return response

    def _fetch(self, url):
        import urllib.request
        with urllib.request.urlopen(url) as r:
            return r.read()  # Could be megabytes

cache = RequestCache()  # Module-level — lives forever

This cache grows without bound. Every unique URL adds an entry that will never be evicted. The GC can see that every object in the cache is reachable — cache._cache points to all of them — so it correctly does nothing. The memory grows until the process is killed by the OOM killer or the container runtime.

The fix is straightforward, but requires you to think about object lifetimes — exactly the thing GC was supposed to free you from:

from collections import OrderedDict

class BoundedCache:
    def __init__(self, max_size=1000):
        self._cache = OrderedDict()
        self._max_size = max_size

    def get_or_fetch(self, url):
        if url in self._cache:
            self._cache.move_to_end(url)
            return self._cache[url]

        response = self._fetch(url)
        self._cache[url] = response
        if len(self._cache) > self._max_size:
            self._cache.popitem(last=False)  # Evict oldest
        return response

Or use functools.lru_cache, or cachetools.TTLCache, or any other bounded eviction strategy. The point is: you still have to think about when objects should die. GC only automates the mechanical act of freeing memory, not the design decision of when something should be freed.

Measuring GC Impact Across Languages

Every major runtime gives you tools to observe GC behavior. Use them before you start guessing.

Java:

# Detailed GC logging
java -Xlog:gc*,gc+phases=debug:file=gc.log:time,uptime,level,tags \
     -XX:+UseG1GC -jar app.jar

Python:

import gc

gc.set_debug(gc.DEBUG_STATS)  # Print GC stats to stderr on each collection

# Or programmatically
gc.callbacks.append(lambda phase, info: print(f"GC {phase}: {info}"))

Go:

GODEBUG=gctrace=1 ./myapp
# Output:
# gc 1 @0.012s 2%: 0.015+1.2+0.003 ms clock, 0.12+0.8/1.1/0+0.024 ms cpu,
#   4->4->1 MB, 5 MB goal, 8 P
# Translation: GC #1, 2% of CPU time, 1.2ms wall clock, heap went from 4MB to 1MB

The Go line is dense but revelatory. The format 4->4->1 MB means: heap size at GC start → heap size at GC end before sweeping → live data after sweeping. If that first number keeps climbing, you’re allocating faster than the collector can reclaim.

The Irony

Garbage collection was a genuine advance. It eliminated an entire category of catastrophic bugs — use-after-free, double-free, heap corruption — that caused real security vulnerabilities and real crashes.

But the abstraction created a false comfort. Engineers stopped thinking about memory lifetimes because they believed the problem was solved. It wasn’t solved. It was transformed. Instead of a segfault that crashes immediately and gives you a core dump, you get a slow memory leak that manifests as gradually increasing RAM usage over weeks, or a GC pause that causes a cascade of timeouts across your microservices at peak traffic.

The segfault was honest. It told you something was wrong, immediately, with a stack trace. The GC pause is subtle. It happens intermittently, correlates with allocation rate rather than any specific code path, and is invisible unless you’re actively monitoring for it.

You don’t need to write your own allocator. You don’t need to abandon garbage-collected languages. But you absolutely need to understand what the collector is doing, how to measure it, and when it’s working against you. The alternative is debugging production latency with a blindfold on.