Usage

Analyze an existing recording

java -jar jvmlens.jar analyze recording.jfr

The output is markdown: ranked hot paths (application-frame attributed), allocation sites, lock contention, GC pressure, and a one-line heuristic cause.

Each ranked row shows its absolute hit count next to the share (e.g. 99% (1040 samples)), so a high percentage built on one stray hit is obvious. Every section is tagged with how the data was obtained — [sampled] (statistical: CPU and allocation) or [measured] (exact: lock blocked time, GC) — so the reliability of each signal is explicit.

Reading the rows:

  • Source-line anchoring — a hot-path row’s teaser lists the top leaves where time actually goes with counts, each carrying its source line (com.example.Svc.compute:88 30/168); allocation sites carry the allocation call-site’s line (:120 · byte[] 4.2 GB). The locator turns "diagnose → find the spot → edit" into one step for a coding agent.

  • Leaf confidence — when no single leaf holds more than ~20% of a path’s samples the teaser is flagged ⚠ diffuse, so you don’t chase a frame that isn’t where the cost is.

  • Allocation confidence — a short recording yields few allocation samples; a ⚠ Only N allocation samples caveat means the per-site byte shares are noisy (the total bytes stay reliable).

  • Escape-analysis caveat — an allocation site dominated by a boxed primitive or a captured lambda is tagged ⚠ … may be scalar-replaced (escape analysis): JFR samples the allocation, but C2 can eliminate a non-escaping one in steady state, so the sampled bytes may be a false lever. Verify any candidate fix with JMH -prof gc (exact bytes/op) before acting.

Differential analysis (before → after)

The agent optimize→measure loop — "did the fix work, what changed" — is a single flag. -b / --baseline diffs <file.jfr> (the after) against a baseline (the before) and names what moved, instead of making you eyeball two summaries:

java -jar jvmlens.jar analyze --baseline before.jfr after.jfr
java -jar jvmlens.jar analyze -b before.jfr after.jfr -f prompt   # wrapped for an LLM
# JVM profile diff (before.jfr → after.jfr)
## Totals
- Exec samples: 236 → 691 (+455, +193%)
- Allocation: 9.6 GB → 3.4 GB (-6.2 GB, -64%)
- GC pause: 1663 ms → 35 ms (-1628 ms, -98%)
## Allocation sites
- `GoFmt.floatString` — 517.6 KB → 299.8 KB (▼ 42%) [share 22%→51%]

It diffs the totals (incl. total allocation bytes — the absolute memory anchor), hot paths, allocation sites, locks, and every extended section. Each row is anchored on its absolute weight (bytes / ms / samples) with the direction (▲/▼) and the share change as secondary context, ranked by the size of the absolute change; NEW / GONE are called out, and rows that barely moved drop. Anchoring on absolute matters: in an optimize loop the total shrinks, so a site whose absolute bytes fell can show a rising share — share alone would mislabel a real win as a regression. -a/-x scoping applies to both recordings.

When a fix extracts a helper method, one allocation drop splits across two rows (the caller falls, a NEW callee appears) — which can read like a regression at a glance. The diff adds an Allocation by type (rollup) block that sums sites by their declaring type, so the net is one line (e.g. GoFmt.* — 7.6 GB → 5.8 GB (▼ 24%) [2 methods]); it shows only types with ≥2 contributing methods and a real net change.

CI perf-gate (--assert)

Add --assert "<rules>" (with --baseline) and jvmlens exits non-zero on regression — a backend-free perf gate for a PR, ground the SaaS APMs can’t take (they own prod, not the pull request). Rules are comma-separated metric < threshold:

Metric Fails when

gc-ms

the after GC pause time (ms) is not under the limit

gc-pct

GC pause increased by more than the limit (%)

alloc-pct

total allocation bytes increased by more than the limit (%) — the absolute memory gate

oldobj-delta

retained (old-object) samples grew by more than the limit

regression-pp

a hot path’s share rose by more than the limit (percentage points)

new-hotpath-pp

a new hot path appears at/above the limit (% share)

Prefer the absolute gates (gc-, alloc-pct, oldobj-delta) for memory/GC; the -pp ones are share-based and can shuffle when the leader shrinks in an optimize loop.

