spring-boot-demo-oom: five real memory-leak demos with captured OOM + MAT evidence

This commit is contained in:
2026-10-01 12:11:56 +00:00
commit fd035a79e8
41 changed files with 1317 additions and 0 deletions
@@ -0,0 +1,19 @@
$ java -Xmx220m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=docs/output/heap.hprof -jar target/threadlocal-leak.jar
threadlocal-leak: submitted=3017200
threadlocal-leak: submitted=3017600
threadlocal-leak: submitted=3018000
threadlocal-leak: submitted=3018400
threadlocal-leak: submitted=3018800
java.lang.OutOfMemoryError: Java heap space
Dumping heap to docs/output/heap.hprof ...
Heap dump file created [300071460 bytes in 1.908 secs]
Exception in thread "req-worker-1" java.lang.OutOfMemoryError: Java heap space
at com.ankurm.oomdemo.ThreadLocalLeakApplication.handleOneRequest(ThreadLocalLeakApplication.java:61)
at com.ankurm.oomdemo.ThreadLocalLeakApplication.lambda$driveLeak$1(ThreadLocalLeakApplication.java:47)
at com.ankurm.oomdemo.ThreadLocalLeakApplication$$Lambda/0x000000002d25dce8.run(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614)
at java.base/java.lang.Thread.runWith(Thread.java:1487)
at java.base/java.lang.Thread.run(Thread.java:1474)
@@ -0,0 +1,23 @@
Eclipse MAT 1.17.0 batch report (org.eclipse.mat.api:suspects) on heap.hprof -- Problem Suspect 1
================================================================================================
Problem Suspect 1 One instance of
org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor$1 loaded by
org.springframework.boot.loader.launch.LaunchedClassLoader @ 0xf2434790 occupies 172,593,776
(82.46%) bytes. The top consumers of its minimum retained heap are
com.ankurm.oomdemo.ThreadLocalLeakApplication$$Lambda+0x000000006f25dce8 (3,082,020 instances
totaling 98,624,640), java.util.concurrent.LinkedBlockingQueue$Node (3,082,021 instances
totaling 73,968,504), and java.util.HashMap$Node (4 instances totaling 128). Thread
java.lang.Thread @ 0xf2508e50 req-worker-1 has a local variable or reference to
org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor$1 @ 0xf2508f60 which is on the
shortest path to org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor$1 @ 0xf2508f60
. The thread java.lang.Thread @ 0xf2508e50 req-worker-1 keeps local variables with total size
8,391,448 (4.01%) bytes. The top consumers of its minimum retained heap are
com.ankurm.oomdemo.ThreadLocalLeakApplication$$Lambda+0x000000006f25dce8 (3,082,020 instances
totaling 98,624,640), java.util.concurrent.LinkedBlockingQueue$Node (3,082,021 instances
totaling 73,968,504), and java.util.HashMap$Node (4 instances totaling 128). Significant stack
frames and local variables java.util.concurrent.ThreadPoolExecutor.runWorker(Ljava/util/concurre
nt/ThreadPoolExecutor$Worker;)V (ThreadPoolExecutor.java:1090)
org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor$1 @ 0xf2508f60 retains
172,593,776 (82.46%) bytes The stacktrace of this Thread is available. See stacktrace . See
stacktrace with involved local variables .
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>threadlocal-leak</artifactId>
<version>1.0.0</version>
<name>threadlocal-leak</name>
<description>Leak pattern 2: a per-task ThreadLocal never removed from a pooled executor</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
</dependencies>
<build>
<finalName>threadlocal-leak</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Runs docs/output/heap.hprof (produced by scripts/run.sh) through Eclipse Memory Analyzer's
# headless batch report generator -- MemoryAnalyzer's -application org.eclipse.mat.api.parse,
# invoked via the ParseHeapDump.sh wrapper MAT ships -- then re-extracts the Problem Suspect
# summary into docs/output/02-mat-leak-suspects.txt.
#
# No GUI is shown, but MAT's SWT runtime still needs an X display even in this mode, hence
# xvfb-run. Needs: MAT_HOME pointing at an extracted Memory Analyzer 1.17.0+ "rcp" distribution,
# and xvfb-run on PATH (package "xvfb" on Debian/Ubuntu).
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MODULE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ROOT_DIR="$(cd "$MODULE_DIR/.." && pwd)"
cd "$MODULE_DIR"
: "${MAT_HOME:?Set MAT_HOME to your Memory Analyzer install (e.g. /opt/mat)}"
if [ ! -f docs/output/heap.hprof ]; then
echo "docs/output/heap.hprof not found -- run scripts/run.sh first" >&2
exit 1
fi
rm -f docs/output/heap_Leak_Suspects.zip
xvfb-run -a "$MAT_HOME/ParseHeapDump.sh" "$MODULE_DIR/docs/output/heap.hprof" org.eclipse.mat.api:suspects
python3 "$ROOT_DIR/scripts/extract-mat-summary.py" "$MODULE_DIR"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Drives threadlocal-leak to a REAL java.lang.OutOfMemoryError under a constrained heap, with
# -XX:+HeapDumpOnOutOfMemoryError so the crash leaves a real .hprof behind. Also regenerates
# docs/output/01-oom-console.txt from the real run log.
#
# Needs JDK 25 (LTS) on PATH.
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MODULE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ROOT_DIR="$(cd "$MODULE_DIR/.." && pwd)"
cd "$MODULE_DIR"
mvn -q -B package -DskipTests
mkdir -p docs/output
rm -f docs/output/heap.hprof
CMD="java -Xmx220m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=docs/output/heap.hprof -jar target/threadlocal-leak.jar"
echo "== running: $CMD =="
LOG="$(mktemp)"
$CMD > "$LOG" 2>&1 || true
python3 "$ROOT_DIR/scripts/extract-oom-console.py" "$MODULE_DIR" "$LOG" "$CMD"
rm -f "$LOG"
echo "heap dump: docs/output/heap.hprof (not committed -- see .gitignore; regenerate it with this script)"
@@ -0,0 +1,65 @@
package com.ankurm.oomdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* Leak pattern 2 of 5: a ThreadLocal that outlives the request that created it.
*
* The fixed-size pool below is deliberately realistic: four threads, reused forever,
* exactly like a web server's request-handling pool. The bug is in {@link #handleOneRequest},
* which does what a lot of real "request context" code does: creates a *new* ThreadLocal
* instance per request to stash a payload, and never calls remove().
*
* A ThreadLocal's key is a WeakReference, so the ThreadLocal object itself can be collected.
* But Thread.ThreadLocalMap.Entry extends WeakReference<ThreadLocal&lt;?&gt;> and keeps a
* STRONG reference to the *value*. Expunging a stale entry (key==null) only happens on a later
* get()/set()/remove() call on that same slot -- which never comes, because every request makes
* a brand new ThreadLocal, so no one ever touches the old slot again. Each of the 4 pooled
* threads accumulates one dead-keyed, live-valued entry per request forever.
*/
@SpringBootApplication
public class ThreadLocalLeakApplication {
public static void main(String[] args) {
SpringApplication.run(ThreadLocalLeakApplication.class, args);
}
@Bean
ApplicationRunner driveLeak() {
return (ApplicationArguments args) -> {
ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();
pool.setCorePoolSize(4);
pool.setMaxPoolSize(4);
pool.setQueueCapacity(Integer.MAX_VALUE);
pool.setThreadNamePrefix("req-worker-");
pool.initialize();
long payloadSize = 256 * 1024; // 256 KB "request context" per call
long i = 0;
System.out.println("threadlocal-leak: driving a 4-thread pool to OOM via leaked ThreadLocal entries");
while (true) {
final long reqId = i++;
pool.execute(() -> handleOneRequest(reqId, payloadSize));
if (reqId % 400 == 0) {
System.out.println("threadlocal-leak: submitted=" + reqId);
}
}
};
}
/**
* Every call creates a fresh ThreadLocal (the bug -- it should be a single static field)
* and never removes it. The whole point: this looks like ordinary per-request code.
*/
private static void handleOneRequest(long requestId, long payloadSize) {
ThreadLocal<byte[]> requestContext = new ThreadLocal<>();
requestContext.set(new byte[(int) payloadSize]);
requestContext.get()[0] = (byte) requestId; // "use" the context
// Missing on purpose: requestContext.remove();
}
}