The work finished an hour ago. Nothing is running. And 113 MB just sits there.
Restarting fixes it. Which means nothing is fixed. Here is the missing picture, with real numbers from my machine.
You cannot free memory. At all.
In Java there is no delete and no free. Your code can only do one thing: cut arrows.
int[] data = new int[50_000_000]; // ~190 MB, one arrow to it
data = null; // you cut YOUR arrow
Enter fullscreen mode Exit fullscreen mode
Cut the last arrow and the garbage collector takes the object. But watch the refusal:
static List<int[]> keep = new ArrayList<>();
int[] data = new int[50_000_000];
keep.add(data); // a second arrow, from a static field
data = null; // your arrow is gone...
// memory: 195 MB. Nothing left. The static list still points.
Enter fullscreen mode Exit fullscreen mode
The collector has one law, and it is not the one we wish it had. It never asks “will you use this again?” It only asks “can you reach it?” Anything reachable from a root (your running code’s variables, anything static) is kept. Every byte of it.
Reachable = alive.
The collector never visits the dead
The walk works like this: stop the world, mark everything reachable from the roots, carry the few survivors out to a clean region, wipe the old region in one sweep. The dead are never even looked at.
That inverts the cost model most of us carry. Two runs from my machine (-Xlog:gc):
- 150+ MB of pure garbage: cleared in about 0.6 ms.
- 6 million objects still alive: 29 ms.
The collector charges you for survivors, not for garbage. Churn is cheap. The expensive thing is keeping objects alive that should have died.
So what is a leak, exactly?
Not memory the collector missed. In a GC language that basically does not happen.
A leak = reachable, but useless. You can reach it. You will never use it. The collector is doing its job perfectly. That is the problem.
The classic shape:
static Map<String, Session> cache = new HashMap<>();
void handle(Request req) {
cache.put(req.id, new Session(req)); // in...
// ...and nothing ever removes them
}
Enter fullscreen mode Exit fullscreen mode
Every session is reachable from a static root forever. The fix is one line: cache.remove(id) when the work is done, or give the cache a limit (max size or TTL). An unbounded cache is a leak with good intentions.
Small isn’t small: the arrow you never wrote
The sneaky version. I kept three tiny summary objects, a few int fields each. They weighed 69 MB.
Each summary was born inside a 20 MB report, as a non-static inner class. The compiler quietly writes a field you never wrote:
$ javap -p 'Server$Report$Summary'
...
final Server$Report this$0; // the hidden arrow to the parent
Enter fullscreen mode Exit fullscreen mode
Keep the tiny summary, and you keep the 20 MB report stapled behind it. Three tiny notes, three huge reports.
Fix: make the inner class static, and copy the value out. Keep the value, not the arrow.
(JavaScript does the same thing with closures: a small callback captures the scope it was born in and keeps a huge object alive. Same disease, different syntax.)
The death
I capped the heap so we could watch: -Xmx256m, ~5 MB per leaked session, nothing ever removed. The collector ran again and again and got nothing back, because everything was still reachable. The memory climbed in steps, hit the cap, and:
died at request #42: java.lang.OutOfMemoryError: Java heap space
Enter fullscreen mode Exit fullscreen mode
Last episode the stack killed a program at 49,394 frames. This time the heap did it at request #42. On a real server this death takes days, not seconds, which is why leaks feel like ghosts.
The rule
A leak is an arrow you forgot to cut. Cut it, or give it a limit.
Honesty notes: the request counter in the video is an animation; the death is measured (request #42 on my machine, Java 21, and it moves with heap cap and object size). The GC lines above are real -Xlog:gc output from my runs. And “my process memory did not shrink in Activity Monitor” is usually NOT a leak: the JVM keeps freed heap for reuse. Judge with the after-GC number, not the OS process size.
The full video draws the whole thing frame by frame, including the walk (mark, carry, wipe) and the moment the server dies:
Which one have you actually shipped: the unbounded cache, the forgotten listener, or the inner class you did not know was inner?
답글 남기기