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