java -jar jvmlens.jar analyze -b before.jfr after.jfr \
  --assert "gc-pct < 10, regression-pp < 5, new-hotpath-pp < 20" || echo "perf regressed"

Each rule prints / with the actual value; the process exits 1 if any rule fails, 0 if all pass (2 for bad arguments) — drop it straight into a CI step.

JMH benchmarks (a directory of forks)

Optimizing a JVM library is almost always a JMH loop, and JMH’s -prof jfr writes one .jfr per fork (often all named profile.jfr in per-benchmark subdirs). Point analyze (and --baseline) at a directory and jvmlens finds every .jfr under it and merges the forks into one summary, so the signal isn’t split:

java -jar benchmarks.jar -prof "jfr:dir=/tmp/run-after"     # JMH writes per-fork .jfr there
java -jar jvmlens.jar analyze /tmp/run-after -a com.example       # merged summary of all forks

# before/after across two JMH runs — the diff header uses the directory names, so it stays
# unambiguous even though every fork file is `profile.jfr`:
java -jar jvmlens.jar analyze --baseline /tmp/run-before /tmp/run-after \
  --assert "alloc-pct < 0, gc-pct < 10"

(JMH records the whole fork including warm-up; for measurement-only signal, configure JMH to delay the recording past warm-up, compare steady-state runs, or pass --skip-warmup <ms> to analyze — it drops the first <ms> of each recording, measured per file from that file’s earliest event, so each fork’s own warm-up is trimmed and hot paths reflect steady state.)

Or skip the separate step entirely with the JMH profiler plugin — it records the fork and prints the summary inline when the trial ends. Put jvmlens-jmh.jar (a tiny engine+profiler jar, no Spring/picocli/jmh) on the benchmark’s classpath and:

java -cp benchmarks.jar:jvmlens-jmh.jar org.openjdk.jmh.Main \
  -prof "org.alexmond.jvmlens.jmh.JvmlensProfiler:appPackage=com.example;report=cpu"

appPackage (+-separate several) scopes to your code; report focuses the summary. An unknown option key is a hard error with a did-you-mean (no silent misconfiguration). Socket I/O events are dropped by default (on a JMH fork the only socket traffic is the harness control socket — a rotating localhost:<port> that would otherwise show up as External I/O and skew the correlation); pass socketio=true to keep them. If the run also enables JMH’s GC profiler (-prof gc), the summary is headed with JMH’s exact gc.alloc.rate.norm (bytes/op) next to jvmlens’s sampled per-site bytes, so the measured rate and the attribution sit together. The plugin runs in JMH’s host process and reuses the same engine as analyze.

For a before→after diff entirely inside JMH — no separate analyze — keep the fork’s recording with keep=<path> (so it can seed the next run’s baseline) and diff against a prior one with baseline=<prev.jfr> (the profiler prints the change report instead of the summary):

# run 1 (baseline): keep the recording
... -prof "org.alexmond.jvmlens.jmh.JvmlensProfiler:appPackage=com.example;keep=/tmp/before.jfr"
# run 2 (after the fix): print the diff vs run 1
... -prof "org.alexmond.jvmlens.jmh.JvmlensProfiler:appPackage=com.example;baseline=/tmp/before.jfr"

Add JMH’s GC profiler (-prof gc) to both runs and the baseline= run prints a measured allocation A/B verdict above the sampled diff — JMH’s exact gc.alloc.rate.norm before→after with a significance call (the change is SIGNIFICANT only if the relative Δ exceeds the combined noise band and the confidence intervals don’t overlap), and a ⚠ when the sampled total Δ disagrees with the measured one (JIT elision / sampling redistribution). Use -f 2+ so the cross-fork variance is real — a single fork badly understates it. This gates the optimize loop on measured significance instead of a sampled delta that may be a phantom.

