OQL — Object Query Language

hprof-analyzer ships a SQL-flavoured query language, modelled on Eclipse MAT's OQL, for interrogating a heap dump. You can run queries three ways:

  1. query subcommand — parse the dump once and print result tables to the terminal (or start an interactive REPL). Fast; no full report is built.
  2. --query / --query-file on the main command — run queries and fold their results into a full Markdown/HTML/JSON report.
  3. [[query]] entries in a config file — persist a query set alongside a dump so every report includes the same views.

Every example below was run against tests/fixtures/dump_4_philosophers.hprof.


Quick start

$ hprof-analyzer query heap.hprof --query "SELECT COUNT(*) FROM java.lang.String"
COUNT(*)
24760
(1 row)

Interactive REPL (tab-completion for keywords, class names, attributes, and the -- @viz directive):

$ hprof-analyzer query heap.hprof --repl
oql> SELECT @displayName FROM java.lang.Thread LIMIT 3

Multiple queries in one run (repeat --query, or one query per line in a file):

$ hprof-analyzer query heap.hprof \
    --query "SELECT COUNT(*) FROM java.lang.String" \
    --query "SELECT COUNT(*) FROM java.lang.Thread"

Grammar surface

SELECT [DISTINCT] [OBJECTS] <select-list> [AS RETAINED SET]
  FROM [OBJECTS] <class | "regex" | INSTANCEOF class | ( subquery )> [alias]
  [WHERE <predicate>]
  [ORDER BY <expr> [ASC|DESC]]
  [LIMIT <n>]
[UNION <select> ...]

SELECT list

¹ Requires the full analysis pipeline — see the caveat below.

FROM source

FormMeaning
FROM java.lang.Stringexact class name
FROM OBJECTS java.lang.Stringidentical to the above (OBJECTS is an optional MAT no-op keyword)
FROM INSTANCEOF java.lang.Objectthe class and all subclasses
FROM "java\.lang\..*"class-name regex (double-quoted)
FROM ( SELECT … ) ssubquery as the row source

Predicates (WHERE)

Ordering, limiting, union

Not supported

eval(...) and ${snapshot} reflection are MAT features we do not implement.


GROUP BY and HAVING

GROUP BY groups rows by one or more expressions and applies aggregate functions per group. HAVING filters groups after aggregation (analogous to WHERE filtering rows before aggregation).

-- Count instances per class
SELECT @displayName, COUNT(*) AS n
FROM INSTANCEOF java.lang.Object
GROUP BY @displayName
ORDER BY n DESC
LIMIT 10

-- Only classes with many instances
SELECT @displayName, COUNT(*) AS n
FROM INSTANCEOF java.lang.Object
GROUP BY @displayName
HAVING COUNT(*) > 100
ORDER BY n DESC

toString(s) can appear as a GROUP BY key to find duplicate String values:

-- Top duplicate String values (the `duplicate-strings` named query)
SELECT toString(s) AS value, COUNT(*) AS count
FROM java.lang.String s
GROUP BY toString(s)
HAVING COUNT(*) > 1
ORDER BY count DESC
LIMIT 30

Only COUNT(*) and COUNT(toString(s)) are supported as aggregates in a toString-query — SUM, AVG, MIN, MAX over an attribute that is only available at decode time are rejected with an actionable error.


CASE WHEN

CASE WHEN … THEN … ELSE … END returns different values based on conditions. It can be used in SELECT and GROUP BY expressions.

-- Bucket objects by size class
SELECT
  CASE
    WHEN @usedHeapSize > 10000 THEN 'large'
    WHEN @usedHeapSize > 1000  THEN 'medium'
    ELSE 'small'
  END AS size_class,
  COUNT(*) AS n
FROM INSTANCEOF java.lang.Object
GROUP BY
  CASE
    WHEN @usedHeapSize > 10000 THEN 'large'
    WHEN @usedHeapSize > 1000  THEN 'medium'
    ELSE 'small'
  END
ORDER BY n DESC

COALESCE, NULLIF, BETWEEN

COALESCE(e1, e2, …) returns the first non-null argument. NULLIF(e1, e2) returns null when e1 = e2, otherwise returns e1. e BETWEEN a AND b is equivalent to e >= a AND e <= b.

