Skip to content

Java Memory Leak Patterns: Statics, Listeners, ThreadLocals

Static maps, forgotten listeners, ThreadLocals in pools, inner classes holding outer ones: the Java memory leak patterns I keep finding, with dump signatures.

outofmemoryerror memory-leaks troubleshooting
Gopi Gorantala
Reading Progress

On This Page

You've already restarted it. You've probably doubled -Xmx too, and the graph came back with the same slope, only starting from a higher floor. Everyone does both, in that order, and neither is a diagnosis.

Here's the part nobody tells you: Java memory leaks are boring. In every dump I've opened, the cause was one of maybe seven shapes, and each shape leaves a fingerprint you can read in a class histogram without any tool fancier than jcmd. This post is the catalogue. For each pattern I'll show the code that does it, the lines you'll see in the dump, and the fix. All the numbers are from a lab I ran for this post, and where a pattern turned out not to leak the way the internet says it does, I'll say so. A few of them didn't.

What a leak looks like from the outside

Heap after GC climbs and never comes back down. That's the whole symptom. If you haven't confirmed that yet, stop and read the heap-dump workflow first, because a heap that's merely large and a heap that's growing need opposite fixes. The error you eventually get is java.lang.OutOfMemoryError: Java heap space, covered in the OutOfMemoryError map and, in a looser way, in an older post here.

A leak in Java isn't memory the JVM forgot. It's memory your program can still reach and will never use again. The collector is doing its job. Something is holding a reference, and the whole catalogue below is a list of the places references like to hide.

How to use this list when you're mid-incident

Take a class histogram. Look at the top rows for something that has a suspiciously round number of instances, or a count that matches the number of requests you've served. Match what you see to a signature below. Then find the thing holding it.

jcmd <pid> GC.class_histogram | head -15     # live objects only: it forces a full GC first

Say that again, because it matters: GC.class_histogram runs a full GC before counting (the lab log shows Pause Full (Heap Inspection Initiated GC)), so it only counts what's still reachable. That's exactly what you want for leak hunting, and exactly what you don't want if you're trying to see allocation churn. Add -all to include unreachable objects.

I used histograms for everything below rather than a full dump analysis. For the last step, the path from the object to the thing holding it, you want Eclipse MAT's "path to GC roots", which I haven't run for this post; I'll describe what it shows rather than paste output I don't have. A deeper walkthrough is coming in the MAT article.

Seven leaks in two small programs

