Java Agent COMPUTE_FRAMES Crash on Missing ClassLoader
Monitoring agent deployment caused 10x startup delay and ClassNotFoundException.
- Java Agent is a JAR with a premain() or agentmain() method declared in MANIFEST.MF — the JVM calls it before main(), handing you an Instrumentation instance
- ClassFileTransformer.transform() intercepts raw class bytes at load time — return null to skip, return modified bytes to transform
- ASM is the standard library for bytecode manipulation — use AdviceAdapter for clean method entry/exit hooks
- Startup agents (-javaagent) register before main(); Attach API (VirtualMachine.loadAgent) injects into a running JVM
- Performance cost: transformation time per class load + injected code overhead per method call — scope to the narrowest package prefix
- Biggest mistake: not shading ASM — Spring's bundled ASM version causes NoSuchMethodError at class load
Imagine every Java class is a recipe card in a chef's kitchen. Normally, the chef follows each recipe exactly as written. A Java agent is like a head chef who intercepts every recipe card before it reaches the line cooks — they can scribble notes on it, change an ingredient, or add a timing step, without ever touching the original recipe book. The line cooks (the JVM) never even know the card was modified; they just cook what's in front of them. That's instrumentation: quietly editing the instructions before they're executed.
Production Java systems are black boxes. You deploy a JAR, traffic hits it, and when something goes wrong — a memory leak, a slow database call, an unexpected exception — you're often blind. APM tools like Datadog, New Relic, and Dynatrace somehow see inside every method call, every HTTP request, every SQL query, all without you touching a single line of your application code. The mechanism behind all of them is the Java Agent API, and understanding it puts you in rare company.
The Java agent mechanism, introduced in Java 5 via the java.lang.instrument package, solves a profound problem: how do you add cross-cutting behavior — logging, tracing, profiling, security checks — to a running JVM without modifying source code or recompiling? The answer is bytecode instrumentation. At class-load time, before the JVM hands a class to its execution engine, an agent intercepts the raw bytes and rewrites them. It's surgical, powerful, and completely transparent to the application.
By the end of this article you'll be able to write a working Java agent from scratch, attach it to any JVM process, use the Instrumentation API to transform class bytecode with ASM, understand the production implications including memory overhead and class retransformation limits, and walk into an interview knowing exactly how tools like JaCoCo and OpenTelemetry Java work under the hood.
How the JVM Loads a Java Agent — premain, MANIFEST.MF, and the Attach API
A Java agent is a plain JAR file with one special contract: a class containing a static premain method (for startup-time agents) or an agentmain method (for runtime attach). The JVM calls premain before your application's own main method, handing it an Instrumentation instance — the single most powerful object in the Java platform. Through it you can query loaded classes, redefine them, and most critically, register a ClassFileTransformer that intercepts every class as it's loaded.
The JAR's MANIFEST.MF is the contract document. Without the Premain-Class attribute pointing to your agent class, the JVM refuses to honour the -javaagent flag. You can also declare Can-Redefine-Classes: true and Can-Retransform-Classes: true there — both must be opt-in because they carry real overhead and security implications.
The Attach API (com.sun.tools.attach) lets you inject an agent into an already running JVM process without restarting it. This is how profilers like async-profiler and VisualVM work. The target JVM's PID is all you need. One critical catch: since Java 9, self-attach (attaching to your own PID) requires -Djdk.attach.allowAttachSelf=true or it throws an IOException.
One subtle point: premain runs on the main thread before any application code. That means you can safely set up singletons or static state without worrying about race conditions. But if your agent initialisation fails, the JVM will not start your application — it exits with an error. Make sure premain is idempotent and fast. Also note that the agent's own classes are loaded by the system classloader, not the bootstrap loader. If your agent needs to add classes to the bootstrap classpath (e.g., a Java agent that provides a new implementation of a core API), use instrumentation.appendToBootstrapClassLoaderSearch() — but this is rarely needed and carries its own risks.
Manifest File Setup Guide: Required Attributes and Common Mistakes
The META-INF/MANIFEST.MF file inside your agent JAR is the contract between you and the JVM. Without the correct attributes, the JVM will silently ignore your agent. The three essential attributes for a startup agent are: - Premain-Class: Fully qualified name of the class containing the premain(String, Instrumentation) method. - Agent-Class: Fully qualified name of the class containing the agentmain(String, Instrumentation) method (required for runtime attach). - Can-Retransform-Classes: Set to "true" if you plan to call retransformClasses() on already-loaded classes (required for most runtime attach scenarios). Optional but often needed: Can-Redefine-Classes, Boot-Class-Path.
The manifest file is whitespace-sensitive. Each line must end with a newline character, including the last line. Attribute names and values are separated by a colon and a space. No trailing spaces allowed. If an attribute value is too long, you can wrap it to the next line by starting the continuation line with a single space.
To generate the manifest automatically in Maven, use the maven-jar-plugin or maven-shade-plugin's ManifestResourceTransformer (as shown in the pom.xml example later). For manual builds, create a text file named manifest.txt with these contents:
unzip -p agent.jar META-INF/MANIFEST.MF and check the output ends with a newline.unzip -p to verify before shipping.Bytecode Manipulation Libraries: ASM vs ByteBuddy vs Javassist Compared
While ASM is the de facto standard for production Java agents, several libraries simplify bytecode manipulation with higher-level APIs. Choosing the right one depends on your performance requirements, complexity of transformations, and team expertise.
ASM (Low-level) – Direct bytecode instruction manipulation. Offers maximum performance and control. Used by the JDK itself. Requires understanding of the JVM instruction set and stack frames. Best for production APM agents where overhead must be minimal.
ByteBuddy (High-level) – Builds on ASM but provides a fluent API for common tasks like method delegation, field access, and type creation. Automatically handles frame computation and class writer configuration. Excellent for ad-hoc instrumentation and debugging tools. Some overhead compared to raw ASM, but negligible for typical use.
Javassist (Medium-level) – Uses a source-level API: you write Java code as strings that get compiled and injected. Very easy to get started, but limited for complex transformations (e.g., adding try-catch blocks). Performance is lower due to interpretation. Best for quick prototypes or simple method wrapping.
| Feature | ASM | ByteBuddy | Javassist |
|---|---|---|---|
| API Level | Instruction-level | High-level (fluent) | Source-level (String) |
| Learning Curve | Steep | Moderate | Shallow |
| Performance | Fastest | Very fast (on ASM) | Moderate |
| Stack Frame Handling | Manual or COMPUTE_FRAMES | Automatic | Automatic |
| Class Creation | Possible but manual | Built-in (DynamicType) | Easy (new ClassPool) |
| File Size (jar) | ~500 KB | ~1.5 MB | ~800 KB |
| Production Use | JaCoCo, OpenTelemetry | Mockito, Hibernate | Spring AOP (CGLib replacement) |
For most production scenarios, ASM remains the best choice due to its performance and no-framework dependency. ByteBuddy is ideal when you need to write an agent quickly without deep bytecode expertise. Javassist is useful for simple filtering but not recommended for production agents that modify control flow.
Writing a ClassFileTransformer with ASM — Intercepting Method Calls in Raw Bytecode
The ClassFileTransformer interface has one method: transform. It receives raw class bytes and returns either modified bytes or null (meaning 'leave this class alone'). Returning null for classes you don't care about is critical for performance — every transformer in the chain gets called for every class, including java.lang.String and java.util.ArrayList.
To actually modify bytecode, you need a bytecode manipulation library. ASM is the industry standard — it's what the JDK itself uses internally. It operates at the instruction level at method entry, every exit point: a method can have multiple RETURN opcodes and any number of ATHROW instructions. ASM's AdviceAdapter handles this elegantly by providing onMethodEnter and onMethodExit hooks that account for all exit paths automatically, including the exception path.
The dependency you need is org.ow2.asm:asm:9.6 and org.ow2.asm:asm-commons:9.6. Bundle them into your agent JAR with Maven Shade or a fat-jar Gradle task.
AdviceAdapter works on all methods including constructors (<init>) and static initializers (<clinit>). Be careful with <init>: System.nanoTime() in a constructor of a frequently created object can add measurable overhead. Also, inside <clinit>, the class is being initialised, and calling System.nanoTime() is fine, but avoid any method calls that trigger additional class loading at that point — you can cause circular class loading deadlocks. If your transformer needs to skip <clinit> because of this, check the method name and return the original visitor for it.
Runtime Attach, Class Retransformation, and Production Performance Gotchas
Startup agents are clean but require a JVM restart. The Attach API lets you inject an agent into a live PID — indispensable for production diagnosis. The VirtualMachine.attach(pid) call establishes a socket connection to the target JVM's attach listener thread, then loadAgent sends the agent JAR path over that socket. The target JVM loads your JAR, calls agentmain, and if Can-Retransform-Classes: true is set, you can call instrumentation.retransformClasses(MyClass.class) to retroactively apply your transformer to already-loaded classes.
Retransformation is powerful but constrained. You cannot change the class schema: no adding fields, no adding methods, no changing method signatures or the class hierarchy. You can only change method bodies. Violating this causes an UnsupportedOperationException with the message 'class redefinition failed: attempted to change the schema'. This is a JVM limitation, not ASM's — the JVM's HotSpot runtime can hot-swap method bodies but cannot reorganize vtables or field layouts without a full GC pause and internal restructuring it simply doesn't support.
The performance cost of instrumentation has three parts. First, transformation time: every class load runs your transformer. Keep the fast-path null return tight — a single startsWith check costs nanoseconds. Second, the injected code itself: System.nanoTime() is not free (it's a JNI call on some platforms), and if you're logging inside transformed methods you're paying I/O costs on every call. Third, and most insidious: ClassWriter.COMPUTE_FRAMES triggers ASM to load referenced classes through the provided ClassLoader to compute frame types, which can cause unexpected class loading side effects. Pass the original ClassLoader from the transform signature into your ClassWriter to avoid ClassNotFoundException at transformation time.
The Attach API uses a socket file (/tmp/.java_pid<pid> on Linux). If the target JVM is running in a container without shared /tmp, the attach listener socket may not be accessible. This is a common issue in Kubernetes environments. To work around, ensure the /tmp directory is shared or use -XX:+StartAttachListener with a custom path via -XX:+AttachListener. Also, the attach listener thread might be blocked by a long GC pause — the call will timeout after about 10 seconds. In such cases, retry with a longer timeout (unfortunately the JVM doesn't expose a configurable timeout; you'll need to implement a retry loop in your attacher).attach()
Production Outage: Agent's COMPUTE_FRAMES Crashes JVM on Class Load
transform() method signature. ASM internally loads referenced classes to compute stack map frames, and without the correct ClassLoader, it couldn't find the new library's classes, causing the transform to throw an exception that was silently caught (returning null), which then cascaded as later transformers and class resolution failed.- Always pass the original ClassLoader to ASM's ClassWriter when using COMPUTE_FRAMES.
- Never rely on the context ClassLoader — it may be null for bootstrap classes.
- Log all transform exceptions with full stack traces — silenced failures mask production outages.
- Test agent updates against a representative workload before rolling to production.
unzip -p agent.jar META-INF/MANIFEST.MF to inspect. Check JVM flags for typos in -javaagent path.transform() method directly to ClassWriter constructor. If using a different ClassLoader, the transform may fail to resolve types during frame computation.That's Advanced Java. Mark it forged?
7 min read · try the examples if you haven't