-- Replace null with a default
SELECT COALESCE(toString(s), '<null>') AS val FROM java.lang.String s

-- Filter to objects of moderate size
SELECT COUNT(*) FROM java.lang.Object
WHERE @usedHeapSize BETWEEN 100 AND 1000

EXISTS subquery

EXISTS (SELECT …) is true when the inner query returns at least one row. It is non-correlated: the inner query runs once before the outer scan. If EXISTS evaluates to false, the outer query returns 0 rows immediately. Because the subquery is not re-evaluated per outer row, EXISTS cannot filter by a per-row condition — it either admits all outer rows or none.

-- Run analysis only when leaked connections exist
SELECT COUNT(*) FROM java.lang.Object
WHERE EXISTS (SELECT * FROM com.example.Connection c WHERE c.closed = false)

INTERSECT and EXCEPT

INTERSECT returns rows present in both result sets. EXCEPT returns rows present in the first set but not the second.

-- Class names in both cache and pool namespaces
SELECT @displayName FROM "com\.example\.cache\..*"
INTERSECT
SELECT @displayName FROM "com\.example\.pool\..*"

-- Strings only in the large set, not the small set
SELECT toString(s) FROM java.lang.String s WHERE s.count > 100
EXCEPT
SELECT toString(s) FROM java.lang.String s WHERE s.count > 1000

Array indexing and slicing

value[i] returns the element at index i (0-based). value[i:j] returns a slice of elements from index i up to (but not including) j. Out-of-bounds accesses return null.

-- First element of each array (null if empty or out of bounds)
SELECT @objectId, value[0] AS first FROM byte[] b LIMIT 10

-- Slice of elements
SELECT @objectId, value[1:4] AS mid FROM byte[] b LIMIT 10

Worked examples

Counting and filtering

$ hprof-analyzer query heap.hprof --query "SELECT COUNT(*) FROM java.lang.String"
COUNT(*)
24760

$ hprof-analyzer query heap.hprof --query "SELECT COUNT(*) FROM INSTANCEOF java.lang.Object"
COUNT(*)
134277

$ hprof-analyzer query heap.hprof --query 'SELECT COUNT(*) FROM "java\.lang\..*"'
COUNT(*)
169980

FROM OBJECTS <class> is identical to FROM <class>:

$ hprof-analyzer query heap.hprof --query "SELECT COUNT(*) FROM OBJECTS java.lang.String"
COUNT(*)
24760

Attributes, ordering, and limits

$ hprof-analyzer query heap.hprof \
    --query "SELECT @displayName, @usedHeapSize FROM java.lang.Thread ORDER BY @usedHeapSize DESC LIMIT 3"
@displayName | @usedHeapSize
java.lang.Thread | 104
java.lang.Thread | 104
java.lang.Thread | 104
(3 rows)

Column aliases

$ hprof-analyzer query heap.hprof \
    --query "SELECT @usedHeapSize AS bytes FROM java.lang.String LIMIT 1"
bytes
24

DISTINCT

DISTINCT de-duplicates whole result rows (after any UNION, before LIMIT):

$ hprof-analyzer query heap.hprof \
    --query "SELECT DISTINCT @displayName FROM java.lang.Thread"
@displayName
java.lang.Thread
(1 row)

Aggregates

$ hprof-analyzer query heap.hprof --query "SELECT MEDIAN(@usedHeapSize) FROM java.lang.String"
MEDIAN(@usedHeapSize)
24

$ hprof-analyzer query heap.hprof --query "SELECT PERCENTILE(@usedHeapSize, 95) FROM java.lang.String"
PERCENTILE(95)(@usedHeapSize)
24

PERCENTILE's second argument is an integer percentile (95), not a fraction (0.95).

Membership with IN

$ hprof-analyzer query heap.hprof \
    --query 'SELECT COUNT(*) FROM java.lang.String s WHERE s IN (SELECT @objectAddress FROM java.lang.String)'
COUNT(*)
24760

UNION

$ hprof-analyzer query heap.hprof \
    --query "SELECT @displayName FROM java.lang.Thread LIMIT 2 UNION SELECT @displayName FROM java.lang.String LIMIT 2"

Visualization directives (-- @viz)

