Coming from a Java background, I only started experimenting with Rust for personal projects last year, and now I'm exploring Zig. To understand what garbage collection really offers, I implemented the same problem in all three languages: Java (with GC), Rust (ownership), and Zig (manual memory management). The experience taught me why GC has survived since the 1960s, and why it still matters.

What GC Actually Solves

The trivial correctness of GC is worth the hassles, in most cases. You don't have to worry about allocators or deallocators; things just work, without restriction. And it's usually fast enough if you pay attention to your object usage patterns.

Here's the same pattern in each language - creating a list of objects:

// Java: just allocate and use
List<String> items = new ArrayList<>();
items.add("hello");
// memory cleaned up automatically when unreachable
// Rust: ownership tracks cleanup
let mut items = Vec::new();
items.push("hello".to_string());
// dropped automatically when out of scope
// Zig: explicit allocation and deallocation
var list = std.ArrayList([]const u8){};
defer list.deinit(allocator); // must remember to clean up
try list.append(allocator, "hello");

// zig 0.15.1 you need to use the same allocator for all allocations and to deallocate

It's a heavyweight solution, in terms of engineering, to a heavyweight problem. But GC isn't magic - you can still get OutOfMemoryErrors if you keep appending data without releasing references. And in Java, you still need to pay attention to GC configuration, even though modern GCs are much more powerful than they used to be and for each new version they continue to improve.

Where GC Excels: Cycles

This is where GC really shines, and where alternatives struggle most. When you have complicated, potentially cyclic referencing where links are picked up and dropped all the time - exactly where you're most likely to have memory leaks with manual or scope-based deallocation.

In Zig, I had to explicitly track allocations and think carefully about when to call allocator.free(). With complex data structures, it's easy to leak memory if you lose track of a pointer:

// Zig: manual cycle handling
const Node = struct {
    data: i32,
    next: ?*Node,
};

const node = try allocator.create(Node);
node.* = .{ .data = 42, .next = null };
// ... later, you must remember:
allocator.destroy(node);
// If nodes reference each other, you need to carefully manage cleanup order

Rust's ownership system famously struggles with cycles too. Anyone who's tried to write a linked list in Rust knows about "Learning Rust With Entirely Too Many Linked Lists" - it's practically a rite of passage. You need Rc<RefCell<>> to create cycles:

// Rust: cycles require reference counting
use std::rc::Rc;
use std::cell::RefCell;

struct Node {
    data: i32,
    next: Option<Rc<RefCell<Node>>>,
}

let node = Rc::new(RefCell::new(Node { data: 42, next: None }));
// complex ownership, potential cycles still leak without careful weak references
// Java: cycles handled automatically
class Node {
    int data;
    Node next;
}

Node node = new Node();
node.data = 42;
// even if nodes form cycles, GC handles cleanup

With reference counting approaches like Swift's ARC or Rust's Rc, you have to think it through super-carefully to avoid leaks. With GC? You hardly have to think at all. The GC tracks reachability from root objects, not individual references, so cycles get cleaned up automatically when the whole structure becomes unreachable.

What GC Doesn't Solve

I learned this the hard way. We had a Java class that processed many large files, and somewhere in the processing pipeline, we weren't closing some of the files properly. The GC handled the memory just fine, but we were leaking file handles. The problem only showed up during peak periods when we processed hundreds of files - suddenly, we couldn't open any more files.

We had to review the code and restructure everything to use try-with-resources, making sure every file was explicitly closed. The GC gave us memory safety, but it didn't save us from resource leaks.

This is where Rust's RAII shines. When a File goes out of scope, it's automatically closed:

// Rust: file closed automatically
{
    let file = File::open("data.txt")?;
    // use file...
} // file closed here automatically
// Java: must explicitly close
try (var file = new FileReader("data.txt")) {
    // use file...
} // file closed by try-with-resources
// Zig: defer schedules cleanup
const file = try std.fs.cwd().openFile("data.txt", .{});
defer file.close(); // you write the cleanup

Zig takes a different approach - it gives you defer to schedule cleanup code, but you're still responsible for thinking about it. GC languages require explicit resource management with try-with-resources or similar patterns. You get mental freedom from memory management, but you still need discipline for everything else.

Coming from Java to Rust and then Zig, I found each step required more explicit thinking about resources. In Java, I barely thought about memory. In Rust, the compiler forces you to think about ownership. In Zig, you're on your own - the flexibility is liberating, but the responsibility is real.

There's a psychological shift that happens along this journey. With Java, I trusted the GC to handle everything. Moving to Rust and then Zig, I had to let go of that trust and take control. At first it felt like a burden. Now? I kind of enjoy the power. There's something satisfying about knowing exactly what your program is doing with memory, about having that level of control.

The overhead is real: more memory usage, runtime tracking costs, and read/write barriers on every pointer access. But GC completely eliminates an entire class of bugs.

Why It Still Matters

Garbage collection goes back to the 1960s with LISP. Interestingly, memory was too limited back then to use reference counting, which adds an extra counter to every object. Java was originally designed for severely memory-constrained environments like set-top boxes.

We have millions of times more memory now, even on single board computers. Increasing RAM and CPU cores make GC overhead less problematic. Modern machines can absorb the memory overhead and background collection work more easily. The overhead that mattered then matters less now.

The Takeaway

GC isn't a mistake. It's the only solution that is at once fully comprehensive and trivially easy for memory management. You don't always want GC because sometimes control is important, but it's a very good idea for general use.

The real limitation isn't that GC exists - it's that having solved the most ubiquitous resource problem, we sometimes forget we still need good ways to solve the other resource problems.


References

  1. McCarthy, John (1960). "Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I." Communications of the ACM, 3(4), 184-195.
    https://doi.org/10.1145/367177.367199

  2. Klabnik, Steve and Nichols, Carol (2023). The Rust Programming Language, 2nd Edition. No Starch Press.
    https://doc.rust-lang.org/book/

  3. Patterson, Andrew (2022). "Zig: A programming language designed for robustness, optimality, and clarity."
    https://ziglang.org/documentation/

  4. Jones, Richard, Hosking, Antony, and Moss, Eliot (2011). The Garbage Collection Handbook: The Art of Automatic Memory Management. Chapman & Hall/CRC. ISBN: 978-1420082791