This guide walks through building a small but production-grade JVM agent using jvmti-bindings.
The goal is not to show off every feature — it's to show:
- Where things can go wrong
- How to structure an agent safely
- How to avoid the most common JVMTI mistakes
We'll build an agent that:
- Counts loaded classes
- Logs JVM startup/shutdown
- Is safe to run in production without destabilizing the VM
This guide assumes:
- You use Rust 1.85 or newer (Edition 2024)
- You know Rust basics (ownership,
Result, traits) - You know what a JVM agent is (
-agentpath) - You are comfortable debugging native code if needed
You do not need prior JVMTI experience.
Create a new library crate:
cargo new --lib class_counter_agent
cd class_counter_agentIn Cargo.toml:
[lib]
crate-type = ["cdylib"]
[dependencies]
jvmti-bindings = "3"Why cdylib?
- The JVM loads agents as native shared libraries (
.so,.dylib,.dll) cdylibproduces a clean C-compatible shared library
A JVM agent is long-lived. You must assume:
- Callbacks happen on different threads
- Callbacks may race
- Callbacks may happen very early or very late in VM lifetime
Define state explicitly:
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Default)]
pub struct ClassCounterAgent {
loaded_classes: AtomicU64,
}Why atomics?
- JVMTI callbacks can run concurrently
- Locks inside callbacks increase deadlock risk
- Atomics are cheap and predictable
The Agent trait is your contract with the JVM:
use jvmti_bindings::prelude::*;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Default)]
pub struct ClassCounterAgent {
loaded_classes: AtomicU64,
}
impl Agent for ClassCounterAgent {
fn on_load(&self, context: AgentLoadContext<'_>) -> jni::jint {
eprintln!("[agent] Loading class counter agent");
if let Some(options) = context.options_lossy() {
eprintln!("[agent] Options: {}", options);
}
// Get JVMTI environment
let jvmti_env = match context.vm().jvmti() {
Ok(env) => env,
Err(e) => {
eprintln!("[agent] Failed to get JVMTI: {:?}", e);
return jni::JNI_ERR;
}
};
// 1. Request capabilities (OnLoad / OnAttach, before Live if required)
if let Err(e) = jvmti_env.add_capabilities_with(|caps| {
caps.set_can_generate_all_class_hook_events(true);
}) {
eprintln!("[agent] Failed to add capabilities: {:?}", e);
return jni::JNI_ERR;
}
// 2. Register callbacks (before enabling events)
if let Err(e) = jvmti_env.set_default_agent_callbacks() {
eprintln!("[agent] Failed to set callbacks: {:?}", e);
return jni::JNI_ERR;
}
// 3. Enable events. `export_agent!` does not do this for you.
if let Err(e) = jvmti_env.enable_class_file_load_hook_events() {
eprintln!("[agent] Failed to enable class hook: {:?}", e);
return jni::JNI_ERR;
}
if let Err(e) = jvmti_env.enable_vm_lifecycle_events() {
eprintln!("[agent] Failed to enable VM lifecycle events: {:?}", e);
return jni::JNI_ERR;
}
jni::JNI_OK
}
fn vm_init(&self, _context: CallbackContext<'_>, _event: ThreadEvent) {
eprintln!("[agent] VM initialized");
}
fn class_file_load_hook(
&self,
_context: CallbackContext<'_>,
_event: ClassFileLoadHookEvent<'_>,
) {
// Just count - this is a hot path, keep it fast!
self.loaded_classes.fetch_add(1, Ordering::Relaxed);
}
fn vm_death(&self, _context: CallbackContext<'_>) {
let count = self.loaded_classes.load(Ordering::Relaxed);
eprintln!("[agent] VM shutting down");
eprintln!("[agent] Total classes loaded: {}", count);
}
}
export_agent!(ClassCounterAgent);JVMTI requires a specific order. Get this wrong and the JVM will crash or silently ignore your events.
1. Request capabilities → Must happen in on_load, before VM starts
2. Register callbacks → Must happen before enabling events
3. Enable events → Only after callbacks are registered
The export_agent! macro only creates the required entry points. You must:
- Choose which capabilities to request
- Choose which events to enable
- Handle errors explicitly
Inside callbacks:
| Rule | Why |
|---|---|
| Do not block | Deadlocks the VM |
| Do not allocate excessively | GC can't run during some callbacks |
| Do not call into Java carelessly | Wrong phase = crash |
| Return quickly | You're blocking VM threads |
Treat callbacks like signal handlers with privileges.
JVMTI is not a normal runtime. A callback that takes too long or does too much will destabilize the entire JVM.
cargo build --releaseThe output will be:
- Linux:
target/release/libclass_counter_agent.so - macOS:
target/release/libclass_counter_agent.dylib - Windows:
target/release/class_counter_agent.dll
java -agentpath:./target/release/libclass_counter_agent.so MyAppExpected output:
[agent] Loading class counter agent
[agent] VM initialized
... your app output ...
[agent] VM shutting down
[agent] Total classes loaded: 136
If the JVM crashes:
- Run with
-Xcheck:jnifor JNI validation - Run under
gdb/lldb - Add logging inside callbacks (sparingly)
Crashes are bugs — but they're diagnosable.
Before shipping:
- Prefer
stderror structured logging - Avoid logging in hot callbacks (like
class_file_load_hook) - Add a "quiet" option to suppress startup messages
- Never
panic!in callbacks - Treat
Erras fatal only when necessary - Log errors but don't crash the VM
- Measure callback frequency under load
- Avoid JNI calls inside high-volume events
- Use atomics, not locks
- Expect
vm_deathto be called late - Avoid allocations during shutdown
- Don't assume other threads are still running
These are powerful features that deserve their own guides:
- Bytecode rewriting — One wrong byte crashes the JVM
- Heap walking — Stop-the-world implications
- Object tagging — Complex lifecycle management
- Thread suspension — Deadlock minefield
- Calling arbitrary Java code — Phase restrictions
Start simple. Add complexity only when you understand the constraints.
Once this works reliably:
- Add metrics export — Prometheus, StatsD, etc.
- Track method entry/exit — Use
JVMTI_EVENT_METHOD_ENTRY - Experiment with bytecode hooks — Modify classes at load time
- Integrate with async-profiler or perf — Combine native and managed profiling
Each of these has real footguns — and deserves careful design.
If you remember one thing:
A good JVMTI agent is boring. Fast, quiet, predictable, and invisible.
Rust helps. This crate helps. But discipline matters most.
The full source code for this guide is available at:
- examples/class_logger.rs — Similar pattern with class name logging
Run the example:
cargo build --release --example class_logger
java -agentpath:./target/release/examples/libclass_logger.so=filter=com/example MyApp