Understanding Java Memory Leaks and Free Tools For Detection

Updated

java memory leak

A Java memory leak is an object that is unreachable to your program but still reachable from a GC root, so the collector cannot free it. The fastest way to confirm you have one, on any JDK 8+: run jmap -histo:live <pid> twice, a minute apart.

:live forces a full GC first, so anything that grows across two live histograms survived collection and is still referenced. No profiler, no agent, no restart.

A program that actually leaks

import java.util.*;

public class Leak {
    static final List<byte[]> CACHE = new ArrayList<>();   // static field == GC root

    public static void main(String[] args) throws Exception {
        System.out.println("pid " + ProcessHandle.current().pid());
        for (int i = 0; ; i++) {
            CACHE.add(new byte[1024 * 1024]);              // 1 MiB per iteration
            Thread.sleep(40);
        }
    }
}
$ javac Leak.java
$ java -Xmx1g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=oom.hprof Leak &
$ jps -l | grep Leak
78281 Leak

A static collection nothing ever removes from is the archetype. Saying that static variables “make it difficult for the garbage collector” is misleading: the collector has no difficulty at all. It correctly determines the objects are still reachable, because they are.

jcmd: the one command to remember

$ jcmd 78281 GC.heap_info
78281:
garbage-first heap   total reserved 1048576K, committed 190464K, used 188660K
 region size 1M, 1 eden (1M), 1 survivor (1M), 2 old (2M), 182 humongous (182M), 0 free (0M)

The 182 humongous regions (G1’s term for an allocation larger than half a region) are exactly the 1 MiB arrays. jcmd <pid> help lists everything the running JVM will accept; on JDK 26 that includes GC.heap_info, GC.heap_dump, GC.class_histogram, Thread.print, VM.classloader_stats and the whole JFR.* family. jcmd is the JDK’s general-purpose diagnostic entry point and needs no extra flags on the target JVM; jmap and jstack still work and are still convenient one-liners.

The two-histogram test

$ jmap -histo:live 78281 | head -4
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:         10184       97955152  [B (java.base@26.0.2)
   2:         10010         240240  java.lang.String (java.base@26.0.2)

...six seconds later...

$ jmap -histo:live 78281 | head -4
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:         10314      234272112  [B (java.base@26.0.2)
   2:         10010         240240  java.lang.String (java.base@26.0.2)

[B is byte[]. It went from 97 MB to 234 MB after a full GC in both samples, while String did not move. That is a leak, identified in six seconds with a tool that was already installed. Drop the :live and you get a cheaper snapshot that does not pause the application as hard, but garbage that was merely awaiting collection will pollute the comparison.

Heap dumps on demand and on failure

$ jcmd 78281 GC.heap_dump /tmp/live.hprof
78281:
Dumping heap to /tmp/live.hprof ...
Heap dump file created [240350736 bytes in 0.070 secs]

Two things worth planning for. The dump is the size of your live heap (240 MB here, and it will be gigabytes in production), and writing it stops the world. The other route is to let the JVM take one at the moment of failure, which costs nothing until it fires:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/

When this program was given only 256 MB it produced exactly that, unattended:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to /tmp/hw_java/oom.hprof ...
Heap dump file created [137468053 bytes in 0.037 secs]

Open the .hprof in Eclipse MAT, which is still free, still maintained, and still the best thing for the job. Its Leak Suspects report and “path to GC roots” view answer the only question that matters: what is still holding this?

JFR finds the allocation site for you

Flight Recorder ships in the JDK, runs with low enough overhead to leave on, and its OldObjectSample event records objects that survived long enough to be suspicious, along with the stack trace that created them:

$ java -XX:StartFlightRecording=settings=profile,filename=leak.jfr,duration=15s Leak
$ jfr summary leak.jfr | grep OldObject
 jdk.OldObjectSample                         5           158

$ jfr print --events OldObjectSample leak.jfr
jdk.OldObjectSample {
  objectSize = 1.0 MB
  objectAge = 15.0 s
  object =  [ byte[1048576] ]
  eventThread = "main" (javaThreadId = 3)
  stackTrace = [ Leak.main(String[]) line: 10 ]
}

Line 10 is CACHE.add(new byte[1024 * 1024]). No heap dump, no download, no restart with an agent attached. You can also start a recording on a JVM that is already running with jcmd <pid> JFR.start settings=profile duration=5m filename=leak.jfr.

settings=profile matters, though not for the reason you would guess. Both shipped profiles enable the sampler (default.jfc and profile.jfc in $JAVA_HOME/lib/jfr/ both carry <setting name="enabled" control="old-objects-enabled">true</setting>), and they differ on the next line: stackTrace is false in default and true in profile. Recording the same program under settings=default produced OldObjectSample events with the size, the age and the type, and no stackTrace field at all. Same events, minus the answer.

memory-leak-detection-tools.sh compiles Leak.java, attaches jcmd and jmap to the running JVM and asserts the shapes shown above: byte[] growing while String stays flat across two live histograms, a dump file that starts with the JAVA PROFILE header, and an OldObjectSample stack trace under settings=profile that is missing under settings=default. It stops the JVM it started when it finishes.

Which of these tools you actually have

Checked on JDK 26.0.2:

jhat       NOT PRESENT
jvisualvm  NOT PRESENT
jconsole   /opt/homebrew/opt/openjdk/bin/jconsole
jfr        /opt/homebrew/opt/openjdk/bin/jfr
jmap       /opt/homebrew/opt/openjdk/bin/jmap
jcmd       /opt/homebrew/opt/openjdk/bin/jcmd
jstack     /opt/homebrew/opt/openjdk/bin/jstack

VisualVM is not bundled with the JDK, and has not been since JDK 9 in 2017. Oracle’s JDK 9 migration guide states: “Java VisualVM isn’t bundled with JDK 9. If you would like to use VisualVM with JDK 9, then you can get it from the VisualVM open source project site.” It is maintained as a separate download (the current release is 2.2.2, released 8 September 2026, per visualvm.github.io), so it is a thing you install, not a thing you already have. jhat is gone too, removed by JEP 241 in JDK 9 as “an experimental, unsupported, and out-of-date tool”; Eclipse MAT is its replacement. jconsole is still bundled and is still fine for watching a heap graph live.

JProfiler and YourKit remain commercial and remain good, but reach for them for allocation profiling and call-tree work; for “is this a leak and what holds it”, the free path above answers it.

Not tested here

Eclipse MAT, JProfiler, YourKit and VisualVM were not installed or run for this page; what is said about them comes from their own documentation. The VisualVM unbundling is quoted from Oracle’s JDK 9 migration guide and the version and date from visualvm.github.io. Everything under a $ prompt above is real output from OpenJDK 26.0.2 on macOS 26.6 arm64, 2026-08-10. Absolute megabyte figures and PIDs will differ on your machine; the shape, a live histogram entry that grows while its neighbours don’t, is the reproducible part.

Photo of author
Sudhir P. founded HeatWare.com in 1999 and has built and operated it full-stack ever since; it is now used by more than 88,000 people. He writes here about the PostgreSQL, MySQL, Linux and DevOps work that keeps it running. Articles are rewritten only after the commands in them have actually been run, and the verification scripts are published alongside them so anyone can check the claims. Reach him at blog@heatware.net.