Pinned setup: OpenJDK 21.0.12.1, G1, -Xmx512m, Linux x86_64, 2 cores and 8 GB, no container (I had no Docker daemon in my sandbox, so run yours in a container with a memory limit if you're on a shared box). Single file, java/javac only. Six modes live in Lab.java; the finalizer case is a second tiny file, Fin.java.

The program is a stand-in for a WebSocket gateway: every "connection" makes a Session holding a 16 KB buffer. A mode argument picks which leak to commit, and a count says how many connections to simulate. After the loop it sleeps, so you can attach.

static final Map<String, Session> SESSIONS = new HashMap<>();
static final List<Consumer<String>> LISTENERS = new CopyOnWriteArrayList<>();
static final ThreadLocal<byte[]> SCRATCH = new ThreadLocal<>();
static final Map<Key, Session> BY_KEY = new HashMap<>();
static final List<Runnable> TASKS = new ArrayList<>();

case "static"     -> SESSIONS.put(id, new Session(id));                 // never removed on close
case "listener"   -> { Session s = new Session("l" + i);
                       LISTENERS.add(m -> s.id.length()); }             // lambda captures s; never unregistered
case "threadlocal"-> pool.submit(() -> { byte[] b = SCRATCH.get();
                       if (b == null || b.length < need) SCRATCH.set(new byte[need]); }); // grows, no remove()
case "inner2"     -> TASKS.add(new Lab().new UsingCallback());          // inner class that reads outer state
case "mutablekey" -> { Key k = new Key("k" + i); BY_KEY.put(k, new Session("m" + i));
                       k.name = k.name + "x"; BY_KEY.remove(k); }       // key changed, remove misses
case "fixed"      -> { SESSIONS.put(id, s); SESSIONS.remove(id); }      // control

Run each mode, wait for the loop to finish, then ask for the histogram:

javac Lab.java
java -Xmx512m Lab static 10000 6000 &        # 10,000 sessions, then sleep 6 s
sleep 5; jcmd $(pgrep -f '^java.*Lab static') GC.class_histogram | head -8

You land on this, for static:

 num     #instances         #bytes  class name (module)
   1:         28161      164596872  [B (java.base@21.0.12.1)
   3:         18081         433944  java.lang.String (java.base@21.0.12.1)
   4:         11016         352512  java.util.HashMap$Node (java.base@21.0.12.1)

and a GC log line of Pause Full (Heap Inspection Initiated GC) 161M->160M(363M). Ten thousand sessions at 16 KB is 160 MB, so the arithmetic checks. Your -Xmx does not matter to the shape. If the heap is fixed, the same GC line reads 2M->1M. That's the control, and it's why the control mode exists.

Teardown: nothing on disk. Don't leave a lab running with a large -Xmx on a shared machine.

If you only have a minute

Most heap leaks are a long-lived object (a static field, a singleton, a thread, a listener list) that still points at short-lived ones. Find the long-lived object, find who adds to it, and find why nobody takes things out. The dump tells you the first of those in one command.

The catalogue, one fingerprint each

Static collections

The classic. A static Map or a singleton's List that gets put on every request and has no code path that removes. In my lab: 10,000 HashMap$Node entries (11,016 including the JDK's own) and 164 MB of byte[], 160M after a forced GC. Fingerprint: instance counts that track request counts, and in MAT one collection owning most of the retained heap. This is common enough that it gets its own article on unbounded caches. Fix: bound it, or tie removal to the same event that created the entry.

Listeners and callbacks nobody unregisters

Registering is easy. Unregistering needs the registrant to remember to, on every exit path, including exceptions. Whatever the callback captures is held with it. In the lab, the lambda m -> s.id.length() only reads one field of s, but it captures all of s:

  10000   240000  Lab$Session      <- and the 164 MB of byte[] behind them

Ten thousand Session objects that are not in any map. Nothing visible owns them, which is the fingerprint: live instances of a class you know should be short-lived, and no collection in your own code that you can point to. The path to GC roots goes through the CopyOnWriteArrayList inside the static. Fix: return a handle from register() and make closing a session call it, or hold listeners weakly if the semantics allow.

ThreadLocals in pools

I got this one wrong first, and it taught me more than the version that worked. My first lab stored a 256 KB buffer per task in a ThreadLocal, on a pool of four threads, never calling remove(). Heap after GC: 2 MB. No leak. A fixed pool of four threads can only hold four values, so the "leak" is bounded at 1 MB, and the textbook advice (always call remove()) would have sounded like cargo cult.

Then I changed the thing the ThreadLocal holds. If each thread keeps the largest buffer any request has ever needed, you get a high-water mark that sticks until the thread dies:

   1:          8181      428176648  [B (java.base@21.0.12.1)
   ... Pause Full (Heap Inspection Initiated GC) 414M->413M(512M)

Four threads, up to about 100 MB each, 413 MB live after GC, and 400 requests served. So the rule is: ThreadLocals in pools leak in proportion to thread count times the largest value ever stored, and in classloader-heavy apps they leak the whole loader. The fingerprint is a handful of enormous objects (not millions of small ones) whose root is a Thread through threadLocals. Fix: remove() in a finally, or stop caching per-thread buffers at all. Pool design belongs to the concurrency series.

Inner classes and lambdas that hold the outer object

Every textbook says non-static inner classes keep their enclosing instance alive. On 21.0.12.1 I couldn't make it happen until I read the class file. I had a Callback inner class with an empty run(), queued 10,000 of them against 10,000 separate Lab instances (16 KB each), and the heap after GC was 1 MB.

class Lab$Callback implements Runnable {
  Lab$Callback(Lab);          <- no this$0 field

javac dropped the reference. Then a second inner class whose run() touched an outer field:

class Lab$UsingCallback implements Runnable {
  final Lab this$0;           <- there it is

and the heap after GC was 160 MB (164,356,944 bytes of byte[]). So the old advice is half true now: javac omits the outer reference when the inner class never uses it. I found the change in JBS (JDK-8271623); the same issue has a backout and a serialization follow-up attached, and I did not work out which exact release first shipped the final behaviour, only that 21.0.12.1 does it. If your code is compiled for an older --release, or the inner class is Serializable, don't assume it. Fingerprint: instances of an outer class you expected to be gone, and this$0 in the path to GC roots. Fix: make the class static and pass what it needs.

Keys that change after insertion

BY_KEY.remove(k) returned without complaint, and 10,000 entries stayed. I mutated the key's name after put, so its hash changed and remove looked in the wrong bucket. Same 160 MB, same HashMap$Node signature as the static case; the difference is that this one fools code review, because a remove is right there. Fingerprint: a map whose size keeps growing in a codebase where every put has a visible remove. Fix: immutable keys, and records for composite ones.

String.intern, which mostly isn't the problem

Old advice says interning leaks (it used to live in PermGen). I interned 1.5 million distinct strings and kept none of them:

Number of entries       :     75637

That's the StringTable at exit. The string table holds its entries weakly on JDK 21, so strings nobody references get collected, so 1.5 million went down to 75,637. When I also kept them in a list (kept=1500000), heap after GC was 88 MB, but then the leak is the list, not the intern call. Fingerprint if it is your problem: millions of String and [B in the histogram, a big StringTable in jcmd <pid> VM.stringtable, and something holding the strings. Fix: stop interning high-cardinality values.

Finalizer and Cleaner backlogs

Objects with a finalize() can't be freed until the finalizer thread has run theirs. If it falls behind, they queue. Lab: 16 KB objects, finalize() sleeping 2 ms, -Xmx256m:

   1:         12051       66573952  [B (java.base@21.0.12.1)
   5:          4038         161520  java.lang.ref.Finalizer (java.base@21.0.12.1)

4,038 java.lang.ref.Finalizer objects in the histogram is the tell. Without the brakes in my loop it died in under a second with Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main", a message worth recognising on its own. Fix: close resources explicitly, and if you use Cleaner, keep the cleaning action cheap. I didn't lab Cleaner itself; the shape is the same but I won't print a number I didn't measure.

From a histogram to the line that registers the object

Three steps, always in this order. One, histogram twice, a few minutes apart, and diff: the leaking class is the one whose count only goes up. Two, take a dump (setup in the HeapDumpOnOutOfMemoryError article) and ask for the path to GC roots, excluding weak and soft references, for one instance of that class. Three, the first reference in the path that's in your code is the line. It's nearly always an add or put with no matching removal.

If the leaking class is Metaspace-shaped (thousands of loaders), that's a different hunt: see the Metaspace post and the class loader article that follows it.

Make the lifetime part of the API

Every leak above is a missing sentence in a design: when does this get removed? Registration APIs should return something closeable. Caches should have a maximum size at construction, with no no-arg constructor. Per-thread state should have a scope (try/finally) rather than a thread's lifetime. Keys are immutable.

I'd rather the code look slightly clumsy than let an owner be implicit:

try (var reg = events.register(s::onMessage)) {   // register returns AutoCloseable
    serve(s);
}                                                 // removed on every exit path

Tests, alerts, and what changed since JDK 8

A soak test with a small heap shows all of these in minutes; see the soak-testing piece. Alert on heap-after-GC slope, not heap used (alert set). Run production with -XX:+ExitOnOutOfMemoryError (why) so a leak ends as a restart, not a zombie.

Versions: everything above was run on OpenJDK 21.0.12.1 only. Not run: JDK 8, 11, 17, 25, other collectors, MAT, real containers. Behaviour I'd re-check on upgrade: the omitted this$0 (compiler-version dependent), finalize() (deprecated for removal since JDK 18; I ran it on 21 and it still worked), and interning semantics.

This took me well over a day, mostly because two of my first three labs showed no leak at all, and the reason they didn't is the useful part.

Pin this to the wall

  • Heap after GC going up is the symptom; a long-lived object pointing at short-lived ones is the cause.
  • The histogram count that matches your request count is the culprit. Diff two histograms.
  • ThreadLocals leak by thread count times the biggest value ever stored, not by existing.
  • Inner classes only hold the outer object if they use it, on a recent javac. Verify with javap.
  • A remove in the code doesn't prove removal. Mutable keys fool it.
  • Interning is rarely the leak on modern JDKs. The list that keeps the strings is.

Things people search after the heap graph goes up

What is the most common cause of a Java memory leak?
A static collection (a map, list, or cache) that only ever grows. It's the pattern in the first lab mode and the most common one I see, since it's the easiest to write by accident.

Does a ThreadLocal always leak?
No. In my lab a fixed pool of four threads holding 256 KB each stayed at about 2 MB after GC. It leaks when values are large or grow, when threads live forever, or when the value drags a class loader along. Call remove() in a finally anyway.

Do non-static inner classes always leak the outer instance?
Not on a current javac. On 21.0.12.1 the outer reference was omitted when the inner class never used it, and present when it did. Check the class with javap -p.

How do I find which object is holding my memory?
Take two class histograms and diff them, then open a dump and ask for the path to GC roots for one instance of the growing class. The first frame of your own code in that path is the registration point.

outofmemoryerrormemory-leakstroubleshooting

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments