Memory leaks and OOM
A healthy sawtooth against an old-gen floor that only rises, the class histogram that points at the map, the heap dump — and leak versus undersized heap versus OOMKilled.
A Java service does not "run out of memory" suddenly. It runs out slowly, for hours or days, and then fails all at once — often at night, often after someone has already restarted it twice. Diagnosing it means answering one question early: is this a leak, or is the heap simply too small for the work? They look identical at the moment of failure and need opposite fixes.
This lesson runs both on a JVM with a 128 MB heap and G1, and reads them with the tools that ship with the JDK.
What a healthy heap looks like
jstat -gcutil prints how full each area of the heap is, sampled at an interval. The column that matters most is O, the old generation — where objects that survive several collections end up. A service that keeps a bounded working set, sampled every two seconds:
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
- 100.00 54.17 55.72 86.18 48.75 4 0.028 0 0.000 0 0.000 0.028
- 100.00 12.50 59.09 86.77 48.75 26 0.094 0 0.000 14 0.008 0.102
- 100.00 50.00 35.56 86.77 48.75 54 0.170 0 0.000 34 0.018 0.188
- 100.00 62.50 63.64 80.86 48.75 80 0.267 0 0.000 50 0.028 0.295
- 100.00 36.36 46.67 80.86 48.75 106 0.347 0 0.000 68 0.045 0.392
- 100.00 54.55 47.78 80.86 48.75 130 0.414 0 0.000 84 0.063 0.476
- 100.00 9.09 53.06 80.86 48.75 154 0.506 0 0.000 100 0.072 0.578
- 100.00 50.00 40.38 80.86 48.75 177 0.585 0 0.000 116 0.079 0.664The old generation goes up and down — 55%, 59%, 35%, 63%, 46% — because concurrent collections (the CGC column) keep reclaiming it. Young collections (YGC) happen constantly and cost almost nothing: 177 of them in sixteen seconds took 0.585 seconds in total. FGC, full collections, stays at zero. That is a sawtooth with a flat floor, and it is what healthy looks like even when the heap is busy.
What a leak looks like
Now the leaking version: a "quote cache" that stores a price calculation for every request, keyed by a request id that is never seen again. About a thousand requests a second, each leaving roughly 2 KB behind:
static final Map<String, PriceQuote> quoteCache = new HashMap<>();
quoteCache.put(requestId, q); // grows foreverSampled every five seconds, with a timestamp (a selection of the samples):
Timestamp S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
11.1 - 100.00 6.67 17.48 80.64 48.75 1 0.011 0 0.000 0 0.000 0.011
21.1 - 100.00 33.33 33.05 80.66 48.75 2 0.017 0 0.000 0 0.000 0.017
31.1 - - 37.50 50.00 80.66 48.75 2 0.017 1 0.015 0 0.000 0.032
41.1 - 100.00 47.06 65.57 80.66 48.75 3 0.027 1 0.015 0 0.000 0.042
51.1 - 100.00 33.33 82.05 79.68 46.77 5 0.048 1 0.015 2 0.003 0.066
56.2 - 100.00 0.00 93.16 79.68 46.77 7 0.058 1 0.015 4 0.004 0.077
61.1 - - 0.00 97.18 79.68 46.77 9 0.068 2 0.035 4 0.004 0.107
66.1 - - 0.00 99.95 79.68 46.77 12 0.079 5 0.134 4 0.004 0.217The old generation only goes up: 17%, 33%, 50%, 66%, 82%, 93%, 97%, 99.95%. Near the end, full collections start — FGC climbs from 1 to 5 in five seconds — and each one reclaims nothing, because everything in the old generation is still reachable from the map. Then:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /tmp/leak.hprof ...
Heap dump file created [140760960 bytes in 0.148 secs]
Terminating due to java.lang.OutOfMemoryError: Java heap spaceThe signature of a leak is the floor, not the peak: the old generation's lowest point after each collection rises over time. A busy but healthy service can reach 90% between collections. A leaking one has a floor that never comes back down.
In production you will see this on a dashboard as jvm.memory.used for the old gen region, or heap after GC, trending up across hours — and the time between full GCs shrinking as it approaches the limit.
Which objects: the class histogram
jcmd <pid> GC.class_histogram counts live objects by class. It is fast, and it can run on a live service. Taken at about 25 seconds into the leaking run:
num #instances #bytes class name (module)
-------------------------------------------------------
1: 78357 49514760 [B (java.base@21.0.12)
2: 55371 1328904 java.lang.String (java.base@21.0.12)
3: 24066 770112 java.util.HashMap$Node (java.base@21.0.12)
4: 22900 732800 LeakyService$PriceQuoteThe top line is almost always [B — byte arrays — or String, and on its own says little: everything contains byte arrays. The useful lines are the application classes, and the counts that match. There are 22,900 PriceQuote objects and 24,066 HashMap$Node objects: nearly one map entry per quote. Something is putting every quote into a map. Take the histogram twice, a few minutes apart, and the classes whose counts only grow are the suspects.
Why they are alive: the heap dump
The histogram says what is leaking. A heap dump says what is holding it. -XX:+HeapDumpOnOutOfMemoryError writes one automatically at the moment of failure — 140 MB here — and every production JVM should run with it, with -XX:HeapDumpPath pointing at a disk with room, because an OOM without a dump is an OOM you will have to reproduce.
Open the dump in Eclipse MAT or VisualVM and look at the dominator tree or the path to GC roots for the suspect class. For this leak it ends in a short chain: a static field, LeakyService.quoteCache, holding a HashMap, holding every PriceQuote. A static field is a GC root; nothing reachable from it is ever collected.
Heap dumps contain everything in memory — tokens, personal data, whatever the application held. Treat them like a database backup: restricted access, deleted when done.
The usual culprits
Most real leaks are one of a short list:
- A cache with no bound or expiry — a
HashMap"just for now", keyed by something unbounded: request ids, session ids, user-generated strings. Use a cache library with a maximum size and expiry, such as Caffeine. - A
ThreadLocalon pooled threads that is set and never removed. The pool threads live forever, and so does the value — the MDC lesson'sfinally { remove() }is for this. - Listeners and callbacks registered and never unregistered, keeping their whole object graph alive.
- Collections that are appended to and never trimmed — a list of recent events, an in-memory audit trail, metrics tagged with ids.
- Class loader leaks in application servers that redeploy without restarting, each deploy leaving the previous version's classes in Metaspace — the error then says
Metaspace, notJava heap space.
Leak, or undersized heap?
| leak | undersized heap | |
|---|---|---|
| old gen floor after GC | rises steadily over time | stable, but high |
| failure timing | after hours or days, roughly predictable | at a load peak or a large request |
| restart | buys time until the same point | fixes nothing until the next peak |
| histogram over time | one or two classes grow without bound | proportions stay stable |
| fix | find the reference and remove it | more heap, or less memory per request |
Raising -Xmx on a leak only moves the failure later — which can be useful as a mitigation while the real fix ships, as long as everyone knows that is what it is.
Two OOMs that are not the heap
- The container killed the process. Kubernetes reports
OOMKilled, the exit code is 137, and there is noOutOfMemoryErrorin the logs and no heap dump — because the JVM never ran out of heap. The process exceeded the container's memory limit: heap plus Metaspace, thread stacks, direct buffers, and the JVM's own memory. Size-Xmx(or-XX:MaxRAMPercentage) well below the container limit — leaving a quarter or more for non-heap is a common starting point. unable to create native threadis not a heap problem either: the process hit an operating-system limit on threads or memory for thread stacks. It usually means a thread pool without a bound.
Verifying the fix
A leak fix is verified the same way it was found: run the service under realistic load for longer than it used to take to fail, and watch the old-generation floor. Flat after hours is the evidence. "It has not crashed since the deploy" is not, when the leak used to take three days.