Prefix a query with a -- @viz comment line to declare how its result should be drawn in the report (HTML rich charts, Markdown ASCII bars). The directive is a stripped comment — it is not part of the OQL grammar and has no effect on the plain query-subcommand table output.

-- @viz <kind> [label=<col>] [value=<col>] [cap=<n>] [title="<text>"] [name="<text>"]
SELECT ...
FieldMeaning
<kind>table (default), histogram, piechart, or treemap
label=<col>column supplying slice/bar labels (defaults to first non-numeric column)
value=<col>column supplying numeric magnitudes (defaults to first numeric column)
cap=<n>limit the chart to the first n rows (the table still shows all)
title="<text>"heading rendered above the chart; quote it for multiple words
name="<text>"display name for the whole query block, replacing the auto q{N} label

Column names may be attributes (@usedHeapSize), aliases, or positional. A title=/name= value may be a single bare word or a "quoted string" for multiple words. A malformed directive or an unchartable result falls back to a plain table with a warning — it never hard-fails the query. Note that name= applies even when the chart itself cannot be drawn (it only labels the block).

Example (as it appears in a --query-file or config entry):

-- @viz histogram title="Threads by heap" name=threads label=@displayName value=@usedHeapSize cap=10
SELECT @displayName, @usedHeapSize FROM java.lang.Thread ORDER BY @usedHeapSize DESC

CLI gotcha: use the --query= equals form for directives

clap treats a leading -- in an argument value as the start of a flag, so --query "-- @viz …" is rejected:

$ hprof-analyzer query heap.hprof --query "-- @viz histogram
SELECT * FROM java.lang.String LIMIT 1"
error: unexpected argument '-- @viz histogram …' found

Use the attached --query= form (or put the directive in a --query-file / config entry, where it is not a shell argument):

$ hprof-analyzer query heap.hprof --query="-- @viz histogram label=@displayName value=@usedHeapSize
SELECT @displayName, @usedHeapSize FROM java.lang.Thread ORDER BY @usedHeapSize DESC LIMIT 10"

Two execution paths: query-only vs. full report

The query subcommand runs a query-only path: it parses the dump in a few streaming passes and answers queries directly, without building the dominator tree, retained sizes, or the reference graph. This is fast and low-memory, but some attributes and functions are unavailable there:

Needs the full pipelinequery-only error
@retainedHeapSize, AS RETAINED SET"…requires the full analysis pipeline…"
dominators(x), dominatorof(x)"dominator queries … require the full analysis pipeline…"
@inbounds, @outbounds, path(a, b)reference-graph queries need the full path

To use those, run the full report command (no query subcommand) and pass --query / --query-file. The report build produces the retained sizes, dominator tree, and reference graph the query then reads:

$ hprof-analyzer heap.hprof report.html \
    --query "SELECT @displayName, @retainedHeapSize FROM java.lang.Thread ORDER BY @retainedHeapSize DESC LIMIT 20"

Query files and config

--query-file — one query per line; blank lines and # comments are skipped. A -- @viz line attaches to the query on the following line:

# threads-by-retained.oql
-- @viz histogram label=@displayName value=@retainedHeapSize cap=15
SELECT @displayName, @retainedHeapSize FROM java.lang.Thread ORDER BY @retainedHeapSize DESC
SELECT COUNT(*) FROM java.lang.String
$ hprof-analyzer heap.hprof report.html --query-file threads-by-retained.oql

Config [[query]] entries — a .hprof-analyzer.toml in the working directory (or $HOME/.config/hprof-analyzer/collections.toml) is auto-discovered:

[[query]]
oql = """
-- @viz piechart label=@displayName value=@retainedHeapSize
SELECT @displayName, @retainedHeapSize FROM java.lang.Thread ORDER BY @retainedHeapSize DESC
"""

[[query]]
oql = "SELECT COUNT(*) FROM INSTANCEOF java.util.Map"

Point at a specific file with --collection-config <path>.


server subcommand

$ hprof-analyzer server heap.hprof [--port 7070]

Starts an HTTP server on 127.0.0.1 (loopback only, default port 7070) that exposes OQL query execution and report sections as JSON/Markdown endpoints. The server prints a startup banner listing every available endpoint.

Endpoints