The same verdict adds a dispersion line: a genuine structural allocation removal doesn’t just lower the mean, it eliminates the GC-sampling variance that was that allocation, so the cross-fork band collapses (±17,200 → ±35 /op, variance collapsed ~500× is a strong real-win signal the mean alone can’t give). And once bytes/op goes near-deterministic it prints a stop signal — further allocation tuning has diminishing returns; the residual is intrinsic floor, pivot off allocation. The diff also hedges a CPU hot-path row whose absolute samples rose while the total exec samples fell (possible sampling redistribution — total samples fell N%), so a share that climbed only because the rest of the profile shrank isn’t misread as a regression.

A measured throughput A/B verdict sits next to the allocation one — the CPU analog — gating on JMH’s exact primary score (ops/s, us/op, …) with the same significance test. Its job is to stop a sampled hot-path swing being read as a speedup: if a hot-path share moved a lot but wall-clock throughput is flat, it says so explicitly (the sampled hot-path share moved (executeArguments 37%→18%) but throughput is flat — a CPU-share shift is not a speedup; the win is in allocation/GC). Because the inline baseline= matches a single benchmark method, a baseline JFR recorded for a different method is detected and the measured verdict is skipped with a warning rather than emitting confident wrong numbers — record a per-benchmark baseline.

The CPU hot-path diff applies the same redistribution hedge as allocation: a row whose absolute samples rose while the total fell, rose while the total stayed ~flat, or outpaced a modestly-rising total (its share climbed) is annotated (possible sampling redistribution …), because under a fixed-duration capture a now-faster workload runs more iterations — an unchanged frame then accrues more samples and gains share without its per-op cost changing. Whenever hot-path shares shift more than the total moved, the diff notes that absolute exec-sample deltas conflate per-op cost with throughput and points to a fixed-iteration bench A/B for a clean per-op comparison.

Fix-direction hints (--hints, opt-in)

--hints appends a hedged ## Likely fix directions [possible] section that maps recognized hot-frame / allocation shapes to a one-line direction, each grounded in the row that triggered it — e.g. DoubleToDecimal/formatUnsignedInt → "number→string formatting", a LinkedList$ListItr.<init> self-time → "per-iteration iterator allocation", AbstractStringBuilder.ensureCapacity → "presize the buffer", and per-call regex compilation (java.util.regex.Pattern.* leaves, or a String.replaceAll(String,…)/matches/split source line) → "hoist the Pattern to a static final field and reuse Matcher`". It is off by default (the report stays clean data) and every line is tagged `[possible] — a direction to investigate, never an assertion.

The semantic dimensions feed --hints too, each with its own mechanical lever, grounded in the app call-site the row is anchored to (· at <Class>:line): db — N+1 → batch (WHERE id IN (…) / @BatchSize), SELECT * → project columns, and repeated single-row insert/update → enable JDBC batching; web — a high error-rate endpoint → validate/handle first; cache — a low hit rate → check key cardinality / TTL; messaging — a synchronous per-message send → batch or send async; mongo — a repeated findFilters.in, repeated insertOneinsertMany/bulkWrite; redis — many single-key reads → pipeline / MGET.

java -jar jvmlens.jar analyze recording.jfr --hints

Echo the source line (--source, opt-in)

Every hot-leaf and allocation-site row already carries a file:line anchor (e.g. :129). Point --source at your source root(s) and jvmlens reads each anchored line and appends its text inline — floatString:129 ⟶ mantissa.substring(0, dot) + mantissa.substring(dot + 1) — so a reader (or coding agent) sees the offending line without opening the file. Pass several roots comma-separated. It is off by default, reads files locally, and degrades silently: a file it can’t find is simply left un-annotated (it never fabricates).

java -jar jvmlens.jar analyze recording.jfr --source src/main/java

Budget-dialing the size

--top-k <n> keeps only the top n rows per section; --max-tokens <n> shrinks top-k until the summary fits roughly n tokens (chars/4). For an always-on agent that adjusts limits at runtime, the agent control plane’s topn does the same per-dimension (see Runtime control).

java -jar jvmlens.jar analyze recording.jfr --top-k 3
java -jar jvmlens.jar analyze recording.jfr --max-tokens 250

Report focus

-r / --report narrows the output to one concern (reusing the same sections the MCP server exposes):

Report Shows

full (default)

Everything.

cpu

Hot paths + leaf methods (sampled).

