Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Java  Module 16 – JVM internals, memory, GC and performance

Memory leaks, heap dumps and OOM errors

Find a Java memory leak with jcmd class histograms and heap dumps, watch a leaky cache hit OutOfMemoryError in 64 MB, and read what each OOM message means.

  • Advanced
  • 24 minutes
  • Examples run with Java 25.0.4.1 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 8 of 24 minutes free
Free: Four patterns behind most memory leaks · A leak reproduced with a 64 MB heap · Two class histograms, 20,000 requests apart · 2 more sections. With a pass: 3 in-depth parts and 3 interview answers, about 16 minutes.

What you will learn

  • Recognise common leak patterns in Java code
  • Capture class histograms and heap dumps with jcmd
  • Distinguish OutOfMemoryError causes by their messages

Before you start

Memory leaks in Java are objects that a program no longer needs but still references, so the garbage collector, which reclaims only objects nothing can reach, has to keep them. The heap left over after each collection creeps up, collections come more often, and in the end the JVM throws OutOfMemoryError. Two kinds of evidence find the leak. A class histogram, printed by jcmd <pid> GC.class_histogram, counts the objects of every class, and two histograms taken some time apart show which classes grow. Heap dumps, written by jcmd <pid> GC.heap_dump, record every object and every reference, so a dump analyser can follow the chain of references that keeps the growing objects alive. When the memory does run out, the error’s message names which kind of memory it was. Heap dumps show chains like the one in this diagram for every object in the heap. Two more tools help: -XX:+HeapDumpOnOutOfMemoryError writes a dump at the moment of failure, and JDK Flight Recorder’s old-object sampler records the chain behind a few long-lived objects.

A chain of references from a GC root through the static field CACHE and a HashMap to each quote and its byte array.GC rootthe class loader that loaded QuoteServiceclass QuoteServicestatic field CACHEHashMapone entry per request servedHashMap$Node[ ]the map's bucketsHashMap$Nodekey "req-41307"QuoteService$QuoteSKU and pricebyte[1000]the termsObjects that no chain reachesare reclaimed by the next collectionkeeps its classesCACHEtablean elementvalueterms

The chain of references that keeps every cached quote alive

Text description of the diagram

The diagram runs from top to bottom, one box per object, with an arrow from each object to the one it references.

  1. A GC root: the class loader that loaded QuoteService, which keeps the classes it loaded.
  2. The class QuoteService, with its static field CACHE.
  3. Through CACHE: a HashMap that holds one entry per request served.
  4. Through the map's field table: an array of buckets, HashMap$Node[].
  5. Through one element of that array: a map entry, HashMap$Node, whose key is a request id such as "req-41307".
  6. Through the entry's value: a QuoteService$Quote with a SKU and a price.
  7. Through the quote's field terms: a byte array of 1,000 bytes.

A separate dashed box notes that objects which no such chain reaches are reclaimed by the next collection. Every quote is reachable this way, so the collector must keep all of them.

Four patterns behind most memory leaks

Most memory leaks in Java code follow one of four patterns, and each one is a reference that lives longer than the object it points to needs to:

  • A static collection that only grows. A static field lives as long as its class, usually as long as the program, so everything added to it stays.
  • A cache without a limit or a useful key. A map keyed by something that never repeats, such as a request id, stores one entry per request and never reads any of them again.
  • Listeners and callbacks that are never removed. An event source holds every listener registered with it, and a listener that captures a page or a session keeps that alive too.
  • ThreadLocal values on pooled threads. A thread holds its thread-local values while it is alive, and the threads of a pool live as long as the pool.

A leak reproduced with a 64 MB heap

This price service caches every quote under the id of the request that asked for it. Every request has a new id, so no entry is ever read twice or removed:

QuoteService.java Java · QuoteService.java
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;

/**
 * A price service that keeps every quote it has ever served. Each line of standard input is a batch size: the service
 * answers that many requests, each with a new request id, then prints how many quotes it holds.
 */
public class QuoteService {
    record Quote(String sku, long pricePaise, byte[] terms) {}

    // The leak: the cache is keyed by request id, and every request has a new id, so no entry is read twice or removed.
    static final Map<String, Quote> CACHE = new HashMap<>();
    static long served;