MethodPathDescription
GET/Welcome + endpoint catalog
GET/version{"version":…,"endpoints":[…]}
GET/status`{"status":"ready"\"analyzing"\"not_started"}`
POST/analyzeTrigger full analysis
POST/Run OQL → JSON QueryResult
POST/queryAlias of POST /
POST/streamRun OQL → NDJSON
GET/helpOQL language reference JSON
GET/schemaJSON Schema for QueryResult
GET/reportFull report JSON (or ?format=md)
GET/report/overviewSystemOverview JSON (or ?format=md)
GET/report/leaksLeakSuspects JSON (or ?format=md)
GET/report/topTopConsumers JSON (or ?format=md)
GET/report/threadsThreadOverview JSON (or ?format=md)

Format negotiation

Append ?format=md to any /report/… endpoint to receive Markdown instead of JSON:

$ curl -s 'http://127.0.0.1:7070/report?format=md'
$ curl -s 'http://127.0.0.1:7070/report/leaks?format=md'

Lazy analysis

The first GET /report/… request automatically triggers analysis if it has not been started yet. While analysis is running the server returns 202 Accepted. Use GET /status to poll until the status is "ready", or trigger analysis explicitly with POST /analyze before issuing report requests.

POST /analyze always returns 200 with a JSON body describing what happened:

{"ok":true,"status":"started"}          // analysis kicked off now
{"ok":true,"status":"already_running"}  // already in progress
{"ok":true,"status":"already_done"}     // nothing to do

Retained sizes and full-analysis queries

At startup the server runs a query-only parse (fast, no dominator tree). This means @retainedHeapSize, dominators(), dominatorof(), @inbounds, and @outbounds are not available until the full analysis has been completed via POST /analyze (or an implicit trigger from a GET /report/… request).

Once analysis is done the OQL engine automatically upgrades to the full pipeline, and retained-size queries work without restarting the server.

Limiting result rows

Append ?limit=N to any /report/leaks, /report/top, or /report/threads endpoint to cap the number of rows in the response:

$ curl -s 'http://127.0.0.1:7070/report/leaks?limit=5'
$ curl -s 'http://127.0.0.1:7070/report/leaks?limit=5&format=md'

OQL body format

POST / (and its alias POST /query) accept either a raw SQL string or a JSON object in the request body:

# Plain SQL string
$ curl -s http://127.0.0.1:7070/ -d 'SELECT COUNT(*) FROM java.lang.String'

# JSON body (useful for embedding queries with special characters)
$ curl -s http://127.0.0.1:7070/ \
    -H 'Content-Type: application/json' \
    -d '{"query":"SELECT COUNT(*) FROM java.lang.String","limit":10}'

The JSON body accepts:

FieldTypeDescription
querystringOQL query text (required)
limitintegerCap result rows (optional; overrides LIMIT clause)

Request bodies larger than 64 KiB are rejected with HTTP 413.

NDJSON streaming (POST /stream)

POST /stream runs an OQL query and returns results as Newline-Delimited JSON: one JSON object per line, flushed as rows are produced. This is useful for large result sets or streaming to a pipeline.

$ curl -s http://127.0.0.1:7070/stream \
    -d 'SELECT @objectId, @usedHeapSize FROM java.lang.String' \
    | head -5
{"@objectId":1234,"@usedHeapSize":24}
{"@objectId":1235,"@usedHeapSize":24}
...

Error responses

All endpoints return structured JSON errors:

{"ok":false,"error":{"kind":"query","message":"<reason>"}}
{"ok":false,"error":{"kind":"analysis_failed","message":"<reason>"}}
{"ok":false,"error":{"kind":"not_ready","message":"analysis not started — POST /analyze first"}}
{"ok":false,"error":{"kind":"body_too_large","message":"request body exceeds 65536 bytes"}}

HTTP status codes: 400 for bad queries or missing query field, 404 for unknown paths, 405 for wrong method, 413 for oversized bodies, 503 if a /report/… section is requested before analysis has been triggered.

Example workflow

# Start the server
$ hprof-analyzer server heap.hprof --port 7070 &

# Check current status
$ curl -s http://127.0.0.1:7070/status
{"status":"not_started"}

# Trigger full analysis
$ curl -s -X POST http://127.0.0.1:7070/analyze
{"ok":true,"status":"started"}

