A Java agent that instruments classes at runtime to record and visualize line-level execution counts. It generates a modern, interactive HTML report with a file tree, global heatmap, and support for both dark and light modes.
- Bytecode Instrumentation: Automatically injects counting logic into target methods using ASM.
- Frequency Analysis: Tracks exactly how many times each line executes, rather than just "if" it was hit.
- Modern UI: Interactive report built with Vue.js 3 and PrismJS.
- Live Updates: Uses JSONP + polling for "serverless" real-time updates, so you can watch counts increase while the app runs (even from
file://). - Global Heatmap: Consistent coloring across all source files based on the project-wide maximum execution count.
- Activity Highlighting: Visual "flash" indicators in the file tree when counts for a specific file increase.
- Standalone Mode: Regenerate the HTML report from saved JSON data without re-running the application.
JVM Hotpath is not a coverage tool. Coverage tools (e.g., JaCoCo, OpenClover, JCov) are designed around coverage (did it execute), not frequency (how many times did it execute). That's critical for quality metrics, but limited for understanding runtime behavior and hot-path analysis.
JVM Hotpath focuses on frequency: "How many times does this line execute in a real-world workload?"
See docs/Motivation.md for a more detailed deep-dive into the goals and architectural choices of this project.
IDEs do not expose an easy way to visualize per-line execution as the app runs. JVM Hotpath bridges that gap by instrumenting production-like workloads, streaming live frequency data to a local HTML UI, and surfacing hotspots without needing a server or sacrificing compatibility.
In the era of vibe coding, where large amounts of code are introduced or refactored in short bursts (often with the help of LLMs), traditional profiling workflows can feel too heavy. Attaching a commercial profiler, configuring sampling rates, and navigating complex call trees for every small logic change is a significant friction point.
I found the need for a "low-ceremony" way to verify that new code behaves as expected. When you're moving fast, you don't always need a nanosecond-precise timing breakdown; you need an immediate, visual confirmation that your loops aren't spinning 10,000x more than they should. JVM Hotpath was built to be that lightweight "Logic X-Ray" that stays out of your way until it finds a logic error.
This tool was born during a high-velocity "vibe coding" session where I was refactoring a core processing engine. With hundreds of lines changing at once, I needed to know if my architectural "vibes" matched the actual runtime reality.
Standard profilers missed the following bug because the system didn't feel slow yet, but the logic was fundamentally broken:
The Bug: A logic check (e.g., isValid()) was being called 19 million times in 15 seconds.
The Problem: Each call was ~50 nanoseconds - easy for sampling profilers to under-sample.
The Impact: Algorithmic complexity (O(N) instead of O(1)) was killing performance.
Standard profilers showed the method as "not hot" because the CPU wasn't stuck there. But 19 million calls Γ 50ns = 950ms of wasted time hidden in plain sight.
| Tool Type | What It Shows | What It Misses |
|---|---|---|
| Sampling Profilers (VisualVM, JFR) |
CPU-intensive methods | Fast methods called millions of times |
| Commercial Profilers (JProfiler, YourKit) |
Deep timing and call tracing | Always-on convenience (heavier workflow, and instrumentation/tracing can add noticeable overhead) |
| APM Tools (Datadog, New Relic) |
Request/span-level metrics | Line-level logic errors |
Java profilers focus on where the CPU is hot (timing).
This tool shows how many times code runs (frequency).
In modern Java:
- JIT compilation makes methods fast
- The bottleneck is often algorithmic (O(N) vs O(1))
- Logic errors create millions of unnecessary calls
- Sampling profilers are statistical: they do not provide exact invocation counts, and very short "fast but frequent" work can be under-sampled
Example:
Sampler says: "Line 96 uses 2.3% CPU time"
Hotpath says: "Line 96: executed 19,147,293 times"
One is a performance metric. The other is a logic error screaming at you.
β
Zero timing overhead - Just counts, no nanosecond measurements
β
Counts every execution - No sampling, no missing fast methods
β
Simple output - JSON/HTML, not a heavy GUI
β
LLM-friendly - Pipe the report to Claude/GPT for analysis
β
Logic-focused - Finds algorithmic problems, not just CPU hotspots
It's a "Logic X-Ray" not a "CPU Thermometer".
When you see "Line 42: executed 19 million times" in a 15-second run, you don't need to measure nanoseconds. You need to fix your algorithm.
- Java: 11 or higher (tested in CI on 11, 17, 21, 23, and 24)
- Build Tool: Maven 3.6+ or Gradle 7.0+
The agent is compiled to Java 11 bytecode for maximum compatibility. Java 25 is currently blocked by upstream bytecode-tooling support (see Development section).
Add the plugin to your build:
Kotlin DSL (build.gradle.kts):
plugins {
java
id("io.github.sfkamath.jvm-hotpath") version "0.2.10"
}
jvmHotpath {
packages.set("com.example")
flushInterval.set(5)
}Groovy DSL (build.gradle):
plugins {
id 'java'
id 'io.github.sfkamath.jvm-hotpath' version '0.2.10'
}
jvmHotpath {
packages.set('com.example')
flushInterval.set(5)
}Then run your application:
./gradlew runReport output: target/site/jvm-hotpath/execution-report.html
Add this instrument profile to your pom.xml:
<profile>
<id>instrument</id>
<build>
<plugins>
<plugin>
<groupId>io.github.sfkamath</groupId>
<artifactId>jvm-hotpath-maven-plugin</artifactId>
<version>0.2.10</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
</executions>
<configuration>
<flushInterval>5</flushInterval>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<executable>java</executable>
<commandlineArgs>${jvmHotpathAgentArg} -classpath %classpath ${exec.mainClass}</commandlineArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>Run your app:
mvn -Pinstrument jvm-hotpath:prepare-agent exec:execNote:
exec:execrequires a main class. Provide it via-Dexec.mainClass=..., or configuremainClass/exec.mainClassin yourpom.xml.
Report output: target/site/jvm-hotpath/execution-report.html
Run the app with -javaagent to instrument without the Maven plugin. For more details, see Manual -javaagent Workflow.
Choose either the Gradle plugin, Maven plugin, or manual -javaagent workflow.
Apply the plugin as shown in Quick Start. The plugin automatically:
- Attaches the agent to all
JavaExectasks (includingrun,bootRun, etc.) - Collects source paths from the
mainsource set across all subprojects - Seeds
packageswith your project'sgroupId
By default, test tasks are not instrumented. To include tests, set instrumentTests to true.
Common Gradle plugin usage:
Kotlin DSL:
jvmHotpath {
packages.set("com.example,com.other.module")
exclude.set("com.example.generated.*")
flushInterval.set(5)
output.set(layout.buildDirectory.file("site/jvm-hotpath/execution-report.html").get().asFile.path)
sourcepath.set("module-a/src/main/java:module-a/target/generated-sources")
verbose.set(true)
keepAlive.set(false)
append.set(true)
instrumentTests.set(true)
skip.set(false)
}Groovy DSL:
jvmHotpath {
packages.set('com.example,com.other.module')
exclude.set('com.example.generated.*')
flushInterval.set(5)
output.set("${layout.buildDirectory.get()}/site/jvm-hotpath/execution-report.html")
sourcepath.set('module-a/src/main/java:module-a/target/generated-sources')
verbose.set(true)
keepAlive.set(false)
append.set(true)
instrumentTests.set(true)
skip.set(false)
}JMH spawns isolated forked JVMs for each benchmark iteration. The jvm-hotpath plugin does not auto-configure these forks β you need to inject the agent manually into the jmh task's jvmArgsAppend.
Note: If your JMH setup uses an ASM version too old for your JDK, the plugin will warn at configuration time. See Resolving the ASM version conflict below.
Step 1 β Inject the agent into JMH forks
Add the following to your build.gradle (Groovy DSL). The agent JAR is available via the jvmHotpathAgent configuration that the plugin creates:
afterEvaluate {
def agentJar = configurations.jvmHotpathAgent.files
.find { it.name.startsWith('jvm-hotpath-agent') && it.name.endsWith('.jar') }
.absolutePath
def args = [
"packages=com.example",
"sourcepath=src/main/java",
"append=true",
"flushInterval=1",
"output=${layout.buildDirectory.get().asFile.absolutePath}/jvm-hotpath/execution-report.html"
].join(',')
tasks.named("jmh").configure {
jvmArgsAppend = ["-javaagent:${agentJar}=${args}"]
}
}Two settings are critical for JMH forks:
append=trueβ each fork appends its counts to the same file rather than overwriting itflushInterval=1β flushes data to disk every second, ensuring counts survive when the fork is killed after each iteration
Step 2 β Resolve the ASM version conflict
JMH bundles its own (older) copy of ASM. When the jvm-hotpath agent attempts to instrument Java 17+ bytecode inside a fork, it picks up JMH's ASM and crashes with Unsupported class file major version. Fix this by forcing a newer ASM version into the jmh dependency configuration:
dependencies {
jmh 'org.ow2.asm:asm:9.9.1'
jmh 'org.ow2.asm:asm-tree:9.9.1'
jmh 'org.ow2.asm:asm-analysis:9.9.1'
jmh 'org.ow2.asm:asm-commons:9.9.1'
jmh 'org.ow2.asm:asm-util:9.9.1'
}Step 3 β Analyse the results
Because JMH runs many iterations across many forks, the HTML report can be large. Use the jq query from CLI Analysis with jq to find the hottest lines directly from the terminal.
Use the instrument profile from Quick Start.
This workflow requires exec:exec to have a main class (via -Dexec.mainClass=... or config).
For exec:exec, prepare-agent resolves exec.mainClass in this order:
-Dexec.mainClassjvm-hotpath.mainClassmain.classmainClassstart-classspring-boot.run.main-class
If no main class can be resolved, prepare-agent fails fast. This validation is skipped for non-exec runs (for example test-only runs).
Common Maven plugin extensions:
By default, tests are not instrumented. To include tests (Surefire/Failsafe), set:
mvn -Djvm-hotpath.instrumentTests=true ...When tests are not instrumented, the plugin sets the agent string into jvmHotpathAgentArg instead of argLine.
Use this when your run spans multiple modules or generated sources:
<configuration>
<packages>com.example,com.other.module</packages>
<sourcepath>
module-a/src/main/java:module-a/target/generated-sources:module-b/src/main/java
</sourcepath>
</configuration>Or as a one-off CLI override:
mvn -Pinstrument jvm-hotpath:prepare-agent exec:exec \
-Djvm-hotpath.packages=com.example,com.other.module \
-Djvm-hotpath.sourcepath=module-a/src/main/java:module-a/target/generated-sources:module-b/src/main/javaUse : on macOS/Linux and ; on Windows for sourcepath.
Use <includes> in pom.xml for repeatable team/project config:
<configuration>
<packages>com.legacy.utils</packages>
<includes>
<include>
<groupId>com.example</groupId>
<artifactId>shared-library</artifactId>
<packageName>com.example.shared</packageName>
</include>
</includes>
</configuration>Use CLI override only for one-off local runs:
mvn -Pinstrument jvm-hotpath:prepare-agent exec:exec \
-Djvm-hotpath.packages=com.example.shared \
-Djvm-hotpath.sourcepath=$HOME/.m2/repository/com/example/shared-library/1.0.0/shared-library-1.0.0-sources.jarsourcepath accepts directories and source archives (.jar/.zip). If you provide archives manually, match source and runtime versions.
By default, every run overwrites the previous report. Use append to accumulate counts across multiple JVM runs. This is useful for complex applications where multiple distinct user journeys, batch jobs, or manual workloads need to be combined to see the full hot-path picture.
<configuration>
<append>true</append>
</configuration>Drift Detection (Filesystem-as-Truth):
To ensure data integrity, the agent calculates a CRC32 checksum for every source file. During an append run:
- It compares the current source checksum with the one stored in the existing report.
- If they match: The previous counts are rehydrated and added to the current session.
- If they differ: The source has changed (line numbers may have shifted). The agent logs a
WARNINGand ignores previous counts for that specific file to avoid misleading reports.
Download the agent from Maven Central:
wget https://repo1.maven.org/maven2/io/github/sfkamath/jvm-hotpath-agent/0.2.10/jvm-hotpath-agent-0.2.10.jar
export PATH_TO_AGENT_JAR="$PWD/jvm-hotpath-agent-0.2.10.jar"Or build locally:
mvn clean package -DskipTests
export PATH_TO_AGENT_JAR="$PWD/agent/target/jvm-hotpath-agent-0.2.10.jar"Run with single-source config:
java -javaagent:${PATH_TO_AGENT_JAR}=packages=com.example,sourcepath=src/main/java,flushInterval=5,output=target/site/jvm-hotpath/execution-report.html -jar your-app.jarReport output: target/site/jvm-hotpath/execution-report.html
Run with multi-source config:
AGENT_ARGS="packages=com.example,com.other.module,flushInterval=5,output=target/site/jvm-hotpath/execution-report.html,sourcepath=module-a/src/main/java:module-a/target/generated-sources:module-b/src/main/java"
java -javaagent:${PATH_TO_AGENT_JAR}="${AGENT_ARGS}" -jar your-app.jarUse : on macOS/Linux and ; on Windows for sourcepath.
Smart defaults (plugin workflow):
packages: starts with your projectgroupIdsourcepath: starts with compile source roots (typicallysrc/main/java)output: defaults totarget/site/jvm-hotpath/execution-report.htmlflushInterval: defaults to0(set to5for live updates)
Use the table below as the full reference.
| Option | Scope | Agent Arg | Maven Plugin Config | Notes |
|---|---|---|---|---|
packages |
Agent + Plugin | packages= |
jvm-hotpath.packages / <packages> |
Plugin seeds with project groupId, then appends configured values. |
exclude |
Agent + Plugin | exclude= |
jvm-hotpath.exclude / <exclude> |
Exclusion list passed through to agent. |
flushInterval |
Agent + Plugin | flushInterval= |
jvm-hotpath.flushInterval / <flushInterval> |
Interval in seconds. Default 0 (no periodic flush). |
output |
Agent + Plugin | output= |
jvm-hotpath.output / <output> |
Default target/site/jvm-hotpath/execution-report.html. |
sourcepath |
Agent + Plugin | sourcepath= |
jvm-hotpath.sourcepath / <sourcepath> |
Supports directories and source archives (.jar/.zip). |
verbose |
Agent + Plugin | verbose= |
jvm-hotpath.verbose / <verbose> |
Extra instrumentation/flush logging. |
keepAlive |
Agent + Plugin | keepAlive= |
jvm-hotpath.keepAlive / <keepAlive> |
Agent default is true; plugin emits when enabled. |
append |
Agent + Plugin | append= |
jvm-hotpath.append / <append> |
If true, loads existing report counts at startup to accumulate across runs. |
instrumentTests |
Plugin only | n/a | jvm-hotpath.instrumentTests / <instrumentTests> |
Attach the agent to test tasks. Default false. |
mainClass |
Plugin only | n/a | jvm-hotpath.mainClass / <mainClass> |
Populates exec.mainClass for exec:exec. |
includes |
Plugin only | n/a | <includes> |
Resolves dependency sources and appends them to sourcepath. |
propertyName |
Plugin only | n/a | jvm-hotpath.propertyName / <propertyName> |
Target property for injected -javaagent string (default argLine, or jvmHotpathAgentArg when instrumentTests=false). |
skip |
Plugin only | n/a | jvm-hotpath.skip / <skip> |
Skips plugin execution. |
- Open the generated
target/site/jvm-hotpath/execution-report.htmlfile in any modern web browser. - If
flushIntervalis set, the report will automatically poll for updates from a siblingexecution-report.jsfile. - No Web Server Required: Thanks to the JSONP implementation, live updates work even when the file is opened directly from disk (
file://protocol). - If you open the report from disk and nothing renders, hard-refresh once (the
report-app.jsbundle is copied alongside the report and may be cached).
The agent produces both human-readable and machine-readable output in the target/site/jvm-hotpath/ directory:
execution-report.html: The interactive web UI for developers. Self-contained with the initial data snapshot.execution-report.json: Pure JSON data for machine consumption (CI pipelines, LLM analysis, etc.).
execution-report.js: A JSONP wrapper used by the HTML report for live updates without a web server.report-app.js: The bundled Vue.js runtime used by the HTML UI.
The JSON payload format is optimized for clarity:
{
"generatedAt": 1700000000000,
"files": [
{
"path": "com/example/Foo.java",
"project": "my-module",
"counts": { "12": 3, "13": 47293 },
"content": "..."
}
]
}See docs/jsonp-live-updates.md for implementation details and gotchas.
If you have a saved execution-report.json file and want to regenerate the HTML UI (e.g., after updating the template or changing themes):
java -jar ${PATH_TO_AGENT_JAR} --data=target/site/jvm-hotpath/execution-report.json --output=target/site/jvm-hotpath/new-report.htmlThe execution-report.json is machine-readable and works well with jq for instant terminal-based analysis β useful when the HTML report is large (e.g. after a JMH run with many benchmark forks) or when you want to script hotspot detection in CI.
Top 20 hottest lines across all files:
jq '[.files[] | select((.counts | length) > 0) | {path: .path, counts: .counts | to_entries} | .counts[] as $c | {file: .path, line: $c.key|tonumber, hits: $c.value}] | sort_by(.hits) | reverse | .[0:20]' path/to/execution-report.jsonExample output:
[
{ "file": "com/example/OrderService.java", "line": 42, "hits": 19147293 },
{ "file": "com/example/OrderService.java", "line": 43, "hits": 19147293 },
...
]How the query works:
| Step | Expression | Purpose |
|---|---|---|
| 1 | .files[] | select((.counts | length) > 0) |
Skips files with no recorded executions |
| 2 | {path: .path, counts: .counts | to_entries} |
Converts the {"lineNumber": hitCount} map into a [{key, value}] array |
| 3 | .counts[] as $c | {file: .path, line: $c.key|tonumber, hits: $c.value} |
Flattens to one object per line |
| 4 | sort_by(.hits) | reverse |
Hottest lines first |
| 5 | .[0:20] |
Top 20 only |
Tip: Run
./gradlew hotpathHelpto print this query with your project's report path.
- Development JDK: Java 21
- Bytecode Target: Java 11 (for maximum runtime compatibility)
- Instrumentation Engine: ASM 9.9.1 (supports up to Java 24 bytecode)
- CI Testing Matrix: Covers Java 11, 17, 21, 23 and 24.
To build the agent JAR (shaded with all dependencies):
mvn clean package -DskipTestsThe resulting JAR will be at agent/target/jvm-hotpath-agent-0.2.10.jar.
Frontend build: The report UI lives in
report-ui/and is bundled via Vite.mvn clean packagerunsfrontend-maven-pluginto executenpm install/npm run buildinside that folder before packaging, producing a browser-safereport-app.js(IIFE bundle). When iterating on the UI you can runnpm install && npm run buildmanually fromreport-ui/to refresh the bundled asset.
Java 25 Note: Support for Java 25 is currently blocked until the ASM project releases a version that supports the finalized Java 25 bytecode specification. Using the agent on a Java 25 JVM will likely result in an
UnsupportedClassVersionErrorduring instrumentation.
- Filesystem-as-Truth Filtering: The agent only instruments classes if their corresponding
.javasource file is found in thesourcepath. This automatically excludes standard libraries, third-party dependencies, and test frameworks (JUnit, Mockito) without manual configuration. - Infrastructure Exclusions: Core framework classes (e.g.,
io.micronaut,io.netty) and generated proxy classes (e.g.,$Definition,$$EnhancerBySpring) are automatically excluded to prevent interference with application lifecycles. - Non-Daemon Threads: The agent starts a non-daemon "heartbeat" thread (configurable via
keepAlive) to ensure the JVM stays alive for monitoring even if the application's main thread completes. - Robustness: Instrumentation is wrapped in
Throwableblocks to prevent bytecode errors from crashing the application.
We built this because we needed it. If you need it too, let's make it better together.
- π Found a bug? Open an issue
- π‘ Have an idea? Start a discussion
- π§ Want to contribute? Submit a PR
MIT License - Free to use, modify, and distribute.