Every Java profiler, APM agent, code coverage tool and debugger works through the same two doors into the JVM. The JVM Tool Interface, JVMTI, is a native C interface that lets an agent library subscribe to events, inspect threads, stacks and the heap, and replace class definitions. The java.lang.instrument package is a Java-level layer built on top of JVMTI that lets a jar file rewrite class bytes as they load, or after they have loaded.
This page explains both from first principles: how agents are loaded, what an environment and its capabilities are, how class transformation actually flows, what redefinition can and cannot change, how bytecode libraries fit in, and what JDK 21 changed about attaching agents to running processes. A worked example builds a method timing agent, and the operational sections cover the overhead and classloader problems that make agents fail in production.
Two layers, one mechanism
JVMTI is part of the JVM specification family and is implemented by HotSpot. An agent written in C or C++ is a shared library exporting Agent_OnLoad for startup loading, Agent_OnAttach for loading into a running VM, and optionally Agent_OnUnload. It obtains a jvmtiEnv pointer with GetEnv. Each environment is independent: it has its own capabilities, its own event callbacks and its own enabled events, so several agents can coexist in one process.
A Java agent is a jar whose manifest names an agent class. The JVM's built-in instrumentation agent, called the JPLIS agent, creates a JVMTI environment on its behalf, enables the class file load hook, and calls the agent's premain or agentmain method with an Instrumentation object. Everything a Java agent does to classes ultimately flows through JVMTI's ClassFileLoadHook event and its redefinition functions.
The practical split: write a Java agent to change what code does; write a native agent only for what Java cannot reach, such as heap walking, and accept platform-specific builds and crashes that take down the JVM.
Loading agents
| How | Kind | Entry point | When |
|---|---|---|---|
-agentpath:/path/lib.so=opts | native | Agent_OnLoad | startup, explicit path |
-agentlib:name=opts | native | Agent_OnLoad | startup, library on the native path |
-javaagent:agent.jar=opts | Java | premain | startup, before main |
Launcher-Agent-Class in an executable jar | Java | agentmain | startup of that jar |
jcmd JVMTI.agent_load or the Attach API | both | Agent_OnAttach / agentmain | running process |
Startup loading is the predictable path: the agent is visible on the command line, it sees every class from the first one loaded, and native agents can request capabilities that are only available in the OnLoad phase. Dynamic attach is what tools such as profilers and diagnostic utilities use to connect to a process that is already running, through the com.sun.tools.attach.VirtualMachine API or jcmd.
Dynamic attach is also how a process could be modified by something that was never declared. JEP 451, delivered in JDK 21, makes the JVM print a warning when an agent is loaded into a running VM, and states the intent to disallow it by default in a future release. The -XX:+EnableDynamicAgentLoading flag allows it without the warning; test tools that self-attach are affected too.
# Load a Java agent into a running JVM (JDK 9+; prints the JEP 451 warning on JDK 21+)
jcmd <pid> JVMTI.agent_load /opt/agents/orders-agent.jar
# Allow dynamic loading without the warning, deliberately and visibly, at startup
java -XX:+EnableDynamicAgentLoading -jar app.jar
# Preferred: load at startup, where the operator can see it on the command line
java -javaagent:/opt/agents/orders-agent.jar=sample=0.1 -jar app.jar
JVMTI essentials: phases, capabilities and events
The VM passes through phases: OnLoad while it is being created, primordial, start once the class loading machinery works, live after VMInit, and dead after VMDeath. Each JVMTI function documents the phases in which it may be called, and calling outside them returns JVMTI_ERROR_WRONG_PHASE. An agent that does real work typically registers for VMInit and starts it there.
Capabilities are JVMTI's cost control: an agent must add one, such as can_retransform_classes or can_generate_method_entry_events, before using what depends on it. Some can only be added during OnLoad, and some change how code runs: in HotSpot, method entry and exit events effectively keep affected methods in the interpreter, which can slow a program by orders of magnitude. Sampling profilers avoid them.
Callbacks are set with SetEventCallbacks and enabled with SetEventNotificationMode. They run on the thread that caused the event, so they must be short, non-blocking and thread safe. The minimal agent below counts prepared classes.
// count_loads.c build: cc -shared -fPIC -I$JAVA_HOME/include -I$JAVA_HOME/include/linux -o libcountloads.so count_loads.c
#include <jvmti.h>
#include <stdio.h>
#include <string.h>
static volatile long loads = 0;
static void JNICALL on_class_prepare(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, jclass klass) {
__sync_fetch_and_add(&loads, 1);
}
static void JNICALL on_vm_death(jvmtiEnv *jvmti, JNIEnv *jni) {
fprintf(stderr, "classes prepared: %ld\n", loads);
}
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved) {
jvmtiEnv *jvmti;
if ((*vm)->GetEnv(vm, (void **)&jvmti, JVMTI_VERSION_11) != JNI_OK) return JNI_ERR;
jvmtiCapabilities caps;
memset(&caps, 0, sizeof(caps)); // ask only for what you need
if ((*jvmti)->AddCapabilities(jvmti, &caps) != JVMTI_ERROR_NONE) return JNI_ERR;
jvmtiEventCallbacks cb;
memset(&cb, 0, sizeof(cb));
cb.ClassPrepare = &on_class_prepare;
cb.VMDeath = &on_vm_death;
(*jvmti)->SetEventCallbacks(jvmti, &cb, sizeof(cb));
(*jvmti)->SetEventNotificationMode(jvmti, JVMTI_ENABLE, JVMTI_EVENT_CLASS_PREPARE, NULL);
(*jvmti)->SetEventNotificationMode(jvmti, JVMTI_ENABLE, JVMTI_EVENT_VM_DEATH, NULL);
return JNI_OK;
}
// run: java -agentpath:/opt/agents/libcountloads.so -jar app.jar
java.lang.instrument: premain, transformers and the manifest
A Java agent jar declares itself in its manifest. Premain-Class names the class whose premain runs before the application's main method when loaded with -javaagent. Agent-Class names the class whose agentmain runs when the agent is attached later. Can-Redefine-Classes, Can-Retransform-Classes and Can-Set-Native-Method-Prefix request the matching abilities, and Boot-Class-Path appends jars to the bootstrap class path.
The core interface is ClassFileTransformer. Registered transformers are called for every class as it is defined, receiving the module, the defining loader, the internal class name with slashes, the class being redefined if any, the protection domain and the class file bytes. Returning null means no change. Returning new bytes replaces the class definition, and those bytes are passed to the next transformer in the chain.
// META-INF/MANIFEST.MF
// Premain-Class: com.acme.agent.TimingAgent
// Agent-Class: com.acme.agent.TimingAgent
// Can-Retransform-Classes: true
package com.acme.agent;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
public final class TimingAgent {
public static void premain(String args, Instrumentation inst) { install(args, inst); }
public static void agentmain(String args, Instrumentation inst) { install(args, inst); }
private static void install(String args, Instrumentation inst) {
ClassFileTransformer t = new ClassFileTransformer() {
@Override
public byte[] transform(Module module, ClassLoader loader, String internalName,
Class<?> redefining, ProtectionDomain pd, byte[] classfile) {
if (internalName == null || !internalName.startsWith("com/acme/orders/")) {
return null; // null means "unchanged": the cheap path
}
try {
return Rewriter.addTiming(classfile); // ASM, Byte Buddy or java.lang.classfile
} catch (Throwable e) {
AgentLog.error("transform failed for " + internalName, e);
return null; // never let a bad rewrite take down the app
}
}
};
inst.addTransformer(t, /* canRetransform = */ true);
}
}Two rules are not optional. Filter early and return null for classes you do not own, since transformers see every class loaded. And catch everything inside transform: the JVM ignores a transformer's exception and keeps the original bytes, so the class silently runs uninstrumented.
Redefine versus retransform
Instrumentation.redefineClasses replaces a loaded class with new bytes you supply, which is how hot-swap in debuggers works. Instrumentation.retransformClasses re-runs the registered retransform-capable transformers starting from the class's original bytes, which is how an agent attached late instruments classes that were loaded before it arrived, and how it removes its instrumentation again by returning null.
Both are limited in HotSpot. A redefinition may change method bodies, the constant pool and attributes, but may not add, remove or rename fields or methods, change method signatures or modifiers, or change the class hierarchy; such a request fails with UnsupportedOperationException. This is why instrumentation libraries inline advice into existing method bodies rather than adding helper methods when they work on already-loaded classes. Existing activations of a redefined method keep running the old code; only new calls see the new version.
Retransformation is not free: it pauses at a safepoint and discards dependent compiled code, so retransforming thousands of classes at attach time causes a visible latency pause.
Rewriting bytecode: ASM, Byte Buddy and the ClassFile API
An agent that changes behaviour must produce valid class files, including correct stack map frames, which hand-written byte manipulation gets wrong quickly. ASM is the long-standing low-level library: a visitor over class file structure that most other tools build on. Byte Buddy sits on top of ASM and offers a high-level agent builder with matchers and advice, where advice methods are written in ordinary Java and inlined into target methods. Since JDK 24, the JDK also includes a standard API for parsing and generating class files, java.lang.classfile (JEP 484), which tracks new class file versions with the JDK itself and removes the lag that third-party libraries have when a new release changes the format.
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import static net.bytebuddy.matcher.ElementMatchers.*;
public final class OrdersAgent {
public static void premain(String args, java.lang.instrument.Instrumentation inst) {
new AgentBuilder.Default()
.ignore(nameStartsWith("net.bytebuddy.").or(nameStartsWith("com.acme.agent.")))
.type(nameStartsWith("com.acme.orders."))
.transform((builder, type, loader, module, pd) ->
builder.visit(Advice.to(TimingAdvice.class).on(isMethod().and(isPublic()))))
.installOn(inst);
}
}
// Advice code is copied into the target method; it must only use classes the target's loader can see.
public final class TimingAdvice {
@Advice.OnMethodEnter
static long enter() { return System.nanoTime(); }
@Advice.OnMethodExit(onThrowable = Throwable.class)
static void exit(@Advice.Enter long start, @Advice.Origin String method) {
com.acme.agent.api.Timings.record(method, System.nanoTime() - start);
}
}The ignore clause stops the agent instrumenting its own or the library's classes, which can recurse during class loading. Prefix matching is cheap; matching on annotations or supertypes resolves type information for every class and costs startup time.
Worked example: a method timing agent end to end
Goal: measure the latency of every public method in the com.acme.orders package of a service, without touching its source. Step one is the jar layout: the agent classes, the Byte Buddy dependency shaded into a private package so it cannot clash with a version the application ships, and a manifest with Premain-Class, Agent-Class and Can-Retransform-Classes: true.
Step two is the classloader plan. The advice code is copied into com.acme.orders classes, which are loaded by the application class loader, so anything the advice references, here com.acme.agent.api.Timings, must be visible from that loader. Agents loaded with -javaagent sit on the system class path, which works for flat class paths. Inside application servers or plugin systems with isolated loaders, put that small API in a separate jar and add it with Instrumentation.appendToBootstrapClassLoaderSearch so every loader can see it. The behaviour of class loader delegation behind this is covered in Java class loading.
Step three is the run: java -javaagent:orders-agent.jar -jar orders.jar. The agent logs how many types it transformed, exports percentiles from Timings through the metrics endpoint, and honours a disable flag read in premain. Step four: run the load test with and without the agent and compare throughput and p99; if overhead is visible, narrow the matcher or sample.
Running agents in production
- Declare agents at startup. Put
-javaagentin the container entrypoint orJAVA_TOOL_OPTIONS, reviewed like any other dependency. Reserve dynamic attach for diagnostics, and decide explicitly whether to set-XX:+EnableDynamicAgentLoading. - Pin to JDK versions. Bytecode libraries must understand the class file version of the JDK you run. Test agent upgrades and JDK upgrades together, before production.
- Count agents. Two APM agents plus a security agent means three transformer chains over the same classes. Stacking them is a common source of startup slowdowns and verification errors.
- Build a kill switch. An option or environment variable that makes
premainreturn without registering anything turns an agent incident into a restart instead of a rebuild. - Observe the agent itself. Log transformation counts and failures; watch startup time and metaspace.
- Prefer built-in telemetry where it suffices. JDK Flight Recorder gives low-overhead events without any agent; sampling profilers such as async-profiler show where a JVMTI agent is the right tool.
Failure modes
| Symptom | Likely cause | What to do |
|---|---|---|
NoClassDefFoundError inside instrumented code | Advice references a class the target loader cannot see | Bootstrap-append a small API jar; keep advice dependencies minimal |
VerifyError at class load | Invalid stack map frames or conflicting rewrites | Use a library that computes frames; test with every agent you deploy together |
| Instrumentation silently missing | Transformer threw; JVM used the original bytes | Catch and log in transform; alert on non-zero failure count |
| Startup much slower | Expensive matchers or transforming every class | Filter by name first; ignore JDK and library packages |
| Throughput collapse | Method entry/exit events or heavy advice on hot paths | Sample instead; narrow matchers; measure with and without |
| Warning about dynamic agent loading | Agent attached to a running JDK 21+ VM | Load at startup or enable explicitly with the flag |
| JVM crash | Native agent bug or a callback that blocks | Keep callbacks minimal; test native agents under load and with -Xcheck:jni |
The trade-off: agents change behaviour without touching application code, at the cost of a hidden code path that every JDK, library or class loader change can break. Keep them small and test them like the application. Startup features such as class data sharing interact with class-transforming agents, so measure startup with your real agent set.
What to do next
- List every agent in your production JVMs by checking command lines and
JAVA_TOOL_OPTIONS, and record its owner and version. - Run your service on JDK 21 or later and check logs for the dynamic agent loading warning; move any attached agent to startup.
- Build the timing agent from this page with Byte Buddy, shaded, and run it against a test service with a load test before and after.
- Add a catch-and-count around every transformer you own and expose the failure count as a metric.
- Add a kill switch option to each in-house agent and document it in the runbook.
- Before writing a new agent, check whether JFR events or an existing profiler already answer the question.