# Poll until ready
$ until curl -sf http://127.0.0.1:7070/status | grep -q '"ready"'; do sleep 1; done

# Full report as Markdown
$ curl -s 'http://127.0.0.1:7070/report?format=md'

# Leak suspects as JSON (top 5)
$ curl -s 'http://127.0.0.1:7070/report/leaks?limit=5' | jq .

# Run an OQL query (plain text)
$ curl -s http://127.0.0.1:7070/ -d 'SELECT @displayName FROM java.lang.Thread'

# Run a retained-size query (requires analysis to be done first)
$ curl -s http://127.0.0.1:7070/ \
    -d 'SELECT @displayName, @retainedHeapSize FROM java.lang.Thread ORDER BY @retainedHeapSize DESC LIMIT 5'

# Stream a large result set
$ curl -s http://127.0.0.1:7070/stream \
    -d 'SELECT @objectId, @usedHeapSize FROM java.lang.String' \
    | wc -l

Eclipse MAT OQL compatibility

hprof-analyzer's OQL is modelled on Eclipse MAT's OQL dialect. This section documents what works identically, what works with known differences, and what is not yet supported.

Extensions beyond MAT

These features are available in hprof-analyzer but not in Eclipse MAT's OQL:

FeatureExample
MEDIAN(e), PERCENTILE(e, n) aggregatesMEDIAN(@usedHeapSize)
path(a, b) bounded forward-reachability walkSELECT path(root, target)
-- @viz <type> … visualization directive-- @viz histogram label=class value=bytes
GROUP BY / HAVINGGROUP BY classof(x) HAVING COUNT(*) > 100
Arithmetic expressions in SELECT and WHERE@usedHeapSize * 8 AS bits
System properties snapshot (@systemProperties)SELECT @systemProperties
Reachable-only filter (--reachable-only flag)excludes GC-unreachable objects
Interactive REPL with Tab completion and historyhprof-analyzer query heap.hprof --repl
Report embedding (--query / --query-file)fold OQL results into HTML/MD/JSON report
Named queries library (/run <name>)/run top-classes-by-count

Compatible with MAT

These constructs produce results matching MAT (modulo the reachability note below):

ConstructExample
FROM <class>, FROM OBJECTS <class>FROM java.lang.Thread
FROM INSTANCEOF <class> including subclassesFROM INSTANCEOF java.util.Map
FROM "<regex>" double-quoted class-name regexFROM "java\.util\..*"
FROM (subquery) semi-joinFROM (SELECT * FROM java.lang.Thread) t
WHERE, ORDER BY, LIMIT, UNION, SELECT DISTINCTstandard SQL clauses
LIKE "<regex>" / NOT LIKEWHERE toString(s) LIKE ".error."
WHERE x INSTANCEOF C
SELECT … AS RETAINED SET, SELECT OBJECTS …MAT set-operation markers (no-ops here)
<expr> AS <name> column alias@usedHeapSize AS bytes
COUNT(*), SUM, MIN, MAX, AVG
classof(x), toString(x) (String only), dominators(x), dominatorof(x)
@objectAddress, @objectId, @usedHeapSize, @retainedHeapSize¹, @displayName, @length, @GCRoots
Field paths: s.fieldName, s.a.b
MAT-API method aliases: getObjectAddress(), getUsedHeapSize(), getKey(), getValue(), intValue(), size(), …method → attr/field rewrite

¹ Requires full analysis (hprof-analyzer analyze or POST /analyze on the server).

Differences from MAT

AreaMAThprof-analyzer
Unreachable objectsDiscarded at index timeIncluded in raw scan; MAT ⊆ ours
s.count / s.offset on StringWorks (pre-JDK9 layout)"Unknown field" — use s.value, s.coder, s.hash (modern JDK)
Integer /0Throws ArithmeticExceptionReturns NULL — analyzer never crashes on a bad row
toString() on non-StringCalls JVM toString() via reflectionReturns NULL — static analysis cannot invoke live JVM methods
get(n) indexed accessWorks on arrays/collectionsRejected — dereference the backing field directly (e.g. a.elementData)
SELECT COUNT(*) FROM (subquery)Returns countRejected — aggregate the inner query instead

Not yet supported

ConstructExampleNotes
FROM OBJECTS <decimal-id>FROM OBJECTS 123456Hex address (FROM OBJECTS 0x7f3a) works; decimal object-id lookup requires a new lexer token to distinguish from hex at parse time
s[0] / s[1:3] array element accessSELECT s[0] FROM int[]Parsed and planned; execution returns NULL; blocked on scan-time element capture (memory-intensive design decision)
${snapshot}.getClasses()FROM ${snapshot}.getClasses()Iterating class objects (not heap instances) — needs a new executor path

Silent NULLs (not an error, but worth knowing)

Some field navigations silently return NULL rather than raising an error, because the necessary type information is not available at plan time:

CaseExampleWorkaround
Object-ref field navigationx.parent where parent is typed ObjectField names are known but types are not; the engine cannot auto-rewrite to a ref-walk at plan time. Use @objectAddress and a separate query to look up the referent.
Two-hop collection introspectionx.size on java.util.HashSetHashSet delegates to a backing HashMap; a single-hop field read returns NULL. Use FROM INSTANCEOF java.util.AbstractCollection to reach classes that store size directly.

Both limitations require extending the field-type schema (FieldSchema currently provides names only, not types). They are tracked for a future pass.

--query-file parse error format

When --query-file contains a syntax error, the error should include the filename and 1-based line number so you can jump straight to the offending line:

error: --query-file 'queries.oql': parse error on line 3
  3 | SELEC COUNT(*) FROM java.lang.String
    | ^^^^^ expected SELECT
  hint: did you mean SELECT?

Note: Line numbers in --query-file errors are not yet wired up. The

current output shows the parse error text but without the filename/line

prefix. This will be fixed in a future release.


REPL usage

Start an interactive session with tab-completion and history:

hprof-analyzer query heap.hprof --repl

The prompt shows oql>. Type a query and press Enter to run it. Multi-line queries are supported: press Enter mid-query — the REPL detects an incomplete statement and prompts for more input.

Tab completion

Tab-complete class names, OQL keywords, @ attributes, field names on the most recent result, !<cmd> commands, named queries, and the -- @viz directive.

REPL commands

Commands that start with ! operate on the last query result and never re-run the query.

CommandWhat it does
!help / !help oqlShow command reference or OQL reference
!quitExit the REPL
!lastRe-run the previous query
!countRow count of last result
!plan [--raw] <oql>Show execution plan without scanning the heap
!row [N|first|last|next|prev]Show one row as key=value pairs; navigate with next/prev
!obj <class>#<idx>Inspect a specific heap object (dense index)
!top [N] / !tail [N]First / last N rows (default 10)
!filter <pat>Keep rows matching a substring or /regex/
!sort <col> [desc]Sort result by column
!select <col>…Keep only named columns
!stats [col]Numeric summary: min/max/mean/stddev/p50/p90/p99
!unique <col> [N]Distinct value counts, top N by frequency
!undoRestore result before last shaping command
!save <file>Write result to CSV/TSV/JSON (format by extension)
!set limit NCap rows displayed (0 = unlimited)
!classes [pat]List class names (substring-filtered)
!describe <class>Show fields and types of a class
!reachable / !allRestrict to GC-reachable objects only / include all
!run [<name>]Run a named query (no arg = list all)
!analyzeRun full analysis (enables @retainedHeapSize, dominators)

Browser REPL

An in-browser version of the REPL is available at parttimenerd.github.io/hprof-analyzer/. In server-connected mode, paste the URL printed by hprof-analyzer server heap.hprof and click Connect. All ! commands work the same way.


Differences from Eclipse MAT OQL

Extensions (not in MAT)

FeatureExample
MEDIAN(e), PERCENTILE(e, n) aggregatesMEDIAN(@usedHeapSize)
GROUP BY / HAVINGGROUP BY @displayName HAVING COUNT(*) > 100
CASE WHEN … THEN … ELSE … ENDCASE WHEN @usedHeapSize > 1000 THEN 'large' ELSE 'small' END
INTERSECT / EXCEPTSet operations between result sets
EXISTS (subquery)Guard outer query on inner result
Arithmetic in SELECT and WHERE@usedHeapSize * 8 AS bits
-- @viz <kind> visualization directive-- @viz histogram label=class value=bytes
Interactive REPL with tab-completionhprof-analyzer query heap.hprof --repl
Named queries library (!run <name>)!run top-classes-by-count
Report embedding (--query / --query-file)Fold OQL results into HTML/MD/JSON report

Behavioural differences

AreaMAThprof-analyzer
Unreachable objectsDiscarded at index timeIncluded in raw scan (MAT ⊆ ours)
s.count / s.offset on StringWorks (pre-JDK 9 layout)Unknown field — use s.value, s.coder, s.hash
Integer division by zeroThrows ArithmeticExceptionReturns NULL — never crashes
toString() on non-StringCalls JVM reflectionReturns NULL — static analysis only
get(n) array/collection accessWorksRejected — navigate the backing field directly
eval(…) / ${snapshot}WorksNot implemented

Not yet supported

ConstructNotes
FROM OBJECTS <decimal-id>Hex address (FROM OBJECTS 0x7f3a) works; decimal ID does not yet
s[0] / s[1:3] array element accessParsed; execution returns NULL
${snapshot}.getClasses()Class-object iteration not yet implemented

Use with AI agents

hprof-analyzer exposes an HTTP API via the server subcommand, making it easy to use as a tool for LLM agents (Claude, GPT-4, etc.).

Starting the server

hprof-analyzer server heap.hprof         # listens on 127.0.0.1:7070 by default
hprof-analyzer server heap.hprof --port 8080

The server prints a startup banner with all available endpoints.

Claude Code skill

A ready-made Claude Code skill is included in the repository at skills/hprof-analyzer.md. Load it in a Claude Code session with:

@skills/hprof-analyzer.md

The skill tells Claude how to start the server, write OQL queries, interpret results, and produce useful heap analysis narratives.

Example agent workflow

# 1. Start the server
hprof-analyzer server heap.hprof &

# 2. In Claude Code, load the skill and point it at the server
# @skills/hprof-analyzer.md
# "Connect to http://127.0.0.1:7070 and identify the top memory consumers"

The agent will POST OQL queries to find large retained sets, walk reference chains, and produce a plain-language summary of the heap state.

Key endpoints

MethodPathDescription
POST/Run OQL query → JSON result
POST/streamRun OQL query → NDJSON (streaming)
POST/analyzeTrigger full analysis (enables retained sizes)
GET/status{"status":"ready"|"analyzing"|"not_started"}
GET/report/overviewSystem overview section
GET/report/leaksLeak suspects section
GET/report/topTop consumers section
GET/report/threadsThread overview section

Embedding queries in reports

Via --query / --query-file flags

Pass --query or --query-file to the main hprof-analyzer command to run queries as part of the full report pipeline. These run after the full analysis, so @retainedHeapSize and dominator attributes are available.

hprof-analyzer heap.hprof report.html     --query "SELECT @displayName, @retainedHeapSize FROM java.lang.Thread ORDER BY @retainedHeapSize DESC LIMIT 20"

# Or put queries in a file (one per line, blank lines ignored, # comments OK)
hprof-analyzer heap.hprof report.html --query-file queries.oql

Via [[query]] in a config file

Persist a query set alongside a dump in a .toml config file. Every subsequent report run includes the same queries automatically.

[[query]]
name = "threads-by-retained"
oql = """
-- @viz histogram title="Threads by retained heap" label=@displayName value=@retainedHeapSize
SELECT @displayName, @retainedHeapSize FROM java.lang.Thread ORDER BY @retainedHeapSize DESC LIMIT 20
"""

[[query]]
name = "string-count"
oql = "SELECT COUNT(*) FROM java.lang.String"

Pass the config with --config queries.toml. Named queries also appear in the REPL's !run tab-completion.

Visualization directives (-- @viz)

Prefix any query with a -- @viz comment to declare how its result is rendered in reports:

-- @viz histogram title="Threads by heap" label=@displayName value=@usedHeapSize cap=10
SELECT @displayName, @usedHeapSize FROM java.lang.Thread ORDER BY @usedHeapSize DESC
KindDescription
table (default)Plain data table
histogramHorizontal bar chart (HTML) / ASCII bars (Markdown)
piechartPie chart (HTML only; table in Markdown)
treemapTreemap (HTML only; table in Markdown)