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,11 @@
$ java -Xmx160m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=docs/output/heap.hprof -jar target/classloader-leak.jar
classloader-leak: driving repeated plugin reloads to OOM, class=com.ankurm.oomplugin.PluginTask
classloader-leak: reloads=150 livePlugins=150
classloader-leak: reloads=300 livePlugins=300
classloader-leak: reloads=450 livePlugins=450
java.lang.OutOfMemoryError: Java heap space
Dumping heap to docs/output/heap.hprof ...
Heap dump file created [138919083 bytes in 0.140 secs]
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
@@ -0,0 +1,24 @@
Eclipse MAT 1.17.0 batch report (org.eclipse.mat.api:suspects) on heap.hprof -- Problem Suspect 1
================================================================================================
Problem Suspect 1 The class com.ankurm.oomdemo.ClassloaderLeakApplication , loaded by
org.springframework.boot.loader.launch.LaunchedClassLoader @ 0xf606bba8 , occupies 123,132,768
(95.35%) bytes. The top consumers of its minimum retained heap are byte[] (1,412 instances
totaling 122,739,984), java.util.concurrent.ConcurrentHashMap (1,404 instances totaling 89,856),
and java.util.concurrent.ConcurrentHashMap$Node[] (936 instances totaling 74,880). The memory is
accumulated in one instance of java.lang.Object[] , loaded by <system class loader> , which
occupies 123,131,144 (95.35%) bytes. Thread java.lang.Thread @ 0xf60cad68 main has a local
variable or reference to class com.ankurm.oomdemo.ClassloaderLeakApplication @ 0xf60000b0 which
is on the shortest path to java.lang.Object[549] @ 0xf7b40d50 . The thread java.lang.Thread @
0xf60cad68 main keeps local variables with total size 109,632 (0.08%) bytes. The top consumers
of its minimum retained heap are byte[] (1,412 instances totaling 122,739,984),
java.util.concurrent.ConcurrentHashMap (1,404 instances totaling 89,856), and
java.util.concurrent.ConcurrentHashMap$Node[] (936 instances totaling 74,880). Significant stack
frames and local variables org.springframework.boot.SpringApplication.run(Ljava/lang/Class;[Ljav
a/lang/String;)Lorg/springframework/context/ConfigurableApplicationContext;
(SpringApplication.java:1354) class com.ankurm.oomdemo.ClassloaderLeakApplication @ 0xf60000b0
retains 123,132,768 (95.35%) bytes org.springframework.boot.loader.launch.Launcher.launch(Ljava/
lang/ClassLoader;Ljava/lang/String;[Ljava/lang/String;)V (Launcher.java:106) class
com.ankurm.oomdemo.ClassloaderLeakApplication @ 0xf60000b0 retains 123,132,768 (95.35%) bytes
The stacktrace of this Thread is available. See stacktrace . See stacktrace with involved local
variables .
@@ -0,0 +1,25 @@
package com.ankurm.oomplugin;
/**
* The "plugin" that gets hot-reloaded. Deliberately simple: one sizeable field so each
* reload's copy of this class has a visible memory footprint in the heap dump, and one
* method so the reload actually does something observable.
*
* This file is compiled once by scripts/build-plugin.sh into classloader-leak's resources
* as a committed .class file -- it is intentionally NOT part of the module's own Maven
* compilation unit, because the whole point of the demo is loading these bytes through a
* *different* ClassLoader on every "reload", which only makes sense if the bytes already
* exist as a resource rather than as a class the app's own classloader already loaded.
*/
public class PluginTask implements Runnable {
private final byte[] payload = new byte[256 * 1024];
public PluginTask() {
payload[0] = 1;
}
@Override
public void run() {
payload[1] = 2;
}
}
+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>classloader-leak</artifactId>
<version>1.0.0</version>
<name>classloader-leak</name>
<description>Leak pattern 4: a hot-reloaded plugin whose classloader is never released</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
</dependencies>
<build>
<finalName>classloader-leak</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Regenerates src/main/resources/plugin/PluginTask.class from plugin-src/.
# Run this whenever plugin-src/com/ankurm/oomplugin/PluginTask.java changes.
set -e
cd "$(dirname "$0")/.."
mkdir -p /tmp/plugin-build
javac -d /tmp/plugin-build plugin-src/com/ankurm/oomplugin/PluginTask.java
cp /tmp/plugin-build/com/ankurm/oomplugin/PluginTask.class src/main/resources/plugin/PluginTask.class
echo "rebuilt src/main/resources/plugin/PluginTask.class"
+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 classloader-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 -Xmx160m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=docs/output/heap.hprof -jar target/classloader-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,77 @@
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 java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
/**
* Leak pattern 4 of 5: a hot-reloaded plugin whose old classloader is never released.
*
* {@code PluginTask.class} (compiled from plugin-src/, see scripts/build-plugin.sh) is loaded
* fresh on every iteration through a brand-new {@link PluginClassLoader} -- exactly what a
* plugin system, a rules-engine hot-swap, or a servlet container's redeploy does. That part is
* normal and is not the bug.
*
* The bug is LIVE_PLUGINS: every reloaded instance is appended and never removed. Because the
* instance's {@code getClass()} points at the Class object defined by *that* reload's
* PluginClassLoader, keeping the instance alive keeps its Class alive, which keeps its
* ClassLoader alive, which keeps every other class that loader ever defined alive too. A real
* version of this bug is usually one line: a static registry, metrics map, or "active plugins"
* list that an unload path forgets to clear.
*/
@SpringBootApplication
public class ClassloaderLeakApplication {
/** The leak: every reload's instance (and therefore its whole classloader) kept forever. */
static final List<Runnable> LIVE_PLUGINS = new ArrayList<>();
public static void main(String[] args) {
SpringApplication.run(ClassloaderLeakApplication.class, args);
}
@Bean
ApplicationRunner driveLeak() {
return (ApplicationArguments args) -> {
byte[] classBytes = readPluginClassBytes();
String className = "com.ankurm.oomplugin.PluginTask";
long i = 0;
System.out.println("classloader-leak: driving repeated plugin reloads to OOM, class=" + className);
while (true) {
PluginClassLoader loader = new PluginClassLoader(
ClassloaderLeakApplication.class.getClassLoader(), className, classBytes);
Class<?> pluginClass = loader.loadClass(className);
Runnable plugin = (Runnable) pluginClass.getDeclaredConstructor().newInstance();
plugin.run();
// The bug: this reload's plugin instance, Class, and ClassLoader are never
// forgotten. A correct version would reload into a new list and let the old
// list (and everything it reaches) become unreachable.
LIVE_PLUGINS.add(plugin);
i++;
if (i % 150 == 0) {
System.out.println("classloader-leak: reloads=" + i + " livePlugins=" + LIVE_PLUGINS.size());
}
}
};
}
private static byte[] readPluginClassBytes() throws Exception {
try (InputStream in = ClassloaderLeakApplication.class.getClassLoader()
.getResourceAsStream("plugin/PluginTask.class")) {
if (in == null) {
throw new IllegalStateException("plugin/PluginTask.class not found on the classpath");
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
in.transferTo(out);
return out.toByteArray();
}
}
}
@@ -0,0 +1,28 @@
package com.ankurm.oomdemo;
/**
* One of these is created per "hot reload" in {@link ClassloaderLeakApplication}. Each instance
* defines its own, distinct {@code Class<PluginTask>} object from the same bytes -- that is what
* a real plugin-reload or hot-redeploy mechanism does, and it is not itself the bug. The bug is
* in whoever keeps something reachable from one of these loaders around after the "reload" that
* superseded it.
*/
class PluginClassLoader extends ClassLoader {
private final byte[] classBytes;
private final String className;
PluginClassLoader(ClassLoader parent, String className, byte[] classBytes) {
super(parent);
this.className = className;
this.classBytes = classBytes;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
if (!className.equals(name)) {
return super.findClass(name);
}
return defineClass(name, classBytes, 0, classBytes.length);
}
}