Midden, a Java heap dump analyzer, was released on September 25. Written in Rust, it reads JVM heap dumps and tells you what is holding the memory. It builds the full object graph and dominator tree from an .hprof file and reports retained sizes, owners, root paths and leak suspects. It does all of this from one static binary with no JDK required. It's MIT-licensed, with prebuilt binaries for Linux and Windows; you can also install it via cargo if you check out the source repository and run the installation locally1.
The standard tool for this job is Eclipse MAT, which is excellent and does everything midden does and more. MAT generates a UI view of the heap dump, and is quite useful, but it's slower than midden and generates an HTML view of the data. midden runs in the console, which makes it more appropriate for CI/CD or container use, or for people who prefer text content to (admittedly rather pretty) graphs.
I wrote a small program with two deliberate leaks and had it dump its own heap at the halfway point and at the end.
# build.gradle.kts
plugins {
application
}
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
application {
mainClass = "demo.LeakDemo"
}
// ./gradlew leakSessions or ./gradlew leakListeners -Psteps=400000
val steps = providers.gradleProperty("steps").orElse("100000")
val dumpDir = layout.buildDirectory.dir("dumps")
listOf("sessions", "listeners").forEach { mode ->
tasks.register<JavaExec>("leak${mode.replaceFirstChar(Char::uppercase)}") {
group = "leak"
description = "Run the $mode leak and write before/after heap dumps"
classpath = sourceSets.main.get().runtimeClasspath
mainClass = application.mainClass
maxHeapSize = "1g"
argumentProviders.add(CommandLineArgumentProvider {
listOf(mode, steps.get(), dumpDir.get().asFile.path)
})
}
}
The two leaks are shaped differently. The first is the easy case: a static map of sessions that get flagged invalid but are never evicted. The second is harder. Request handlers subscribe to a singleton event bus through a non-static inner class and never unsubscribe. By shallow size, the bus holds nothing but tiny callbacks. Only a correct retained-size calculation shows that each callback holds its handler and each handler holds a 2KB buffer.
// src/main/java/demo/DemoLeak.java
package demo;
import com.sun.management.HotSpotDiagnosticMXBean;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Two deliberate leaks for exercising a heap dump analyser.
*
* sessions invalidated sessions are flagged but never evicted from a static map
* listeners request handlers subscribe to a singleton event bus and never unsubscribe
*
* Usage: LeakDemo <sessions|listeners> [steps] [dumpDir]
* Writes <mode>-before.hprof at the halfway point and <mode>-after.hprof at the end.
*/
public class LeakDemo {
public static void main(String[] args) throws IOException {
String mode = args.length > 0 ? args[0] : "sessions";
int steps = args.length > 1 ? Integer.parseInt(args[1]) : 100_000;
Path dir = Path.of(args.length > 2 ? args[2] : "dumps");
Files.createDirectories(dir);
Workload workload = switch (mode) {
case "sessions" -> new SessionWorkload();
case "listeners" -> new ListenerWorkload();
default -> throw new IllegalArgumentException("mode must be sessions or listeners");
};
for (int i = 1; i <= steps; i++) {
workload.step(i);
if (i == steps / 2) {
dump(dir.resolve(mode + "-before.hprof"));
}
}
dump(dir.resolve(mode + "-after.hprof"));
}
static void dump(Path file) throws IOException {
Files.deleteIfExists(file);
ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class)
.dumpHeap(file.toString(), true); // live objects only: forces a full GC first
System.out.printf("wrote %s (%,d MB)%n", file, Files.size(file) >> 20);
}
}
interface Workload {
void step(int i);
}
// ---- mode 1: sessions -------------------------------------------------------
final class Session {
final String token;
final byte[] attributes = new byte[2048];
boolean valid = true;
Session(String token) {
this.token = token;
}
}
final class SessionStore {
static final Map<String, Session> SESSIONS = new ConcurrentHashMap<>();
static Session open(String token) {
Session s = new Session(token);
SESSIONS.put(token, s);
return s;
}
static void invalidate(Session s) {
s.valid = false; // the bug: flagged, never removed
}
}
final class SessionWorkload implements Workload {
@Override
public void step(int i) {
Session s = SessionStore.open("sess-" + i);
if (i % 10 != 0) { // nine in ten users log out
SessionStore.invalidate(s);
}
}
}
// ---- mode 2: listeners ------------------------------------------------------
interface Listener {
void onEvent(String event);
}
final class EventBus {
static final EventBus INSTANCE = new EventBus();
private final List<Listener> listeners = new ArrayList<>();
void subscribe(Listener l) {
listeners.add(l);
}
void unsubscribe(Listener l) {
listeners.remove(l);
}
}
final class RequestHandler {
private final String path;
private final byte[] buffer = new byte[2048];
private final Listener callback = new Callback();
RequestHandler(String path) {
this.path = path;
EventBus.INSTANCE.subscribe(callback);
}
void handle() {
buffer[0]++;
}
void close() {
// the bug: should call EventBus.INSTANCE.unsubscribe(callback)
}
// Non-static inner class: each Callback holds its RequestHandler through this$0.
private final class Callback implements Listener {
@Override
public void onEvent(String event) {
if (event.equals(path)) {
handle();
}
}
}
}
final class ListenerWorkload implements Workload {
@Override
public void step(int i) {
RequestHandler h = new RequestHandler("/orders/" + i);
h.handle();
h.close();
}
}
Run ./gradlew leakSessions leakListeners and the dumps land in build/dumps. Pointing midden at the listener dump shows this, in part:
$ midden listeners-after.hprof
heap dump listeners-after.hprof | JAVA PROFILE 1.0.2 | 64-bit ids | 217.6MB file
taken 2026-09-25 13:08 UTC | 1,122 classes | 535,680 objects | 660,974 references | 6 threads | sizes: MAT convention | analysed in 101ms
648 references and 0 roots point at objects the dump does not contain
heap
212.7MB in 535,680 objects | 211.5MB live (99.4%) | 1.1MB in 2,323 objects unreachable, garbage the dump still holds
85.0KB in 1,392 objects reachable only through soft/weak/phantom references; not counted as retained by anything
gc roots 1,025 objects | 950 system class | 62 jni global | 28 java frame | 6 thread | 1 jni local
leak suspects objects or classes retaining ā„10% of the live heap
1. 209.9MB 99.2% jdk.internal.loader.ClassLoaders$AppClassLoader @0x7040621750
209.9MB 99.2% .classes java.util.ArrayList @0x7040621808
209.9MB 99.2% .elementData java.lang.Object[9] @0x7003c1fa78
209.9MB 99.2% [6] class demo.EventBus @0x7003c1ef90
209.9MB 99.2% static EventBus.INSTANCE demo.EventBus @0x7003c00170
209.9MB 99.2% .listeners java.util.ArrayList @0x7003c02300
209.9MB 99.2% .elementData java.lang.Object[106,710] @0x700e38b2a0 ā accumulation point
keeps 500,001 objects: 200,000 Ć byte[] 199.9MB | 100,000 Ć RequestHandler 3.8MB | 100,000 Ć String 3.1MB | 100,000 Ć RequestHandler$Callback 2.3MB
path java.lang.Thread @0x7003c26e88 ā thread "main"
.contextClassLoader jdk.internal.loader.ClassLoaders$AppClassLoader @0x7040621750
biggest objects by retained size | dominator tree to depth 4, branches ā„1.0% of the live heap
209.9MB 99.2% jdk.internal.loader.ClassLoaders$AppClassLoader @0x7040621750
209.9MB 99.2% .classes java.util.ArrayList @0x7040621808
209.9MB 99.2% .elementData java.lang.Object[9] @0x7003c1fa78
209.9MB 99.2% [6] class demo.EventBus @0x7003c1ef90
It found the accumulation point, and the "keeps" line shows the chain from callback to handler to buffer. The count of 200,000 byte arrays is significant. The other 100,000 are the backing arrays of the handlers' path strings.
The session dump shows off the query side. --where filters on a field value:
$ midden sessions-after.hprof --where 'Session.valid=false'
heap dump sessions-after.hprof | JAVA PROFILE 1.0.2 | 64-bit ids | 220.7MB file
taken 2026-09-25 13:08 UTC | 1,118 classes | 568,933 objects | 660,949 references | 6 threads | sizes: MAT convention | analysed in 128ms
649 references and 0 roots point at objects the dump does not contain
objects where Session.valid=false 90,000 matches
2.1KB 40B shallow demo.Session @0x7000800868
2.1KB 40B shallow demo.Session @0x70008010e8
2.1KB 40B shallow demo.Session @0x7000801968
2.1KB 40B shallow demo.Session @0x70008019d8
2.1KB 40B shallow demo.Session @0x7000801a48
2.1KB 40B shallow demo.Session @0x7000801ab8
2.1KB 40B shallow demo.Session @0x7000801b28
2.1KB 40B shallow demo.Session @0x7000801c08
2.1KB 40B shallow demo.Session @0x7000801c78
2.1KB 40B shallow demo.Session @0x7000801ce8
2.1KB 40B shallow demo.Session @0x70008058c8
2.1KB 40B shallow demo.Session @0x7000806148
2.1KB 40B shallow demo.Session @0x70008069c8
2.1KB 40B shallow demo.Session @0x7000806a38
2.1KB 40B shallow demo.Session @0x7000806aa8
2.1KB 40B shallow demo.Session @0x7000806b18
2.1KB 40B shallow demo.Session @0x7000806b88
2.1KB 40B shallow demo.Session @0x7000806bf8
2.1KB 40B shallow demo.Session @0x7000806c68
2.1KB 40B shallow demo.Session @0x7000806cd8
path java.lang.Thread @0x7003a3d310 ā thread "main"
.contextClassLoader jdk.internal.loader.ClassLoaders$AppClassLoader @0x7040621750
.classes java.util.ArrayList @0x7040621808
.elementData java.lang.Object[6] @0x7003a10e28
[3] class demo.SessionStore @0x7003a0f170
static SessionStore.SESSIONS java.util.concurrent.ConcurrentHashMap @0x7003a07750
.table java.util.concurrent.ConcurrentHashMap$Node[262,144] @0x7009d00000
[103155] java.util.concurrent.ConcurrentHashMap$Node @0x700a228c08
.val demo.Session @0x7000800868
+ 89980 more; --top shows more
That's ninety thousand invalid sessions out of a hundred thousand, exactly what the program leaked, along with the path from a GC root to the map. In MAT, the same question is an OQL query. Retained sizes and suspects from both dumps matched MAT. After its first run, midden caches its index beside the dump, and later runs start at the report. Both runs above were cached, which is where the timings in their headers come from.
It's a first release, and dumps from real applications are likely to exercise the project more than this sample code, but the author said that it's a production tool that has been open-sourced, so it might be pretty strong. On these two samples, it found the leaks, named the paths and got the sizes right, as one would hope. Well done.
-
This is likely to change.
ā©