skip to content
everything.gc.memoryLeaks

memory leaks

yes, java leaks. heap dumps, dominators, and the usual suspects.

chapter
chapter 14.12
reading time
9 min read

a java memory leak is an object you'll never use again that's still reachable, so the GC isn't allowed to free it. the collector works fine. the bug is a reference you forgot about.

the usual suspects: static collections, listeners nobody removes, caches with no limit, ThreadLocals on pool threads, inner classes holding this$0. you find them with a heap dump: look for the object with the biggest retained size in the dominator tree, then read its path back to a GC root.

the picture

you know how people say "java has a garbage collector, so it can't leak"? the GC frees what's unreachable, not what's unused. it has no idea you're done with something. it only knows whether there's still a path to it from a GC root.

think of a storage unit. you moved out of the flat years ago, you'll never open the unit again, but the key is still on your key ring, so the company keeps billing you every month. the GC is the company: it'll happily clear out any unit whose key nobody holds. it will never clear one just because nobody's visited.

what a leak looks like

two real runs, same 256 MB heap, same collector (G1, jdk 25). the healthy one is the toy service from serial and parallel. the leaking one is the same program with one extra line: every 10th request registers a listener, and nothing ever removes it.

healthyService.java
0128256fulldone
leakingLeakyService.java
0128256fullOutOfMemoryError
0s1s2s3s
healthy: collections
357
healthy: done in
1.0 s
leaking: full GCs
210
leaking: died at
3.5 s
heap used, before → after each GCafter a whole-heap GC: the live seta full GC
same x axis for both. the amber line joins the heap left after each whole-heap collection (full GCs, or G1's remarks when there are none), which is the live set. in the healthy run it stays flat. in the leaking one it only goes up, full GCs pile up, and the run dies.

see how the leak announces itself long before the error? the healthy run finished all 3 million requests in about a second, with 274 ms of pauses. the leaking one made it 70% of the way, spent 2.5 of its 3.5 seconds in 210 full GCs that each freed a little less, and then died with OutOfMemoryError: Java heap space. the line that leaked:

LeakyService.java
if (req % 10 == 0) {
    byte[] state = new byte[1024];
    listeners.add(() -> System.out.print(state.length));   // registered, never removed
}

one 16-byte lambda holding one 1 KB array, on every tenth request. nothing about it looks dangerous in a code review. that's what makes leaks annoying: every line is fine, the bug is a missing line somewhere else. how to spot this shape in your own logs is in reading GC logs.

the usual suspects

almost every leak is one of these. each one ends the same way: something long-lived (a static field, a running thread, a singleton) holds a reference to something that should have been short-lived.

  1. 1class Audit {
  2. 2 static final List<Request> seen = new ArrayList<>();
  3. 3
  4. 4 static void record(Request r) {
  5. 5 seen.add(r); // added on every request, removed never
  6. 6 }
  7. 7}
what the heap dump shows: the path from a GC root
static Audit.seenArrayListObject[1_234_567]Request × 1.2M

a static field lives until the class loader dies, which for your app's classes means until the JVM exits. bound it, or don't keep it.

pick a suspect. the chain is what a heap dump shows you: the GC root on the left, every reference in between, and the memory you didn't mean to keep, in red.

you can test for a leak without a heap dump. hold the object through a WeakReference (which doesn't keep it alive), drop your own reference, ask for a GC, and check whether it's gone:

predict the output
java
static final List<Runnable> LISTENERS = new ArrayList<>();
 
record Screen(byte[] pixels) {
    void open() { LISTENERS.add(this::redraw); }   // a bound method reference
    void redraw() { }
}
 
void main() throws InterruptedException {
    Screen screen = new Screen(new byte[10_000_000]);
    screen.open();
    var probe = new WeakReference<>(screen);
    screen = null;
    System.gc();
    Thread.sleep(100);
    IO.println("collected: " + (probe.get() == null));
}

heap dumps

a heap dump is a snapshot of every object in the heap, with every reference between them, written to an .hprof file. three ways to get one:

terminal
# from a running JVM. only live objects: it runs a full GC first (add -all to skip that)
jcmd <pid> GC.heap_dump /tmp/app.hprof
 
# automatically, the moment it dies. set this in production, today.
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps -jar app.jar
 
# no dump at all, just a count per class. cheap, and often enough to know what is piling up
jcmd <pid> GC.class_histogram

here's the histogram of the leaking service, taken while it ran:

jcmd <pid> GC.class_histogram
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:        221464      242317776  [B (java.base@25.0.4.1)
   2:        192934        3086944  LeakyService$$Lambda/0x000007f001041000
   3:          1949        1913360  [Ljdk.internal.vm.FillerElement; (java.base@25.0.4.1)
   4:          1070        1096024  [Ljava.lang.Object; (java.base@25.0.4.1)
   5:         20000         800000  java.util.LinkedHashMap$Entry (java.base@25.0.4.1)

[B is byte[]: 231 MB of them. and 192,934 lambdas from LeakyService, which is a hint, since one class of lambda shouldn't have that many live instances. (the FillerElement arrays are padding G1 puts into unused corners of regions. ignore them.) but a histogram can't say who holds those arrays. the cache holds 20,000 of them too. for that you need the graph.

and the automatic dump, when the leaking run finally died:

text
java.lang.OutOfMemoryError: Java heap space
Dumping heap to leak.hprof ...
Heap dump file created [275784926 bytes in 0.083 secs]

shallow, retained, and the dominator tree

two numbers for every object in a heap dump:

shallow size
the object itself: header plus fields. an ArrayList is 24 bytes no matter how much is in it.
retained size
everything that would become garbage if this one object disappeared: itself, plus everything reachable only through it. that's the number that finds leaks.

the tool that computes retained sizes is the dominator tree. object A dominates B if every path from a GC root to B goes through A. so A's retained size is the sum of everything it dominates.

main holds config =
static listenerslocal configArrayList24 BObject[10]56 BListener24 BListener24 BListener24 Bbyte[1024]1,040 Bbyte[1024]1,040 Bbyte[1024]1,040 BConfig16 Bbyte[4096]4,112 B
Object[10] · click any object
shallow
56 B
retained
3,248 B

if nothing pointed at this Object[10] any more, these 7 objects would become garbage with it.

dominator tree · retained size
click an object to highlight what it retains. Config is reachable from the list and from a local variable, so neither one retains it. switch the local off and watch the list suddenly own the config and its 4 KB array.

that's the whole trick of leak hunting: sort the dominator tree by retained size, and the leak floats to the top. i read the leaking service's dump with a small script that walks the same graph:

objectshallowretained
LeakyService.listeners (an ArrayList)24 B212.9 MB, 421,050 objects
LeakyService.cache (the LinkedHashMap)64 B40.6 MB, 59,970 objects
everything live254.5 MB

a 24-byte list retaining 84% of the heap. that's the answer, and its path to a root (a static field of LeakyService) tells you exactly which line to go and fix. real tools do this with a few clicks: Eclipse MAT (the "leak suspects" report is literally this), VisualVM, or your IDE's profiler.

one level deeper:finding leaks without a heap dump: JFR's old object sample+

flight recorder can sample allocations and keep track of the ones that stay alive for a long time (jdk.OldObjectSample, on in the default settings). dump the recording with jcmd <pid> JFR.dump plus path-to-gc-roots=true and each surviving sample comes with its path to a GC root. it's a sample, so it won't find every leak, but it's cheap enough to leave on in production, and it never writes your whole heap to disk.

keylines
  • the GC frees unreachable objects, not unused ones. a leak is a reference you forgot.
  • the shape: the heap left after full GCs keeps rising, full GCs pile up, then OOM.
  • suspects: statics, listeners, unbounded caches, pooled ThreadLocals, this$0, mutable keys.
  • always run with -XX:+HeapDumpOnOutOfMemoryError. a histogram says what, a dump says who.
  • retained size is what goes away with an object. sort the dominator tree by it.