Memory Management: GC, Reference Counting, and Manual Memory


Every program allocates memory and, eventually, needs to reclaim it. How a language handles this reclamation defines much of its character: its performance profile, its safety guarantees, its runtime behavior, and what kinds of bugs are possible.

There are three main approaches. Understanding them explains a lot about why different languages are used for different things.

Manual Memory Management

The programmer explicitly allocates memory and explicitly frees it. C and C++ are the primary examples.

// Allocate
int* array = malloc(10 * sizeof(int));

// Use it
for (int i = 0; i < 10; i++) {
    array[i] = i * 2;
}

// Free - the programmer's responsibility
free(array);

The programmer has complete control over when memory is allocated and freed. No runtime overhead for tracking references or running a garbage collector. Memory is freed exactly when the programmer decides.

The cost: the programmer can get it wrong in ways that cause serious bugs.

Memory leak: allocate memory and never free it. In a long-running program, this causes unbounded memory growth.

void process_requests() {
    while (true) {
        char* buffer = malloc(1024);
        read_request(buffer);
        handle_request(buffer);
        // Forgot to free(buffer) - 1KB leaked per request
    }
}

Use-after-free: access memory after freeing it. The memory may have been reallocated for something else; reading it gives garbage, writing it corrupts unrelated data.

int* p = malloc(sizeof(int));
*p = 42;
free(p);
printf("%d\n", *p);  // Undefined behavior - memory was freed

Double-free: free the same memory twice. Corrupts allocator state, can cause crashes or security vulnerabilities.

Buffer overflow: write beyond the bounds of allocated memory. Overwrites adjacent memory, the classic source of security vulnerabilities.

These bugs are notoriously hard to find. They often don’t crash immediately at the site of the error - they cause corruption that manifests elsewhere, sometimes much later. Tools like AddressSanitizer and Valgrind detect memory errors at runtime, but they’re developer tools, not production safeguards.

Despite these risks, manual memory management is used where performance and predictability matter most: operating systems, game engines, system libraries, embedded systems. The GC pauses that are acceptable in a web service are unacceptable when you’re writing an OS scheduler or a game frame loop.

Garbage Collection

A garbage collector (GC) automatically reclaims memory that the program can no longer reach. The programmer allocates objects; the runtime tracks which objects are still referenced and frees the unreferenced ones automatically.

Java, Python, C#, JavaScript, Go, and most modern languages use garbage collection.

Tracing GC (used by Java JVM, .NET CLR, Go): periodically traces all live references from GC roots (stack, globals), marks reachable objects as live, and frees everything else.

The challenge is the GC pause - periods where the program stops executing while the GC runs. Modern GC implementations (Go’s concurrent mark-and-sweep, Java’s G1/ZGC) minimize pauses with concurrent collection, running most GC work alongside the program. But some stop-the-world pauses remain, and they’re non-deterministic.

# Go GC stats showing pauses
runtime/gc: full-goroutine-stopping operations=8 ns/op=... pauses: 847µs total

847 microseconds in total GC pauses over a period might be acceptable for a web server; it’s not acceptable for audio processing or real-time systems.

Generational GC: an optimization that most tracing GCs use. Most objects die young - they’re allocated for a request, used briefly, and become garbage. Generational GC divides the heap into young generation and old generation. Young objects are collected frequently with low cost. Objects that survive multiple GC cycles are promoted to the old generation and collected less frequently.

This makes GC much more efficient in practice because most garbage is in the young generation, which is small and fast to collect.

Reference Counting

Reference counting maintains a count of how many references point to each object. When the count drops to zero, the object is freed immediately.

Swift and Python use reference counting as their primary memory management strategy (Python also has a cycle GC for cyclic references).

import sys

x = []            # ref count: 1
y = x             # ref count: 2
del x             # ref count: 1
del y             # ref count: 0 -> freed immediately

The advantage over tracing GC: objects are freed deterministically, at the moment their last reference is dropped. No GC pauses, predictable performance.

class File:
    def __init__(self, path):
        self.handle = open(path)
    def __del__(self):
        self.handle.close()  # Called immediately when ref count hits 0

