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 calllock()and get either ashared_ptror 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.
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, keepunique_ptrin 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.