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.
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
What you’ll learn · Before you start
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
On this page
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.
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.
- A GC root: the class loader that loaded QuoteService, which keeps the classes it loaded.
- The class QuoteService, with its static field CACHE.
- Through CACHE: a HashMap that holds one entry per request served.
- Through the map's field table: an array of buckets, HashMap$Node[].
- Through one element of that array: a map entry, HashMap$Node, whose key is a request id such as "req-41307".
- Through the entry's value: a QuoteService$Quote with a SKU and a price.
- 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
staticfield 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.
ThreadLocalvalues 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:
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");
}
}
} 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:
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.
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.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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
-
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.
-
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?
-
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?
-
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?
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
- Troubleshooting Guide, JDK 25: Troubleshoot Memory Leaks (Oracle)
- The jcmd Command (JDK 25 tool reference) (Oracle)
- The java Command (JDK 25 tool reference) (Oracle)
- The jfr Command (JDK 25 tool reference) (Oracle)
- OutOfMemoryError (Java SE 25 API) (Oracle)
- ThreadLocal (Java SE 25 API) (Oracle)
- LinkedHashMap (Java SE 25 API) (Oracle)
- Garbage Collection Tuning Guide, JDK 25: Garbage-First (G1) Garbage Collector (Oracle)
- MethodHandles.Lookup (Java SE 25 API), defineHiddenClass (Oracle)
- WeakHashMap (Java SE 25 API) (Oracle)
- SoftReference (Java SE 25 API) (Oracle)
- Eclipse Memory Analyzer: Shallow vs. Retained Heap (Eclipse Foundation)
- VisualVM (VisualVM project)
- ByteBuffer (Java SE 25 API), allocateDirect (Oracle)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress