spring-boot-demo-oom
Five small Spring Boot apps, each leaking memory a different way. Every one is driven to a
real java.lang.OutOfMemoryError under a constrained heap, with a real .hprof heap dump
captured at the moment it happens, then run through Eclipse Memory Analyzer's headless batch
report generator for real leak-suspect evidence. Nothing here is a description of what a leak
"would" look like -- every number and stack trace under */docs/output/ comes from a file a
script produced.
Companion repository for Debugging OutOfMemoryError: Heap Dumps, Eclipse MAT, and the Five Leak Patterns in Spring Apps on ankurm.com.
Versions
| Component | Version | Verified against |
|---|---|---|
| JDK | 25.0.4.1+1 (Temurin, LTS) | java -version |
| Spring Boot | 4.1.1 | repo1.maven.org/maven2/.../spring-boot-starter-parent/maven-metadata.xml (latest non-milestone GA at write time) |
| Eclipse Memory Analyzer | 1.17.0 (2026-06-01 RCP build) | ftp-stud.hs-esslingen.de/pub/Mirrors/eclipse/mat/1.17.0/rcp/ |
| Maven | 3.9.11 | mvn -version |
The five leaks
| Module | Leak pattern | What actually retains the memory |
|---|---|---|
static-cache-leak |
Static cache, no eviction | ConcurrentHashMap on a static field, grown by one entry per unique key forever |
threadlocal-leak |
ThreadLocal on a pooled executor |
A new ThreadLocal created per task, value never remove()d, on 4 reused pool threads |
listener-leak |
Observer never unregistered | A singleton listener list only ever grows; each listener closes over a session's buffer |
classloader-leak |
Hot-reloaded plugin, classloader never released | A registry of past "reload" instances keeps every one's Class + ClassLoader alive |
unbounded-queue-leak |
Producer outruns consumer into an unbounded queue | new LinkedBlockingQueue<>() with no capacity bound, between a fast producer and a 50ms/item consumer |
Each module is a standalone Spring Boot app (spring-boot-starter, no web layer -- nothing here
needs HTTP to make its point) whose ApplicationRunner drives the leak in a tight loop until the
JVM runs out of heap.
Quickstart
export JAVA_HOME=/path/to/jdk-25 # must be JDK 25 or newer
export PATH="$JAVA_HOME/bin:$PATH"
cd static-cache-leak
./scripts/run.sh # builds, runs with -Xmx160m, crashes with a real OutOfMemoryError,
# regenerates docs/output/01-oom-console.txt
scripts/run.sh always rebuilds and rewrites docs/output/01-oom-console.txt from that run's
real log. The heap dump itself (docs/output/heap.hprof, 140-300 MB per module) is not
committed -- see .gitignore -- regenerate it with the same script.
Running it through Eclipse MAT
export MAT_HOME=/path/to/MemoryAnalyzer-1.17.0...-linux.gtk.x86_64/ # extracted RCP build
cd static-cache-leak
./scripts/mat-report.sh # needs xvfb-run -- MAT's SWT runtime wants an X display even
# in this headless report mode, so xvfb-run -a wraps it
This calls MAT's ParseHeapDump.sh <dump> org.eclipse.mat.api:suspects -- the command-line batch
report generator MAT ships alongside its GUI, no interactive session required -- and then
re-extracts the "Problem Suspect 1" paragraph into docs/output/02-mat-leak-suspects.txt.
Regenerate every module's output in one go with scripts/run-all.sh from the repo root (needs
MAT_HOME set).
Index of captured output
| File | What it proves |
|---|---|
*/docs/output/01-oom-console.txt |
The real java -Xmx...m -XX:+HeapDumpOnOutOfMemoryError run: progress lines, the real OutOfMemoryError, the real "Heap dump file created" line, and (where available) a clean worker-thread stack trace into the leaking call |
*/docs/output/02-mat-leak-suspects.txt |
Eclipse MAT's own "Problem Suspect 1" paragraph from the real .hprof: the class/instance holding the memory, its retained-size percentage, the top consumer classes, and the thread/path that reaches it |
All ten files are regenerated from scratch, per module, by scripts/run.sh +
scripts/mat-report.sh -- see scripts/extract-oom-console.py and
scripts/extract-mat-summary.py for exactly how each is trimmed out of the raw tool output (both
scripts are plain, auditable Python -- no hand-retyping of either tool's text at any point).
Why no web layer
Every demo is a CommandLineRunner-style ApplicationRunner bean that starts leaking the moment
the context comes up. A real HTTP-triggered leak looks the same under MAT once you have a heap
dump -- the point of this repository is the dump-and-analyze workflow and the five retention
shapes, not building five web services.
Source layout
spring-boot-demo-oom/
├── pom.xml aggregator -- lists the five modules, no shared parent
├── scripts/
│ ├── run-all.sh regenerates every module's docs/output/ (needs MAT_HOME)
│ ├── extract-oom-console.py trims a raw run log into 01-oom-console.txt
│ └── extract-mat-summary.py trims a raw MAT report zip into 02-mat-leak-suspects.txt
└── <module>/
├── pom.xml parent: org.springframework.boot:spring-boot-starter-parent:4.1.1
├── scripts/
│ ├── run.sh build + run to a real OOM + regenerate 01-oom-console.txt
│ └── mat-report.sh run the resulting .hprof through MAT + regenerate 02-*.txt
├── src/main/java/com/ankurm/oomdemo/...
└── docs/output/*.txt captured real output (see index above)
classloader-leak/ additionally has plugin-src/ (the hot-reloaded plugin's source, compiled by
classloader-leak/scripts/build-plugin.sh into a committed .class resource -- see that
module's README section in the post for why it has to be a resource rather than an ordinary
compiled class).
License
MIT -- see LICENSE.