    static Quote quote(String requestId, String sku) {
        return CACHE.computeIfAbsent(requestId, id -> new Quote(sku, 49_900, new byte[1_000]));
    }

    public static void main(String[] args) throws Exception {
        var in = new BufferedReader(new InputStreamReader(System.in));
        for (String line; (line = in.readLine()) != null; ) {
            long batch = Long.parseLong(line.trim());
            for (long i = 0; i < batch; i++) {
                served++;
                quote("req-" + served, "SKU-" + served % 500);
            }
            System.out.println(served + " requests served, " + CACHE.size() + " quotes cached");
        }
    }
}
100 batches of 10,000 requests in a 64 MB heap Python · oom_session.py
from session import run

# The leaking service with a 64 MB heap: 100 batches of 10,000 requests, more than the heap can hold.
# -XX:+ExitOnOutOfMemoryError makes the JVM exit at the first OutOfMemoryError, with exit status 3.
run("""
javac -d out QuoteService.java
yes 10000 | head -n 100 | java -Xmx64m -XX:+ExitOnOutOfMemoryError -cp out QuoteService
""", expect_failure=["yes 10000 | head -n 100 | java -Xmx64m -XX:+ExitOnOutOfMemoryError -cp out QuoteService"])

Output

# JDK in use: openjdk version "25.0.4.1" LTS
$ javac -d out QuoteService.java
$ yes 10000 | head -n 100 | java -Xmx64m -XX:+ExitOnOutOfMemoryError -cp out QuoteService
10000 requests served, 10000 quotes cached
20000 requests served, 20000 quotes cached
30000 requests served, 30000 quotes cached
40000 requests served, 40000 quotes cached
50000 requests served, 50000 quotes cached
Terminating due to java.lang.OutOfMemoryError: Java heap space
[exit status 3]

This output changes from run to run: The heap runs out within one batch of the same point, but which batch it is can change from run to run.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 oom_session.py

The service answered 50,000 requests in this run, and the JVM stopped at the first OutOfMemoryError because -XX:+ExitOnOutOfMemoryError tells it to, with exit status 3. The failure point can be estimated before running it. Each request leaves behind a 1,000-byte array (1,016 bytes with its header), two short strings for the request id and the SKU (about 50 bytes each, with their arrays), a map entry of 32 bytes and a Quote of 32 bytes: about 1,180 bytes. A 64 MB heap holds about 57,000 of those, and the service died between 50,000 and 60,000 requests.

Two class histograms, 20,000 requests apart

One histogram is a list; two are evidence. This session starts the service in one terminal, sends it batches of requests, and takes a class histogram from a second terminal after each batch:

Class histograms of the running service, and what grew between them Python · histogram_session.py
from session import background, read, run

# Two class histograms of the leaking service, 20,000 requests apart, and what grew between them.
run("javac -d out QuoteService.java")
print("# Terminal 1: the service, with a 64 MB heap. Each line typed is a batch of requests.")
service = background("java -Xmx64m -cp out QuoteService")
service.send("20000")
print("# Terminal 2: find the process, then save a class histogram")
run("""
jcmd -l | grep QuoteService
jcmd <pid> GC.class_histogram > before.txt
head -n 7 before.txt
""", version=False)
print("# Terminal 1")
service.send("20000")
print("# Terminal 2")
run("""
jcmd <pid> GC.class_histogram > after.txt
head -n 7 after.txt
""", version=False)
print("# Terminal 1")
service.close()


def rows(text):
    """Class name -> (instances, bytes), from the rows of a histogram ("   1:   77378   22293936  [B (java.base@25)")."""
    out = {}
    for line in text.splitlines():
        parts = line.split()
        if len(parts) >= 4 and parts[0].endswith(":") and parts[0][:-1].isdigit():
            out[parts[3]] = (int(parts[1]), int(parts[2]))
    return out


before, after = rows(read("before.txt")), rows(read("after.txt"))
growth = sorted(((after[c][1] - before.get(c, (0, 0))[1], after[c][0] - before.get(c, (0, 0))[0], c) for c in after), reverse=True)
print("# What grew between the two histograms: the five largest increases, in bytes")
for added_bytes, added, name in growth[:5]:
    print(f"{added:+10,} instances {added_bytes:+14,} bytes  {name}")