memory

Allocation sites + types.

locks

Lock contention + contended monitors (measured).

gc

GC pressure and the allocation that drives it.

io

External (network + file) blocking I/O by endpoint (measured).

pinning

Virtual-thread pinning sites, by pinned time (measured).

db

Top SQL statements (agent JDBC instrumentation).

web

Top HTTP endpoints (agent servlet instrumentation).

messaging

Top messaging operations (agent Kafka / JMS / RabbitMQ instrumentation; ActiveMQ via JMS).

cache

Top cache operations (agent Spring-Cache instrumentation, with a hit-rate flag).

mongo

Top MongoDB operations (agent sync-driver instrumentation).

redis

Top direct Redis commands (agent Lettuce / Jedis instrumentation).

metrics

Top Micrometer timers (consumed from an existing registry).

deadlock

Deadlocked threads and their wait-for cycle (agent recordings).

The agent always runs a deadlock check (ThreadMXBean.findDeadlockedThreads) — no option needed. A true deadlock is distinct from ordinary lock contention: the threads block forever and never acquire the monitor, so JFR’s JavaMonitorEnter never fires; the ThreadMXBean check is the reliable signal (and only sees the JVM it runs in, hence agent-only). When present, a Deadlocked threads (wait-for cycle) section names each stuck thread and the lock it waits on / who holds it.

The full report also appends a hedged Cross-dimension correlation note when two or more dimensions carry signal — it renders an ordered chain (endpoint → SQL → cache → messaging → I/O → hot path → lock → GC), each link carrying its source anchor (@ UserRepo:88) and a compact flag (N+1, low hit rate, sync send) so an LLM sees where to look. By default it’s co-occurrence, not proof — but when the agent’s captured call-path shows a deeper op ran inside an endpoint’s handler (the row’s ↳ under <Class> marker matches the endpoint’s anchor class), the note is upgraded to a ✓ Confirmed chain (shared call path); otherwise it degrades to the honest co-occurrence wording (e.g. an offline recording with no agent call-paths).

The io and pinning sections appear in the full report whenever the recording carries those events. io aggregates jdk.SocketRead/jdk.SocketWrite (by remote host:port) and jdk.FileRead/jdk.FileWrite (by path), ranked by blocked time with a bytes/op teaser. pinning aggregates jdk.VirtualThreadPinned by site, surfacing the pinnedReason (MONITOR / NATIVE_METHOD on JDK 24+) — the carrier-pinning that silently caps virtual-thread throughput.

java -jar jvmlens.jar analyze --report cpu recording.jfr
java -jar jvmlens.jar analyze -r memory -f json recording.jfr

Live capture from a running JVM

profile <pid> attaches to a running JVM, captures a timed JFR recording, and summarizes it — no pre-recorded .jfr needed:

java -jar jvmlens.jar profile 12345                 # 20s, markdown
java -jar jvmlens.jar profile -d 30 -w 5 12345      # warm up 5s, record 30s
java -jar jvmlens.jar profile -d 30 -k run.jfr 12345  # keep the recording

-w / --warmup waits before recording so startup/JIT noise is skipped.

-e / --engine selects the capture engine: jfr (default, prod-safe, also works over remote JMX) or async (async-profiler — higher fidelity, adds native frames; local <pid> only, and writes JFR so the same summarizer consumes it):

java -jar jvmlens.jar profile --engine async -d 30 12345

Benchmark a workload without JMH (bench)

bench is the no-JMH harness: point it at any class’s main(String[]) and it runs a warmup→timed loop, captures a JFR over only the timed phase, and summarizes — so an ordinary app or library with no benchmark module doesn’t need a hand-rolled driver:

java -jar jvmlens.jar bench --main com.example.RenderDriver -w 20 -i 200 -a com.example
# load the workload from its own classpath (it needn't be on jvmlens's), keep the JFR,
# pass args to its main after `--`:
java -jar jvmlens.jar bench --main com.example.RenderDriver --cp target/classes:$(cat cp.txt) \
  -w 20 -i 200 -a com.example --jfr /tmp/before.jfr -- arg1 arg2

