Skip to the content
Software Made Clear Diagrams that show the mechanism About

Memory leaks

ANSWER

Two different failures share the name. One is memory nothing points at any more, which a garbage collector genuinely fixes. The other is memory something still points at and nobody wants, which no collector can touch — and that is the one that takes production down.

IN PLAIN TERMS

You keep putting things into a cupboard and never take anything out. Some of it you have forgotten you own and cannot find. The rest you could reach any time you liked — you simply never decide to throw it away. A tidier who works while you sleep bins what nobody can reach, but never what still looks like yours.

“Memory leak” names two failures that behave differently, are found differently, and are fixed differently. Treating them as one thing is why the advice on the subject seems to contradict itself — half of it says a garbage collector solves this and the other half says collected languages leak constantly, and both are right about the failure they have in mind. The clearest way to separate them is to borrow the distinction a leak checker already has to make.

Two different failures share the name#

Valgrind’s Memcheck has to classify every block still allocated when a program ends, and the categories it uses are the distinction this whole subject needs. A block is definitely lost when, in the manual’s own words, no pointer to the block can be found — the program could not have freed it at exit, because nothing was left holding its address. A block is indirectly lost when it is only reachable through blocks that are themselves lost, which is what a leaked tree or list looks like: one dropped pointer at the root, and everything beneath it goes with it.

Then there is the category people skip past. A block is still reachable when a pointer chain to it was found, so — again in the manual’s words — the programmer could at least in principle have freed it before exit. The memory was never returned, and it was never unreachable either. Nothing was dropped. The program simply kept hold of it.

That is the line worth carrying around, and it is a line about reachability rather than about intent. On one side is memory the program has lost the ability to free — stranded on the heap, where the program rather than the language decides when something ends. On the other is memory the program can still reach perfectly well and has no further use for. Both grow, both eventually exhaust the machine, and only one of them is a mistake a collector is able to see.

Why a garbage collector cannot save you#

A garbage collector frees what is unreachable. That is not a summary of what it does — it is the definition it works from, and it is the reason it can run safely at all: if nothing can reach an object, nothing can notice it being taken away. So the entire definitely lost category stops existing in a collected language. You cannot drop the last pointer to an object and strand it, because dropping the last pointer is precisely the signal that the object may be collected.

Run that same definition against the other category and it gives you nothing. An entry in a map you keep appending to is reachable. A listener registered against a long-lived object is reachable. A cache with no eviction policy is reachable, deliberately, by design. Every one of those is doing exactly what the collector requires of a live object, and the collector is correct to keep it — the program said, in the only language a collector reads, that this memory is still wanted.

This is why “we moved to a managed runtime so we no longer have leaks” is half a sentence. The half that is true is real and worth having. The half that is missing is that the remaining category is the one that scales with uptime, which is exactly the property that makes a leak dangerous in a service rather than a nuisance in a script.

Where it goes wrong#

The characteristic production failure is a collection that only ever grows. A map keyed by session, or request, or connection, with an insert on the way in and no removal on the way out. It is not visible in review, because the insert looks correct and the missing removal is not on the screen. It is not visible in tests, because a leak is measured in repetitions and a test suite makes a few hundred. It becomes visible weeks later as a service that needs restarting on a schedule nobody wrote down, which is the industry’s most common way of living with a leak instead of finding it.

The second is diagnosing from a single measurement. Memory that climbs and then flattens is usually a working set filling, or an allocator holding freed pages instead of returning them to the operating system — from outside the process those look the same as a leak and are not one. The shape over time under steady load is the evidence; one heap reading is not. A leak does not flatten.

The third is worth naming because it inverts the usual advice: at process exit, still-reachable memory is frequently harmless. The process is ending, the operating system reclaims everything, and freeing it first buys nothing but shutdown latency. That is why Memcheck reports the category separately rather than counting it with the losses. The same pattern in a process that never exits is the whole problem — which means the question is never just “was this freed”, but “how long is this program going to run”. A long-lived service is a different program from the same code invoked once, in the way a background process that has to keep up forever is a different proposition from one that has to finish.

IF YOU REMEMBER ONE THING

A collector frees what nothing can reach. The leaks that survive it are the ones your program can still reach and no longer wants — which is not a memory bug the runtime can find for you, but a decision about lifetime that only the code can state.

Questions people also ask

5 QUESTIONS
What is the difference between a leak and high memory usage?

A leak grows without bound in proportion to work done; high usage sits at a level and stays there. A cache holding a gigabyte is not leaking if it stops at a gigabyte, however alarming the number looks. The test is the shape over time under steady load, not the size at any one moment — which is why a single heap reading almost never settles the question.

Can a garbage-collected language leak memory?

Routinely, and it is the more common case in practice. A collector frees what nothing references, so it removes exactly the class of leak where the pointer has been lost. It has nothing to say about objects your program still references and no longer needs — a growing map, a listener never unregistered, a cache with no eviction. Those are live by every definition the collector uses.

What does Valgrind's "still reachable" actually mean?

That a pointer chain to the block was found, so the program could in principle have freed it before exiting. Memcheck reports it separately from "definitely lost", where no pointer to the block exists at all. The separation is the useful part: still-reachable memory at exit is often harmless, because the process is ending anyway, while the same pattern in a long-running server is the leak that matters.

Why does the leak only show up in production?

Because leaks are measured in uptime and repetitions, not in correctness. A handler that retains a few kilobytes per request is invisible across the hundred requests a test suite makes and fatal across the ten million a service handles before its next deploy. Nothing about the code is different — only how many times it ran.

Do I have a leak if memory goes up and then flattens?

Almost certainly not. Growth that flattens is a working set filling up, or an allocator holding freed pages rather than returning them to the operating system, which looks identical from outside the process. A leak does not flatten. If the line is still climbing after the workload has been steady for hours, that is the signal worth chasing.