Write JVM agents in Rust with explicit safety boundaries and production-grade ergonomics.
Complete JNI and JVMTI bindings plus higher-level abstractions designed for building profilers, tracers, debuggers, and runtime instrumentation — without writing C or C++.
This crate focuses on:
- Making ownership, lifetimes, and error handling explicit
- Reducing common JVMTI footguns
- Keeping unsafe behavior localized and auditable
It is intended for serious native JVM tooling, not just experimentation.
This crate is a generic JNI/JVMTI binding and agent framework (published on
crates.io — current release 2.2.1). Bytecode instrumentation engines, spec
transforms, stackmap-aware BCI, and related policy live in the separate
bytecode-instrument project. Do not add that instrumentation technology here
unless there is an explicit decision to merge or port it. Agents can depend on
both crates independently.
JVMTI is powerful — and notoriously easy to misuse.
Typical problems when writing agents:
- Unchecked error codes that silently corrupt state
- Invalid reference lifetimes causing segfaults
- Allocator mismatches leaking memory
- Thread-local
JNIEnvmisuse across callbacks - Undocumented callback constraints causing deadlocks
Most existing Rust options either:
- Expose raw bindings with little guidance
- Rely on build-time bindgen
- Are incomplete or unmaintained (7+ years)
- Optimize for JNI, not JVMTI agents
This crate was designed around how agents are actually written, not around mirroring C headers.
If you only need JNI to call into Java from Rust applications, crates like jni or jni-simple are often sufficient. This crate is purpose-built for JVMTI agents (profilers, tracers, debuggers, instrumentation) and emphasizes:
- Full JNI + JVMTI coverage (agent-first focus)
- Safe, owned return types in the high-level
envwrappers - Class file parsing with all standard Java 8-27 attributes
- A tiny but explicit public surface (
env,sys,classfile,prelude) - Safety guidance, pitfalls, and compatibility documentation
- Examples that mirror real JVMTI tooling patterns
C++ is the traditional choice, but Rust offers compelling advantages:
- Memory safety without GC — JVMTI agents run inside the JVM process; a segfault kills the application
- Fearless concurrency — JVMTI callbacks fire from multiple threads simultaneously
- Zero-cost abstractions — RAII guards and Result types add safety without runtime overhead
- No runtime dependencies by default — Core JNI/JVMTI bindings need no external Rust crates; optional helpers are feature-gated
- Modern tooling — Cargo, docs.rs, and crates.io beat Makefiles and manual distribution
Java agents (java.lang.instrument) are simpler but can't access low-level features like heap iteration, breakpoints, or raw bytecode hooks.
| Goal | How |
|---|---|
| Explicit safety model | Unsafe operations centralized; APIs return Result |
| Complete surface | All 236 JNI + 156 JVMTI functions, mapped to Rust types |
| Agent-first ergonomics | Structured callbacks, capability management, RAII resources |
| No hidden dependencies | No bindgen, no build-time JVM, no global allocators; optional helper deps are feature-gated |
| Long-term compatibility | Verified against OpenJDK headers, JDK 8 through 27 |
This crate is built around explicit safety boundaries. See docs/SAFETY.md and docs/PITFALLS.md for the full checklist.
Key rules:
- Never use
JNIEnvacross threads. - Never panic across JNI/JVMTI callbacks.
- Always deallocate JVMTI buffers with
Deallocate. - Avoid JNI calls in GC callbacks.
The supported public surface is intentionally small. For most users:
- Use
envfor safe wrappers. - Use
preludefor standard imports. - Use
sysonly for raw FFI work.
Details: docs/PUBLIC_API.md.
If you need raw JNI/JVMTI functions, use:
jvmti_bindings::sys::jniandjvmti_bindings::sys::jvmtifor raw types and vtables.JniEnv::raw()andJvmti::raw()to access the underlying raw pointers.Agent::*_with_jvmticallback variants when a callback needs the exactjvmtiEnv*supplied by the JVM.
Agent_OnAttachis supported via theexport_agent!macro andAgent::on_attach.JNIEnvis thread-local and must only be used on its originating thread.GlobalRefcleanup attaches to the JVM when needed, but you should still manage lifetimes explicitly.- For bytecode transforms and live metadata collection, prefer
class_file_load_hook_with_jvmti,vm_init_with_jvmti, or the other*_with_jvmtimethods over rediscovering JVMTI fromJavaVM.
JniEnv includes neutral helpers for modern agent work:
define_classfor target-loader helper injection.class_loader_parentandsystem_class_loaderfor loader graph discovery.module_name,module_packages, andmodule_class_loaderfor JPMS metadata.module_can_read,module_is_exported_to, andmodule_is_open_tofor visibility preflight.
These are generic JNI conveniences. Higher-level instrumentation policy, helper deployment, and compatibility planning remain outside this crate.
See docs/COMPATIBILITY.md for a full JDK 8-27 matrix.
The event callback ABI has dedicated offset tests and a live multi-JDK agent
proof. Run cargo test --test jvmti_event_abi for the portable checks or
scripts/prove-event-callback-matrix.sh to exercise callback delivery on the
installed JVMs.
Feature-gated helpers live under advanced:
heap-graphfor heap tagging and reference edge extraction.
Enable with:
[dependencies]
jvmti-bindings = { version = "2", features = ["heap-graph"] }cargo new --lib my_agent
cd my_agent[lib]
crate-type = ["cdylib"]
[dependencies]
jvmti-bindings = "2"use jvmti_bindings::prelude::*;
#[derive(Default)]
struct MyAgent;
impl Agent for MyAgent {
fn on_load(&self, vm: *mut jni::JavaVM, options: &str) -> jni::jint {
println!("[MyAgent] Loaded with options: {}", options);
jni::JNI_OK
}
fn vm_init(&self, _jni: *mut jni::JNIEnv, _thread: jni::jthread) {
println!("[MyAgent] VM initialized");
}
fn vm_death(&self, _jni: *mut jni::JNIEnv) {
println!("[MyAgent] VM shutting down");
}
}
export_agent!(MyAgent);cargo build --release
# Linux
java -agentpath:./target/release/libmy_agent.so=myoptions MyApp
# macOS
java -agentpath:./target/release/libmy_agent.dylib=myoptions MyApp
# Windows
java -agentpath:./target/release/my_agent.dll=myoptions MyAppIf you want to attach to an already running JVM, implement Agent::on_attach and load the agent with the JVM Attach API:
use jvmti_bindings::prelude::*;
#[derive(Default)]
struct AttachLogger;
impl Agent for AttachLogger {
fn on_attach(&self, vm: *mut jni::JavaVM, options: &str) -> jni::jint {
println!("[AttachLogger] attached with options: {}", options);
let _jvmti = Jvmti::new(vm).expect("get JVMTI");
jni::JNI_OK
}
}
export_agent!(AttachLogger);Attach it with jcmd (example):
jcmd <pid> JVMTI.agent_load /abs/path/to/libattach_logger.so "opt1=val1"JVMTI.agent_load expects an absolute path to the native agent and an optional option string.
This crate now includes a zero-dependency class file parser that understands all standard attributes from Java 8 through Java 27. Use it inside ClassFileLoadHook to inspect or transform class metadata.
use jvmti_bindings::classfile::ClassFile;
fn parse_class(bytes: &[u8]) {
let classfile = ClassFile::parse(bytes).expect("valid class file");
println!("major version = {}", classfile.major_version);
println!("attributes = {}", classfile.attributes.len());
}Nested attributes are preserved and exposed (method Code attributes, record component attributes, and more). You can traverse them like this:
use jvmti_bindings::classfile::{AttributeInfo, ClassFile, RecordComponent};
fn walk_attributes(attrs: &[AttributeInfo]) {
for attr in attrs {
match attr {
AttributeInfo::Code(code) => walk_attributes(&code.attributes),
AttributeInfo::Record { components } => {
for RecordComponent { attributes, .. } in components {
walk_attributes(attributes);
}
}
_ => {}
}
}
}
fn parse_class(bytes: &[u8]) {
let classfile = ClassFile::parse(bytes).expect("valid class file");
walk_attributes(&classfile.attributes);
for field in &classfile.fields {
walk_attributes(&field.attributes);
}
for method in &classfile.methods {
walk_attributes(&method.attributes);
}
}If you want to embed a JVM inside a Rust process (not just build an agent), enable the embed feature and use JavaVmBuilder:
use jvmti_bindings::prelude::*;
let builder = JavaVmBuilder::default()
.option("-Xms64m")?
.option("-Xmx256m")?
.option("-Djava.class.path=./myapp.jar")?;
let vm = builder.create()?; // uses JAVA_HOME or JVM_LIB_PATH
let env = unsafe { vm.creator_env() }; // only valid on the creating thread
// ... call JNI through env ...
std::thread::scope(|s| {
s.spawn(|| {
vm.with_attached_current_thread(|env| {
let _string = env.find_class("java/lang/String");
})
.expect("attach current thread");
});
});
vm.destroy()?;The embed feature uses libloading; the core crate remains dependency-free by default. See docs/EMBEDDING.md and examples/embed.rs for details.
Included examples (build as cdylib agents):
examples/minimal.rsexamples/class_logger.rsexamples/profiler.rsexamples/tracer.rsexamples/heap_sampler.rsexamples/attach_logger.rs(dynamic attach viaAgent_OnAttach)
Embedding example (binary):
examples/embed.rs (run with cargo run --example embed --features embed)
See templates/agent-starter/ for a ready-to-copy agent crate.
The repository includes a GitHub Actions workflow that builds and tests on Linux, macOS, and Windows.
The macro generates the native entry points the JVM expects.
It does:
- Generate
Agent_OnLoad/Agent_OnUnload/Agent_OnAttachentry points - Create your agent instance and store it globally (must be
Sync + Send) - Pass the options string to your
on_load/on_attachimplementation
It does not:
- Hide undefined JVMTI behavior
- Make callbacks re-entrant or async-safe
- Attach arbitrary native threads automatically
- Register callbacks or enable events
- Prevent JVM crashes from invalid JVMTI usage
For callbacks that need the raw JVMTI callback environment, implement the
corresponding *_with_jvmti method, for example vm_init_with_jvmti or
class_file_load_hook_with_jvmti.
The goal is clarity, not magic.
This crate enforces the following invariants:
| Invariant | Enforcement |
|---|---|
JNIEnv is thread-local |
JniEnv wrapper is not Send |
| Local refs don't escape | LocalRef<'a> tied to JniEnv lifetime |
| Global refs are freed | GlobalRef releases on Drop |
| JVMTI memory properly freed | High-level JVMTI methods deallocate buffers they allocate |
| Errors are explicit | JVMTI methods return Result, JNI helpers use Option/Result |
Some things cannot be made safe by design:
- Bytecode transformation correctness — invalid bytecode crashes the JVM
- Callback timing assumptions — JVMTI events fire at specific phases
- Blocking in callbacks — long operations in GC callbacks deadlock
- Cross-thread reference sharing — JNI local refs are thread-local
Rust helps — but JVMTI is still a sharp tool.
Yes, if you are:
- Building profilers, tracers, debuggers, or instrumentation
- Want Rust's type system around JVMTI's sharp edges
- Need a single crate that works across JDK 8–27
- Comfortable reading JVMTI docs for advanced use cases
Probably not, if you:
- Only need basic JNI calls (consider the
jnicrate) - Are uncomfortable debugging native JVM crashes
- Need only pure-Java instrumentation (
java.lang.instrumentmay be simpler) - Want zero
unsafeanywhere
┌─────────────────────────────────────────────────────────┐
│ Your Agent Code │
│ impl Agent for MyAgent { ... } │
├─────────────────────────────────────────────────────────┤
│ Agent Trait + Macros │
│ Agent, export_agent!, get_default_callbacks() │
├─────────────────────────────────────────────────────────┤
│ High-Level Wrappers (env module) │
│ Jvmti - JVMTI operations (150+ methods) │
│ JniEnv - JNI operations plus ClassLoader/JPMS │
│ LocalRef - RAII guard, prevented from escaping │
│ GlobalRef - RAII guard, releases on drop │
├─────────────────────────────────────────────────────────┤
│ Class File Parser (classfile) │
│ ClassFile - All standard Java 8-27 attributes │
├─────────────────────────────────────────────────────────┤
│ Convenience Imports (prelude) │
│ prelude::* - Agent, env, sys, helpers │
├─────────────────────────────────────────────────────────┤
│ Raw FFI Bindings (sys module) │
│ sys::jni - Complete JNI vtable (236 functions) │
│ sys::jvmti - Complete JVMTI vtable (156 functions) │
└─────────────────────────────────────────────────────────┘
Events require three steps — capabilities, callbacks, then enable:
use jvmti_bindings::prelude::*;
fn on_load(&self, vm: *mut jni::JavaVM, _options: &str) -> jni::jint {
let jvmti_env = Jvmti::new(vm).expect("Failed to get JVMTI");
// Requests capabilities, wires callbacks, and enables ClassFileLoadHook.
jvmti_env.configure_class_file_load_hook_agent().expect("configure");
jni::JNI_OK
}Lower-level helpers are available when you need explicit control:
let caps = jvmti::jvmtiCapabilities::for_method_trace();
jvmti_env.add_capabilities(&caps)?;
jvmti_env.set_default_agent_callbacks()?;
jvmti_env.enable_method_entry_exit_events()?;For diagnostics:
eprintln!("jni={}", describe_jni_result(jni::JNI_EDETACHED));
eprintln!("jvmti={}", jvmti::error_name(jvmti::jvmtiError::MUST_POSSESS_CAPABILITY));| Capability | Required For |
|---|---|
can_generate_all_class_hook_events |
class_file_load_hook |
can_generate_method_entry_events |
method_entry |
can_generate_method_exit_events |
method_exit |
can_generate_exception_events |
exception, exception_catch |
can_tag_objects |
Object tagging, heap iteration |
can_retransform_classes |
retransform_classes() |
can_redefine_classes |
redefine_classes() |
can_get_bytecodes |
get_bytecodes() |
can_get_line_numbers |
get_line_number_table() |
can_access_local_variables |
get_local_*(), set_local_*() |
| JDK | Status | Notable Additions |
|---|---|---|
| 8 | ✅ Tested | Baseline |
| 11 | ✅ Tested | SetHeapSamplingInterval |
| 17 | ✅ Tested | — |
| 21 | ✅ Tested | Virtual thread support |
| 27 | ✅ Verified | ClearAllFramePops |
Bindings generated from JDK 27 headers, backwards compatible to JDK 8.
| Aspect | Status |
|---|---|
| API stability | SemVer 2.x; breaking changes require a major release |
| JVMTI coverage | 156/156 (100%) |
| JNI coverage | 236/236 (100%) |
| Dependencies | Zero by default; optional embed and bench-tools features |
| Testing | Classfile parser, doctests, all-feature builds, example agents |
# Minimal agent — lifecycle events only
cargo build --release --example minimal
# Method counter — counts all method entries/exits
cargo build --release --example method_counter
# Class logger — logs every class load
cargo build --release --example class_logger- Your First Production Agent — Step-by-step guide with production hardening
- Public API Surface — What is stable and supported
- API Stability Checklist — 2.x stability rules
- Contributor Style Guide — Prelude-first and API consistency
- Public API Report — Snapshot of the public surface
- API Report Script — Regenerate the report with rustdoc JSON
- Changelog — Release notes and breaking changes
- Comparison With Alternatives — Feature parity and positioning
- Benchmarks — How to run and view Criterion reports
- Embedding A JVM — Start a JVM from Rust and attach threads
- Dynamic Attach — Agent_OnAttach example and notes
- Safety and FFI Checklist — Safety rules and audit checklist
- Pitfalls and Footguns — Common JVMTI/JNI traps
- Compatibility Matrix — JDK 8-27 coverage
- Versioning Policy — API stability and SemVer plan
- API Reference — Complete API documentation on docs.rs
MIT OR Apache-2.0