Each main invocation is one iteration. -w / --warmup iterations run before the recording starts (so JIT/classload churn stays out of the steady-state signal); -i / --iters are timed. --cp / --classpath loads the workload through a separate class loader; --jfr <file> keeps the recording (else a temp file) so it can be a --baseline for the next run; --no-analyze captures without printing. A one-line timing summary (iters, ms/iter) goes to stderr; the report to stdout, so it stays pipeable.

Remote servers (run on the host)

For a JVM deployed elsewhere, run jvmlens on that host through the access channel you already have and let it ship back the compact summary — no JMX ports, no extra start flags, works on any running JVM (and --engine async works too, since the profiler is local to the target):

ssh prod-host        'java -jar jvmlens.jar profile <pid> -f prompt'
kubectl exec pod --   java -jar jvmlens.jar profile 1 --engine async -f prompt
docker exec ctr       java -jar jvmlens.jar watch 1 --on-gc-ms 200

This plays to jvmlens’s strength: the output is a few hundred tokens, so there is nothing heavy to move over the network. (A networked MCP endpoint and an in-process agent are on the roadmap for always-on remote querying.)

Continuous watch (rolling profile)

watch <pid> keeps a continuous JFR ring buffer on the target and dumps + summarizes a rolling window every interval — the foundation of the production "dump-on-trigger" mode (condition-based triggers build on this):

java -jar jvmlens.jar watch 12345                          # every 30s, last 120s, forever
java -jar jvmlens.jar watch -i 60 --max-age 300 12345      # every 60s, last 5min
java -jar jvmlens.jar watch -n 5 -i 10 12345               # 5 snapshots, then stop

-i / --interval sets the dump cadence, --max-age the ring-buffer window, and -n / --snapshots a fixed count (0 = until interrupted). Each snapshot is summarized with the same -f / -a / -x options as analyze.

Dump on trigger

By default every interval is emitted. Pass any threshold and watch instead stays quiet and emits only when a window breaches it — the production "dump-on-trigger" mode:

Option Fires when

--on-gc-ms <ms>

total GC pause time in the window reaches <ms> (latency / memory pressure)

--on-cpu-pct <pct>

the top hot path reaches <pct> of samples (a hot loop)

--on-old-objects <n>

retained (old-object) samples reach <n> (suspected leak)

# emit a summary only when GC pauses exceed 200ms or a leak shows up
java -jar jvmlens.jar watch --on-gc-ms 200 --on-old-objects 5 12345

Scoping application code

By default a hot path is "application code" if it is outside the JDK and common frameworks (Spring, Apache, BouncyCastle, Jackson, logging, …). To focus on your own packages — or trim more noise — use -a / --app-package (include-only) and -x / --exclude (both repeatable, comma-separable); they apply to analyze and profile alike:

java -jar jvmlens.jar analyze -a org.alexmond recording.jfr
java -jar jvmlens.jar analyze -x com.thirdparty recording.jfr

