> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Java Memory Leak Patterns: Statics, Listeners, ThreadLocals
- URL: https://www.ggorantala.dev/java-memory-leak-patterns/
- Published: 2026-11-05T03:43:16.000Z
- Updated: 2026-11-05T03:43:16.000Z
- Description: Static maps, forgotten listeners, ThreadLocals in pools, inner classes holding outer ones: the Java memory leak patterns I keep finding, with dump signatures.
- Author: Gopi Gorantala
- Tags: outofmemoryerror, memory-leaks, troubleshooting

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](https://ggorantala.dev/java-memory-leak-heap-dump/?ref=ggorantala.dev) 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](https://ggorantala.dev/java-outofmemoryerror-types/?ref=ggorantala.dev) and, in a looser way, in an [older post here](https://ggorantala.dev/java-outofmemoryerror-java-heap-space-gc-overhead-limit/?ref=ggorantala.dev).

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.

```bash
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](https://ggorantala.dev/eclipse-mat-heap-dump-analysis/?ref=ggorantala.dev).

## 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.

```java
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:

```bash
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`:

```log
 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](https://ggorantala.dev/outofmemoryerror-unbounded-cache/?ref=ggorantala.dev). 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`:

```log
  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:

```log
   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](https://ggorantala.dev/java-threadlocal-thread-confinement/?ref=ggorantala.dev).

### 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**.

```log
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:

```log
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](https://bugs.openjdk.org/browse/JDK-8271623?ref=ggorantala.dev)); the same issue has a [backout](https://bugs.openjdk.org/browse/JDK-8277718?ref=ggorantala.dev) and a [serialization follow-up](https://bugs.openjdk.org/browse/JDK-8277965?ref=ggorantala.dev) 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:

```log
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`:

```log
   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](https://ggorantala.dev/heapdumponoutofmemoryerror/?ref=ggorantala.dev)) 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](https://ggorantala.dev/outofmemoryerror-metaspace/?ref=ggorantala.dev) 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:

```java
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](https://ggorantala.dev/soak-testing-memory-leaks/?ref=ggorantala.dev). Alert on heap-after-GC slope, not heap used ([alert set](https://ggorantala.dev/java-memory-alerting-before-oom/?ref=ggorantala.dev)). Run production with `-XX:+ExitOnOutOfMemoryError` ([why](https://ggorantala.dev/exitonoutofmemoryerror-fail-fast/?ref=ggorantala.dev)) 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.