Files

6.1 KiB

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.