-x scopes the hot-path and allocation-site blocks (by the calling frame’s package) and also folds the Top allocated types block: any allocated type whose package matches an exclude (including array types like [Lorg.h2.mvstore.Page$PageReference;) collapses into one accounted «excluded types (-x), rolled up» row, so a test capture’s embedded infrastructure — e.g. an in-process H2’s MVStore types — doesn’t crowd out your app’s byte[] / String / domain types. The bytes stay counted, just rolled up (#128):

java -jar jvmlens.jar analyze -a org.alexmond -x org.h2 recording.jfr

A summary built from very few execution samples is flagged with a adequacy caveat — its hot-path shares are statistically noisy; record longer or under steady-state load.

In-process agent

For always-on profiling — especially in containers — load jvmlens as a Java agent. It keeps a continuous JFR ring buffer inside the target and writes a fresh LLM-ready summary to a file every interval. No attach, no JMX, nothing external:

java -javaagent:jvmlens-agent.jar=out=/var/log/jvmlens.md,interval=60 -jar your-app.jar

Options (comma-separated key=value): out (latest-summary file), interval (seconds between summaries), settings (JFR config, default profile), snapshot (see below), db (instrument JDBC — see Database (SQL) below), web (instrument HTTP — see Web (HTTP endpoints) below), messaging (time Kafka / JMS / RabbitMQ send + poll/receive/basicPublish; ActiveMQ via JMS), cache (time Spring Cache get/put/evict, with a hit-rate flag), mongo (time MongoDB sync-driver ops — see MongoDB below), redis (time direct Lettuce/Jedis commands — see Redis below), micrometer (summarize an existing Micrometer registry — no extra instrumentation; degrades to nothing if Micrometer is absent), history (see Long-running monitor below), paused (launch without emitting — start it after warm-up; see Runtime control below), control (a file the agent watches for in-flight commands), and scope (pin the application scope at launch — see below). The messaging and cache dimensions aggregate by Class.method operation and render top operations by total time. The agent jar is the separate jvmlens-agent.jar artifact (engine + agent + a relocated ByteBuddy); it can also be attached dynamically via the Agent-Class entry.

By default the agent attributes application frames heuristically (everything non-JDK and non-jvmlens). For a headless long-running monitor — a container with no control channel wired — pin the scope to the target module at launch with scope, so history / trend attribute to the actual module from the first sample instead of guessing:

# app = org.alexmond.unitrack, from sample 1 (no control file needed)
java -javaagent:jvmlens-agent.jar=out=/agent/x.md,history=/agent/x.jsonl,scope=app:org.alexmond.unitrack -jar app.jar

Segments are `-separated (the arg list is comma-separated, so ` avoids the clash); each is app:<prefix> or exclude:<prefix> (a bare prefix defaults to app), e.g. scope=app:org.alexmond.unitrack+exclude:org.h2. This is the launch-time equivalent of the in-flight scope app <prefix> control command below.

Runtime control (in-flight adjustment)

Like a desktop profiler’s live controls, the agent can be steered at runtime — without a restart — through a control file it watches (control=<file>). No ports, no JMX: an operator appends commands over whatever access they already have, via the jvmlens control CLI (run it on the host: kubectl exec, ssh, …):

java -javaagent:jvmlens-agent.jar=out=/agent/jvmlens.md,control=/agent/jvmlens.control,paused -jar app.jar

# then, on the host:
java -jar jvmlens.jar control /agent/jvmlens.control start          # begin (e.g. after warm-up)
java -jar jvmlens.jar control /agent/jvmlens.control enable db      # turn a dimension on (lazy-instruments)
java -jar jvmlens.jar control /agent/jvmlens.control topn db 5      # top 5 SQL queries with their stats
java -jar jvmlens.jar control /agent/jvmlens.control settings default   # lighter sampling (profile = denser)
java -jar jvmlens.jar control /agent/jvmlens.control scope app com.example   # adjust app-frame filtering
java -jar jvmlens.jar control /agent/jvmlens.control dump           # emit a summary now
java -jar jvmlens.jar control /agent/jvmlens.control status         # read current state back

Commands: start / stop, clear (reset the window + stores), dump (emit now), enable <dim> / disable <dim> (db/web/messaging/cache/mongo/redis/micrometer/snapshot/deadlock), settings <profile|default> (sampling density), interval <seconds>, scope app|exclude <prefix> / scope reset (filtering), topn [<category>] <n> / topn reset (rows per section — category is cpu/perf, memory/mem, locks, io, pinning, or a plugin like db/web), and status. Each command makes the agent publish its state to <control-file>.status, which the CLI reads back and prints — so topn db 5 returns the resulting limits to you.

Launching paused and then start after warm-up is the clean fix for short cold runs profiling startup rather than the workload — no more guessing a --warmup duration.

Long-running monitor (history + trend)

out is overwritten each interval — only the latest window survives. For a multi-day watch, add history=<file.jsonl> and the agent instead appends one compact sample per interval (covering all three dimensions — CPU, memory, wait), so nothing is lost:

java -javaagent:jvmlens-agent.jar=out=/var/log/jvmlens.md,history=/var/log/jvmlens.jsonl,interval=300 \
     -jar your-app.jar

Let it run, then trend reduces the accumulated run to a change-over-time report — what moved across the days, not a single snapshot:

java -jar jvmlens.jar trend /var/log/jvmlens.jsonl          # markdown digest
java -jar jvmlens.jar trend -f prompt /var/log/jvmlens.jsonl  # wrapped for an LLM
java -jar jvmlens.jar trend -f json   /var/log/jvmlens.jsonl  # the raw samples

The digest reports each dimension’s first-third→last-third direction (rising / flat / falling), whether the hot path stayed stable or shifted, when lock contention appeared, and a hedged retention indicator — old-object growth alongside rising GC pressure is flagged as possible retention growth, never a confident "leak".

When the window spans several JVM lifetimes — a multi-day monitor across rolling redeploys — trend detects each restart from the large gap it leaves between sample timestamps (the agent appends on a fixed interval, so a replaced pod leaves a hole far larger than one interval) and adds a ## Lifecycle note. Each lifetime’s first window is a cold-start burst (classload, warmup, JIT — a 5–10× outlier that would otherwise skew the whole-window trend to "rising"), so it is excluded from the steady-state aggregates, and retention is assessed within the latest lifetime only (old-object counts reset on restart). A gapless single-lifetime run is unaffected.

Database (SQL)

Add db to the agent options and it instruments java.sql.Statement.execute* (ByteBuddy) to time JDBC calls, aggregating them by sanitized SQL shape (literals parameterized, so no values reach the summary) into a Top SQL (by total time) section — each shape with its call count, average latency, the app call-site it was issued from (· at <Repo>:line), and a shape-aware hint: a high-count low-latency select is a possible N+1 (unless it already uses WHERE id IN (…), the batch-fetch fix — then it isn’t flagged), while a repeated single-row insert/update reads as likely un-batched (enable JDBC batching):

java -javaagent:jvmlens-agent.jar=out=/var/log/jvmlens.md,db,interval=60 -jar app.jar

The SQL comes from the statement argument (plain Statement) or the statement’s toString() (most PreparedStatement drivers — H2, PostgreSQL); unknown shapes degrade to ?. This is profiling signal, not a query log — it never records literal values.

Web (HTTP endpoints)

Add web and the agent instruments HttpServlet.service (ByteBuddy; both jakarta.servlet and javax.servlet, read reflectively so jvmlens needs no servlet dependency — Spring MVC’s DispatcherServlet is covered by this one point). Requests aggregate by route shape (numeric / UUID / long-token path segments become {}, query strings dropped) into a Top HTTP endpoints (by total time) section, each with request count, average latency, and an error count (status ≥ 400):

java -javaagent:jvmlens-agent.jar=out=/var/log/jvmlens.md,web,db -jar app.jar

Messaging (Kafka / JMS / RabbitMQ)

Add messaging and the agent times each broker at its lowest client level — Kafka Producer/Consumer (send/poll), JMS MessageProducer/MessageConsumer (send/ receive, both jakarta.jms and javax.jms), and RabbitMQ com.rabbitmq.client.Channel (basicPublish/basicGet) — matched by interface name, so jvmlens needs no broker dependency. Hooking the client (not the Spring template above it — KafkaTemplate/ JmsTemplate/RabbitTemplate) means a Spring app is instrumented once, never double-counted. ActiveMQ (Classic on javax.jms, Artemis on jakarta.jms) is covered via the JMS path. A high-volume per-message publish with material latency is flagged a synchronous per-message send (batch or send async).

MongoDB

Add mongo and the agent instruments the sync driver’s com.mongodb.client.MongoCollection operations (find/aggregate/insert*/update*/delete*/…, matched by name, no Mongo dependency) into a MongoDB operations (by total time) section, each anchored to its app call-site. The document analogues of the SQL N+1 levers: a repeated find/aggregate reads as a possible N+1 document fetch (batch with Filters.in(…) / an aggregation), a repeated single-document insertOne/updateOne/deleteOne as un-batched (use insertMany/ bulkWrite). Note find/aggregate return lazy iterables, so their count is the N+1 signal, not their latency.

java -javaagent:jvmlens-agent.jar=out=/var/log/jvmlens.md,mongo,interval=60 -jar app.jar

Redis

Add redis and the agent times direct Redis commands — Lettuce (RedisAsyncCommands, which the sync API delegates to) and Jedis (JedisCommands), matched by name — into a Redis commands (by total time) section, anchored to the app call-site. This is distinct from the cache dimension (which tracks Spring Cache hit rate); redis is for apps hitting Redis directly (session stores, rate limiters, queues). A high count of single-key reads (get/hget/…) is flagged possible N+1 round-trips — pipeline them or use a multi-key command (MGET/HMGET).

java -javaagent:jvmlens-agent.jar=out=/var/log/jvmlens.md,redis,interval=60 -jar app.jar

Variable snapshots

Beyond performance, the agent can answer correctness questions — what values flow through a method — without stopping the app. Add snapshot=Class#method (semicolon-separate several) and the agent instruments those methods and appends a Variable snapshots section to the summary: per call site, the call count and a per-argument digest (distinct values, null rate, numeric range):

java -javaagent:jvmlens-agent.jar=out=/var/log/jvmlens.md,snapshot='com.acme.OrderService#price;com.acme.Repo#find' -jar app.jar
## Variable snapshots
### `com.acme.OrderService.price` — 1410658 calls
- arg0: 3 distinct [STD, EXPRESS, FREE]
- arg1: 10 distinct [...] (range 0..9)

Method arguments need no debug info; capturing locals (requires -g) and conditions/PII redaction are planned.

MCP server

jvmlens mcp runs a Model Context Protocol server over stdio, exposing the analysis as scoped, navigable tools so an agent pulls only the slice it needs (progressive disclosure) instead of one large blob:

Tool Returns

overview

Event counts, the heuristic cause, and which drill-down tool to use next.

hot_paths

Application-attributed hot call paths, by sample share.

hot_leaves

Leaf (self-time) hot methods, runtime included.

allocations

Top allocation sites and allocated types.

lock_contention

Lock contention by method and contended monitors.

io

External (network + file) blocking I/O by endpoint.

pinning

Virtual-thread pinning sites, by pinned time.

deadlock

Deadlocked threads and their wait-for cycle (agent recordings).

profile

Capture a live local JVM by pid (engine jfr/async, report focus) and summarize it.

The drill-down tools take a file (path to a .jfr); profile takes a pid. All accept optional appPackages / exclude scoping. The server only serves structured data — it never calls an LLM, so recordings never leave the host. Register it with an MCP client:

{ "mcpServers": { "jvmlens": { "command": "java", "args": ["-jar", "/path/to/jvmlens.jar", "mcp"] } } }

For a remote server, point the MCP client at the host’s access channel — no JMX, no extra ports:

{ "mcpServers": { "prod": { "command": "ssh", "args": ["prod-host", "java", "-jar", "jvmlens.jar", "mcp"] } } }

Output formats

-f / --format selects the rendering (case-insensitive); all three carry the same ranked signal from one analysis pass:

Format Use

md (default)

Compact markdown — readable by humans and agents alike.

json

Scoped JSON object — for tooling, or the future MCP server, to consume.

prompt

The markdown wrapped in an LLM task instruction, ready to paste.

java -jar jvmlens.jar analyze --format json recording.jfr
java -jar jvmlens.jar analyze -f prompt recording.jfr

Producing a recording

Any JFR recording works. To capture one with the built-in profiler:

java -XX:StartFlightRecording=duration=30s,filename=recording.jfr,settings=profile -jar your-app.jar

The examples/ directory contains a planted-pathology workload (CPU hot path, memory leak, lock contention) for producing sample recordings.

For forked-test workloads (Maven Surefire/Failsafe), prefer live attach (jvmlens profile <pid>) over -XX:StartFlightRecording=…,dumponexit=true on the fork’s argLine. Surefire reuses and then kills its fork rather than exiting it cleanly, so the dumponexit shutdown hook never fires and you get no .jfr at all. Attaching to the running fork captures cleanly where the JVM-flag path silently produces nothing.

Exit codes

Code Meaning

0

Summary produced

2

Bad arguments (unreadable JFR file, non-numeric pid, invalid duration/warmup)

3

Live capture failed (could not attach to the target JVM)