Diagnostics

Heap Dumps Without the Guesswork

The histogram's largest entry was 525 MB of byte arrays. The class responsible was seventh, at 256 KB. Size and ownership are different measurements.

Heap Dumps Without the Guesswork — Diagnostics article cover
On this page

The histogram’s largest entry was 525,718,672 bytes of byte arrays. The class actually responsible for them was seventh, at 256,000 bytes.

Neither number is wrong. They answer different questions, and the tool that prints them sorts on the one nobody is asking about. That gap is the whole of heap analysis: size and ownership are separate measurements, and each instrument that answers one of them declines to answer the other.

The run: JDK 26.0.2.1 (Homebrew build), macOS 26.6.2, G1 with -Xms1g -Xmx1g, a service that files sessions into a static map and never evicts them. Ground truth from the process itself: 8000 sessions retained, 524,288,000 bytes of payload.

The histogram sorts on the wrong number

jcmd <pid> GC.class_histogram is the cheapest thing to reach for, and this is what it said:

 num     #instances         #bytes  class name (module)
   1:         32825      525718672  [B ([email protected])
   2:         23837         572088  java.lang.String ([email protected])
   3:         10985         439400  java.util.LinkedHashMap$Entry ([email protected])
   ...
   7:          8000         256000  dev.jvmscope.LeakyService$Session

[B is byte[], and it is where the memory is. It is also the least actionable line in the output: byte arrays have no owner in a histogram, because a histogram has no edges. The entry that identifies the bug is seventh, and it looks trivial.

The arithmetic is worth doing, because it is the definition rather than an artefact. A Session holds a header, two references and a long: twelve bytes of header, four and four for the references, eight for the timestamp, rounded to 32. Eight thousand of them is exactly 256,000 bytes. That is the shallow size — the object and nothing it points at. What each one retains is a 64 KB payload it alone references, and retained size is a property of the object graph, which a histogram does not contain.

LinkedHashMap$Entry at third place is the closest the output comes to naming the mechanism, and it names a JDK class rather than the field holding it.

Asking costs something

Two variants, two different prices, and the GC log tells them apart.

GC.class_histogram -all=true walks the heap as it stands and added no line to -Xlog:gc at all. The default — live objects only — added one:

[24.261s][info][gc] GC(1492) Pause Full (Heap Inspection Initiated GC) 836M->505M(1024M) 8.647ms

A full collection, requested by the tool, with the cause spelled out. On this heap it cost 8.6 ms and it also confirms the ground truth: 505 MB live after collection, against 500 MB of payload the process says it is holding. On a service where a full collection is the event you are trying to avoid, the diagnostic caused the symptom — which is the kind of thing a GC log makes visible and nothing else does.

The dump itself is a larger bill. jcmd reported it plainly:

Heap dump file created [534592195 bytes in 0.537 secs]

That is the time to write 534 MB. What it cost the application is a different number, and it is not in that line:

Safepoint "HeapDumper", Time since last: 58435583 ns, Reaching safepoint: 2292 ns,
  At safepoint: 261475458 ns, Leaving safepoint: 6500 ns, Total: 261484250 ns

261 ms with every thread stopped, for a 500 MB heap — roughly half the wall-clock time of the write, and the half that shows up in someone’s latency graph. As with every pause, the reported figure and the stall are not the same number.

What JFR answers, and what it does not

The recording was already running at settings=profile, which enables old object sampling. Its entire contribution to the file was 7,707 bytes across 240 events, and a typical one reads:

jdk.OldObjectSample {
  objectSize = 64,0 kB
  objectAge = 24,6 s
  lastKnownHeapUsage = 483,8 MB
  object = [ byte[65536] ]
  root = N/A
  stackTrace = [ dev.jvmscope.LeakyService.main(String[]) line: 51 ]
}

239 of the 240 samples named LeakyService in their stack trace. For the question “where is this allocated”, that is an answer with no dump, no full collection and no measurable pause — which is what the event catalogue is for.

For the question “what retains it”, every one of the 240 reported root = N/A. The sampler tracked the objects and their age; it resolved no reference chain in this run. Knowing the allocation site is often enough to find the bug, and it is not the same thing as knowing the field that holds it — a distinction that matters most in the cases where the allocating code and the retaining code are different code.

Three questions, three prices

What is large: a histogram, cheap, and it will name the payload rather than the owner. Where was it allocated: JFR, effectively free if a recording is already running, precise about the site and silent about the chain. What retains it: the dump, and a dominator tree computed from it — the only instrument that answers ownership, at 534 MB of file and 261 ms of stopped threads.

“Leak suspects” is a hypothesis for exactly this reason. A report built from a dominator tree is answering the third question, and it is doing so on evidence gathered at a moment somebody chose. Growth over time, native memory the heap never sees, and a retained loader holding classes rather than data are each a different instrument again.

The discipline is not to reach for the largest tool first. It is to say which of the three questions you have before paying for an answer to a different one.

Frequently asked

Why does the class histogram not show my leaking class at the top?
Because a histogram reports shallow size, which is the object itself and not what it references. In the run below 8000 session objects occupied 256,000 bytes between them and retained 500 MB of payload. Ranking by shallow size ranks the payload, never the owner.
Is taking a heap dump safe in production?
It is a stop-the-world operation whose cost scales with live data. On a 500 MB heap it stopped every thread for 261 ms. That number is not in the jcmd output or the GC log - it appears only under -Xlog:safepoint, as a HeapDumper entry.

Search the site

Arrow keys to move, Enter to open.