f = File("data.csv")
# ... use f
del f  # File is closed immediately - predictable resource cleanup

The limitation: reference counting can’t collect cycles. If object A holds a reference to B and B holds a reference to A, both have a reference count of 1 even when nothing else references them.

a = {}
b = {}
a['ref'] = b
b['ref'] = a
del a
del b
# Both objects still have ref count 1 due to the cycle - leaked!

Python handles this with a supplementary cycle detector. Swift handles it with weak references that don’t increment the count - used for back-references to break potential cycles.

Rust’s Ownership Model

Rust takes a different approach entirely: compile-time memory management. No GC, no reference counting at runtime - but no manual memory management bugs either.

Rust enforces ownership rules that guarantee at compile time:

  • Every value has exactly one owner
  • When the owner goes out of scope, the value is freed
  • References must be valid for their lifetime
fn main() {
    let s = String::from("hello");  // s owns the string
    take_ownership(s);               // ownership moves to the function
    // s is no longer valid here - compile error to use it
}

fn take_ownership(s: String) {
    println!("{}", s);
}  // s dropped here, memory freed

If you try to use s after it’s been moved, the compiler refuses. These are compile-time errors, not runtime crashes.

The result: memory safety without a garbage collector. Rust programs have predictable performance (no GC pauses), no memory safety bugs (use-after-free, buffer overflows), and no overhead from runtime memory tracking.

The cost: the borrow checker’s rules require a different way of thinking about program structure. They’re learnable, but they represent a real upfront investment.

Performance, Latency, and Bug Profile

Each memory model produces a distinct performance and failure profile that explains where each language is used.

Manual memory: throughput is excellent because there is no runtime overhead. Latency is fully deterministic - the program frees memory when you say, takes exactly as long as the deallocation takes, and never pauses for GC. The bug profile is severe: use-after-free produces undefined behavior that can range from a crash to a silent data corruption to an exploitable security vulnerability. Buffer overflows are the single most common source of CVEs in C and C++ codebases. The cost is paid in security reviews, tools like AddressSanitizer and Valgrind, and the expertise required to write safe code.

Garbage collection: throughput is high because the GC batches reclamation and is heavily optimized. The latency profile is the tradeoff: GC pauses. Even modern concurrent collectors (Go’s < 1ms target, Java’s ZGC) occasionally pause for longer under pressure. For most web services, GC pauses are invisible - a 2ms pause in a 100ms request is noise. For audio processing, real-time control, or anything with strict SLA requirements, the non-determinism is a problem. The bug profile is much safer: no use-after-free, no buffer overflows, no dangling pointers. The dominant memory bugs are logical - holding references longer than necessary (leaks in GC terms), unintended retention through caches or event listeners.

Reference counting: latency is deterministic in the happy case - objects are freed immediately when the last reference drops. The throughput overhead is real: every assignment, every function call that takes a reference, increments and decrements a counter. Swift instruments show that ARC operations can account for 5-15% of CPU time in reference-heavy code. The cycle-detection overhead adds further cost. The bug profile is intermediate: you can leak through cycles, and retain cycles are subtle. But no use-after-free, no buffer overflow.

Ownership/borrow checking: the performance profile is essentially the same as manual memory - allocations and deallocations are explicit and predictable, with no GC and no reference counting overhead. The bug profile approaches GC safety, but enforced at compile time rather than runtime. The cost is paid entirely upfront, in compilation time and in the learning curve of satisfying the borrow checker.

Which Approach When

Manual memory management: systems programming, embedded systems, performance-critical code where you need deterministic control and can invest in the safety infrastructure.

Garbage collection: most application code. The performance cost is acceptable for web services, developer tools, data processing. The safety and productivity benefits are significant.

Reference counting: environments where deterministic cleanup matters (Swift’s use in iOS development, Python’s memory model). Often combined with cycle detection.

Ownership/borrow checking: Rust’s niche - systems-level performance with memory safety guarantees, without a runtime.

The memory model is one of the fundamental choices a language design makes. Changing it later isn’t feasible. Python will always have a GC; C will always require manual management; Rust will always have ownership. Understanding why these choices were made explains a lot about where each language is and isn’t appropriate.



Read more