Lifetime and memory6 min · 13 of 16

Ownership, RAII and garbage collection

Choose between scope-bound release and a collector, read unique_ptr against shared_ptr as an ownership statement, and find the leak a GC cannot fix.

"When is this freed?" has two answers in mainstream languages, and they are not variations on one idea. One binds the object's lifetime to a scope, so release happens at a line you can point to. The other binds it to reachability and hands the timing to a collector, so release happens at a moment nobody can point to.

Both work. They fail differently, and the failure that costs production teams their afternoons belongs to neither — it belongs to references nobody removed.

Scope: RAII makes release a property of the type

In C++ the destructor is the release, so acquisition in the constructor pairs with release at the closing brace:

void write_report(const std::string& path) {
    std::ofstream out(path);              // acquire: open
    std::lock_guard<std::mutex> g(mu);    // acquire: lock
    out << render();                      // may throw
}                                          // g unlocks, out closes — either path

If render() throws, stack unwinding destroys every fully constructed object in reverse order, so the lock is released and the file closed with no catch block and no finally. That is the actual content of RAII: the type knows how to release itself, so no caller can forget. Java and Python invert this — the type can only announce that it is closeable, and every caller must remember to write with or try-with-resources (which is why that scope matters).

Ownership made explicit

A raw pointer says nothing about who frees the thing. The smart pointers exist to put that in the type signature:

  • std::unique_ptr<T> — exactly one owner. Move-only, so passing it by value is the transfer of ownership, visible at the call site. It costs the size of a pointer and no atomics.
  • std::shared_ptr<T> — shared ownership, freed when the last owner goes. It carries a control block with an atomic strong count and weak count; every copy is an atomic increment. Uncontended that is a few nanoseconds, but when two cores copy the same pointer the cache line moves between them, which is the order of a main memory reference, ~100 ns.
  • std::weak_ptr<T> — observes without owning; you call lock() and get either a shared_ptr or nothing.

weak_ptr exists because reference counting cannot break cycles. Two objects holding shared_ptr to each other never reach zero and are never freed, and C++ ships no collector to notice. A parent owns its children with unique_ptr or shared_ptr; the child points back with weak_ptr.

Default to a value member, then unique_ptr. Reach for shared_ptr only when the lifetime has no single owner — caches and observer lists, mostly. "I made it shared_ptr because I was not sure who frees it" encodes the confusion instead of resolving it.

Reachability: what Python and Java actually do

Python counts references. Every object carries a count; when it reaches zero the object is freed immediately, which is why CPython so often looks deterministic. Cycles defeat the count, so a second mechanism sits behind it: a generational cycle collector over container objects. Generation 0 runs once allocations minus deallocations pass roughly 700, generation 1 after ten generation-0 passes, generation 2 after ten of those. A cycle's memory does come back — just not at a time your code chose.

Java counts nothing. The collector starts from roots — stack frames, static fields, JNI handles — marks everything reachable, and treats the rest as free space. Its cost is proportional to live data, not to garbage, which is the counter-intuitive part: allocating a million objects that all die young costs almost nothing to collect. That is the generational hypothesis, and it is why the young generation is collected often by copying the few survivors, while the old generation is collected rarely and expensively. G1's default pause target is 200 ms — worth holding against the 0.5 ms datacenter round trip and against your own p99 budget, because a pause lands on whatever request was in flight.

The leak you actually get

In a collected language you do not leak memory the program cannot reach. You leak memory it can. The classic shape is a registry that outlives the things registered in it:

class EventBus {
    private static final List<Consumer<Event>> listeners = new ArrayList<>();
    static void register(Consumer<Event> l) { listeners.add(l); }
}

class Session implements AutoCloseable {
    private final byte[] buffer = new byte[50_000];      // ~50 KB
    private final Socket socket;

    Session(Socket socket) {
        this.socket = socket;
        EventBus.register(this::onEvent);                // captures this
    }