Output

# JDK in use: openjdk version "25.0.4.1" LTS
$ javac -d out QuoteService.java
# Terminal 1: the service, with a 64 MB heap. Each line typed is a batch of requests.
$ java -Xmx64m -cp out QuoteService
> 20000
20000 requests served, 20000 quotes cached
# Terminal 2: find the process, then save a class histogram
$ jcmd -l | grep QuoteService
<pid> QuoteService
$ jcmd <pid> GC.class_histogram > before.txt
$ head -n 7 before.txt
<pid>:
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:         68559       21753032  [B (java.base@25.0.4.1)
   2:         48480        1163520  java.lang.String (java.base@25.0.4.1)
   3:         21013         672416  java.util.HashMap$Node (java.base@25.0.4.1)
   4:         20000         640000  QuoteService$Quote
# Terminal 1
> 20000
40000 requests served, 40000 quotes cached
# Terminal 2
$ jcmd <pid> GC.class_histogram > after.txt
$ head -n 7 after.txt
<pid>:
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:        128559       43193032  [B (java.base@25.0.4.1)
   2:         88480        2123520  java.lang.String (java.base@25.0.4.1)
   3:         41013        1312416  java.util.HashMap$Node (java.base@25.0.4.1)
   4:         40000        1280000  QuoteService$Quote
# Terminal 1
> (end of input)
# What grew between the two histograms: the five largest increases, in bytes
   +60,000 instances    +21,440,000 bytes  [B
   +40,000 instances       +960,000 bytes  java.lang.String
   +20,000 instances       +640,000 bytes  java.util.HashMap$Node
   +20,000 instances       +640,000 bytes  QuoteService$Quote
        +0 instances       +131,072 bytes  [Ljava.util.HashMap$Node;

This output changes from run to run: The JDK's own objects change the first rows by a few hundred bytes from run to run; the growth below them does not change.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 histogram_session.py

jcmd -l lists the JVMs running on the machine, with their process ids and main classes; jcmd works only on the same machine, as the user who started the JVM. GC.class_histogram counts only live objects unless you add -all, then prints one row per class: instances, bytes and the class name ([B is byte[]). Between the two histograms, the 20,000 new requests added exactly 20,000 QuoteService$Quote objects, 20,000 map entries, 40,000 strings and 60,000 byte arrays: three arrays per request, one for the terms and one inside each string. Whatever grows by a fixed amount per request, and never shrinks, is the leak. The other rows tell you less: the JDK’s own objects show up in every histogram and change little.

Text Diff Paste two saved histograms side by side to see which rows changed.

What each OutOfMemoryError message points to

The message after java.lang.OutOfMemoryError: names the memory that ran out. OOM errors, as these errors are usually called, come in seven common kinds, each with its own limit and its own first check:

Message What ran out First check
Java heap space The heap, up to -Xmx A leak (histograms) or a heap too small for the live data
GC overhead limit exceeded Progress: collections ran almost all the time and recovered almost nothing As for heap space: the live data barely fits
Metaspace Class metadata, up to -XX:MaxMetaspaceSize Classes generated or loaded again and again
Compressed class space Class structures, up to -XX:CompressedClassSpaceSize As for Metaspace
Requested array size exceeds VM limit Nothing: the array is larger than any JVM allows The size calculation
Cannot reserve … bytes of direct buffer memory Direct buffers, up to -XX:MaxDirectMemorySize Buffers allocated and not released
unable to create native thread Threads the operating system allows Thread pools without a limit

Check yourself

Check yourself

4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 4 Two class histograms of a service were taken an hour apart. Which row is the best lead for a leak?

    Choose one answer.

    Show the answer to question 1

    Answer: A class whose instance count rose by a fixed amount for every request served in that hour

    A leak keeps a fixed amount per unit of work, so the class that grows with the work, and never shrinks, is the lead. byte[] is usually the largest row in any Java program because strings and buffers use it; being large says nothing about growing.

  2. Question 2 of 4 Each request leaves about 1,180 bytes in the cache. About how many requests fit in a 128 MB heap (134,217,728 bytes) before the heap runs out? Ignore everything else in the heap.

    Type a number.

    Show the answer to question 2

    Answer: 113700 requests (anything from 105700 to 121700 counts)

    134,217,728 ÷ 1,180 is about 113,700. The real number is somewhat lower, because the JDK's own objects, the map's table and the temporary objects of each batch need room too.

  3. Question 3 of 4 A JVM fails with java.lang.OutOfMemoryError: Metaspace. What ran out?

    Choose one answer.

    Show the answer to question 3

    Answer: The native memory that holds class metadata

    Class metadata lives in Metaspace, outside the heap, up to -XX:MaxMetaspaceSize when that flag is set. Classes that are generated or loaded again and again, without their class loader ever being collected, fill it.

  4. Question 4 of 4 Which of these keep objects alive longer than they are needed? Choose every one that does.

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • A ThreadLocal set on each request, on the threads of a fixed pool, without remove()
    • A static HashMap keyed by request id, with no removal
    • A listener added to a long-lived event source and never removed

    The map, the pooled threads and the event source all live as long as the program, so whatever they reference stays. A local variable stops counting as a reference once its method returns, so its array becomes unreachable.

Heap dumps and the path to a leak, step by step Pro: opens with a Pro, Premium or Ultimate pass

A histogram says which classes grow; a heap dump says who holds them. This part captures dumps in three ways, puts numbers on their size, and follows the chain of references from one leaked array back to the static field that keeps it alive.

In this part:

  • Capturing a dump on demand and on OutOfMemoryError
  • Following the reference chain through the dump
  • Shallow and retained size, worked out

3 runnable examples (Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Each OutOfMemoryError reproduced, with the fix that holds Pro: opens with a Pro, Premium or Ultimate pass

Every message in the table above can be produced on purpose in a fraction of a second, which is the fastest way to learn what each limit means. This part reproduces five of them, measures how long a bigger heap delays a leak, and works out the thread limit with numbers instead of crashing a machine.

In this part:

  • Five limits, five messages, one program
  • A bigger heap only buys time
  • When no native thread can be created, with numbers

3 runnable examples (Java, Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Fixing the leak, proving the fix, and a leak to find yourself Pro: opens with a Pro, Premium or Ultimate pass

A fix is finished when the measurement that showed the leak shows none. This part fixes the quote service, proves it with the same histograms, reproduces a ThreadLocal leak on a thread pool, and ends with a leak for you to find.

In this part:

  • Cache by the key that repeats, with a limit
  • A ThreadLocal that outlives its request
  • Your turn: the page service that keeps every page

6 runnable examples (Java, Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    Java has a garbage collector. How can a Java program still leak memory?

    The garbage collector reclaims only objects that nothing can reach from a GC root, such as a static field, the stack of a live thread or a class loader. A memory leak in Java is an object that the program will never use again but still references, so the collector has to keep it. Typical cases are a static map filled on every request and never emptied, a cache without a size limit, a listener registered with a long-lived event source and never removed, and a ThreadLocal set on pooled threads without remove(). The symptom is a heap that is a little fuller after every collection, collections that come more often, and finally OutOfMemoryError: Java heap space. Two class histograms taken some time apart (jcmd <pid> GC.class_histogram) show which class grows with the work.

  2. Mid to senior

    A payment service's heap after each full collection rises by about 40 MB a day, and the service fails with OutOfMemoryError about every nine days, so the team restarts it weekly. How would you find the leak in production without hurting latency, and how would you prove the fix?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Senior to staff

    An application server runs several web applications in one JVM. After about ten redeployments of one of them, the JVM fails with OutOfMemoryError: Metaspace. What is likely happening, how do you prove it, and what do you change?

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    A teammate wants to double -Xmx from 2 GB to 4 GB because the service fails with OutOfMemoryError: Java heap space every few hours. When is that the right fix, and what data would you ask for first?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A leak is a reference that outlives its use; the collector cannot reclaim anything that is still reachable.
  • Two class histograms taken some time apart show which classes grow with the work: that row is the lead.
  • A heap dump, or an old-object recording with path-to-gc-roots=true, shows the chain from the leaked object to the field that holds it.
  • OOM errors name the memory that ran out in their message, and each kind has its own limit and its own fix.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.