spring-boot-demo-oom: five real memory-leak demos with captured OOM + MAT evidence
This commit is contained in:
@@ -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 .
|
||||
@@ -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>
|
||||
Executable
+23
@@ -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"
|
||||
Executable
+22
@@ -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<?>> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user