    void onEvent(Event e) { /* ... */ }

    public void close() throws IOException {
        socket.close();                                  // removes no reference
    }
}
class EventBus:
    listeners = []

    @classmethod
    def register(cls, fn):
        cls.listeners.append(fn)

class Session:
    def __init__(self):
        self.buffer = bytearray(50_000)
        EventBus.register(self.on_event)   # bound method holds a strong ref to self

self.on_event is a bound method object, and it holds self. In Java this::onEvent captures this the same way. Both sessions are closed, both are unreachable from the request that made them, and both are pinned by a static field, which is a GC root.

Closing a resource is not dropping a reference. Reachable is reachable, and the collector is doing its job.
A static listener registry keeps a closed session reachable from a GC root, so the collector will not free itreachesholdsstrongrefdroppedGC rootstatic fieldEventBus.listenersListenercaptures selfSessionclosed · 50 KBRequest scopeclose() freed the socket. It removed noreference, so nothing here is collected.

Scroll to zoom · drag to pan · 0 fits · Esc closes

The arithmetic decides how long you have. At 1 million sessions/day, 1,000,000 ÷ 86,400 ≈ 12 sessions/second, each retaining 50 KB: 12 × 50 KB = 600 KB/s, and 600 KB/s × 3,600 = about 2.16 GB/hour. A 4 GB heap falls over in 4 ÷ 2.16 ≈ 1.9 hours; a 32 GB heap lasts 32 ÷ 2.16 ≈ 15 hours, so it survives the working day and dies overnight, which is why the same bug gets reported as "the nightly job breaks it". The diagnosis is identical in both cases: count live instances of one class over time — jmap -histo:live on the JVM, tracemalloc or gc.get_objects() in Python. A class whose live count only rises is the leak.

Two fixes. Unregister where you release everything else — in close() or __exit__ — so the reference follows the same scope rule as the socket. Or hold the listener weakly: WeakReference and WeakHashMap in Java, weakref.WeakSet and weakref.WeakMethod in Python, weak_ptr in C++. Weak references have their own edge — a weak reference to a lambda nothing else holds dies immediately, and a WeakHashMap whose value refers to its own key pins that key forever, because the value side is strong.

In an interview

The question arrives as "can a garbage-collected language leak?" Answer both halves: not memory that has become unreachable, but very much memory that has not, and then name the three usual holders — static collections and registries, caches with no eviction, and listeners nobody removed.

Say the C++ half too — it shows you read these as two designs rather than an old one and a modern one: "RAII removes the timing question. The destructor runs at the closing brace, on the exception path as well; it buys determinism and costs me cycles, which shared_ptr leaks because no collector is watching."

The mistake that loses points: calling shared_ptr garbage collection. It is reference counting with no cycle collector — strictly weaker than CPython, which needed a second mechanism for exactly that reason.

Check yourself

A texture cache hands out textures that renderers may keep after the cache entry is evicted. unique_ptr in the cache, or shared_ptr?

shared_ptr, because the lifetime genuinely has more than one owner and the last one out should free it. If renderers can never outlive the cache, keep unique_ptr in the cache and hand out a raw pointer or reference — non-owning by construction, and cheaper.

A service creates 1 million sessions a day, each retaining 50 KB through a listener nobody removes. How long does a 4 GB heap last?

1,000,000 ÷ 86,400 ≈ 12 sessions/s; 12 × 50 KB = 600 KB/s ≈ 2.16 GB/hour, so roughly 1.9 hours before 4 GB is exhausted — allowing nothing for the rest of the program, so in practice sooner.

Two objects reference each other and nothing else references either. Freed in Python? Freed in C++ with shared_ptr?

Python: yes. Reference counts never reach zero, but the generational cycle collector finds the cycle — later than you might expect, and finaliser order within the cycle is arbitrary. C++: never. The counts never reach zero and nothing else is watching; one of the two links has to be a weak_ptr.