Anatomy of a memory leak

An unbounded cache looks harmless until the old generation fills up. Flip on the fix and watch the pauses disappear.

Runs on
Your browser, and the JVM behind this site
Evidence for
Performance profiling and JVM tuning
Loading the experiment…This experiment runs in JavaScript, which is turned off. The notes below describe what it shows.

What you’re seeing

The application allocates into Eden — blue for request data, orange for cache entries — and most of it dies young: by the time Eden is full, the older part has already turned grey. That is the weak generational hypothesis, and it is why a minor collection stays cheap: it copies the few live bytes into a survivor space and forgets the rest, in a few milliseconds.

Entries in an unbounded map never die. They survive every minor collection, get promoted and pile up in the old generation; each major collection clears the garbage around them but not them, so the floor it leaves behind keeps rising. Marking cost grows with that live set: the first major pause here takes about 90 ms, the last ones about 300 ms, back to back — until a promotion no longer fits. On a 512 MB heap that happens after about 70 simulated seconds. A 1 GB heap lasts twice as long, and its first major pause is several times longer: more memory postpones a leak, it never fixes one.

On a real JVM

Figure 2 runs a leak of the same shape in a JVM of its own, scaled down to a 64 MB heap so that it ends in seconds. Recorded on the production image, it ran out of memory after 13.6 s under Serial, 14.2 s under G1 and 13.4 s under ZGC. No collector can free what is still reachable: the leak sets the deadline, not the collector.

What differs is how the end is spent. Serial and G1 stop the application more and more often as the old generation fills, 568 ms and 813 ms of pauses in all. ZGC hardly pauses, 2,420 times for 10 ms together, but the application waited 139 times for memory the collector had not yet freed, 607 ms in all: a concurrent collector turns the same problem into stalls. With the fix, all three ran their twenty seconds, and every full collection took Serial’s heap back to 6–8 MB.

Where it meets my work

Performance profiling and JVM tuning are on my CV. A leak is where the two meet: the pauses show up in the profile, and only the heap shows their cause. The numbers in the figure come from the model and from the JVM behind this site, not from a production system.

Why weak references and a TTL

A WeakReference does not keep its referent alive: once no request holds a value any more, the next collection may clear it — an entry lives only as long as something else still uses it — and the TTL clears the stale map entries left behind. Switch the policy to Weak + TTL forty seconds into the leak and the next major GC takes the floor from about 200 MB down to a few MB. The old generation still fills up with data promoted too early, but with almost nothing live in it a major GC takes about 10 ms: pauses follow the live set, not how full the heap is.

A strong map with only a TTL is bounded too, but it keeps every entry alive for the whole TTL, used or not, so it settles at roughly cache rate × TTL (Little’s law) — about 125 MB of live data here, with major pauses around 90 ms. Until it levels off, its growth looks exactly like a leak.

Two general notes: a SoftReference is cleared only under memory pressure, which makes a cache built on it hard to predict; and the usual modern choice is a bounded cache with size- or time-based eviction. Either way, something has to decide when an entry may go.

A cohort model, not an object graph: bytes are tracked in batches by allocation time and reachability is a function of age, on a clock that stops during pauses. Sizing follows HotSpot’s classic generational defaults — the young generation is a third of the heap, survivor ratio 8, adaptive tenuring up to 15 — and a stop-the-world major collection starts when the old generation is 75% full. Real G1 divides the heap into regions, marks concurrently and reclaims old regions in mixed collections, so its pauses are shorter; the pause coefficients here are illustrative, not measurements of a real JVM, which Figure 2 makes. The trend is what transfers. Time is compressed: a production leak takes hours or days, not a minute. Time to OOM fits a line through the live set measured at each collection — the model knows reachability exactly, where a real JVM learns it only from a full marking.

The code behind it

The files that do the work, by their place in this site’s repository.

  • web/src/lab/demos/memory-leak/
    • engine.tsThe generational heap: Eden, survivors, promotion, minor and major collections
  • api/src/main/java/com/aghazaryan/api/lab/leak/
    • LeakWorkload.javaFigure 2’s workload, the leak and the fix, run in a JVM of its own
    • ChildJvm.javaStarts that JVM: the chosen collector, a 64 MB heap, its GC log on standard output
    • ChildLog.javaReads the log line by line: heap readings, stopped time, how the run ended
    • ZLogParser.javaZGC’s log: concurrent collections, pauses in microseconds, stalls
    • LeakRun.javaStreams a run, and kills the JVM when the reader leaves
  • api/src/main/java/com/aghazaryan/api/telemetry/
    • TelemetryStreams.javaThe live JVM panel: one sample a second from the real service