Senior 15 min · March 06, 2026

Java Agent COMPUTE_FRAMES Crash on Missing ClassLoader

Monitoring agent deployment caused 10x startup delay and ClassNotFoundException.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is Java Agent and Instrumentation?

Java Agent Instrumentation is the JVM's built-in mechanism for modifying bytecode at load time or retransforming already-loaded classes at runtime. It exists because production Java applications often need to inject cross-cutting concerns—APM tracing, profiling, security policies, or AOP—without touching source code or recompiling.

Imagine every Java class is a recipe card in a chef's kitchen.

The core contract is a premain (or agentmain for dynamic attach) method in a JAR with a MANIFEST.MF that declares Premain-Class and optionally Can-Retransform-Classes. When you specify -javaagent:youragent.jar on the command line, the JVM invokes your premain before main(), passing an Instrumentation instance that lets you register ClassFileTransformer instances.

These transformers receive raw byte arrays and return modified ones, giving you full control over every class the JVM loads—including system classes if you set the right manifest flags.

Under the hood, the JVM calls your transformer's transform() method for each class definition or retransformation. You receive the class name, the current class loader (or null for the bootstrap loader), a protection domain, and the raw bytes. You return new bytes or null to skip.

This is where the COMPUTE_FRAMES crash bites you: when you use ASM's ClassWriter.COMPUTE_FRAMES option, ASM tries to compute stack map frames by simulating execution. It needs to resolve class references to determine types on the operand stack—but if the class loader that loaded the class being transformed isn't available during the computation (e.g., because the transformer runs in a different class loader context or the class loader has been garbage collected), ASM throws a ClassNotFoundException or NullPointerException.

The fix is either to use COMPUTE_MAXS instead (which only computes max stack/local sizes, not full frames) or to manually provide frame information, which is why production agents like ByteBuddy default to COMPUTE_MAXS and avoid frame computation entirely.

In the ecosystem, you have three main bytecode libraries: ASM is the lowest-level, giving you direct event-driven parsing of class files—it's what the JVM itself uses internally, and it's the fastest but most error-prone. ByteBuddy wraps ASM with a fluent API and handles frame computation safely by default, making it the go-to for new agents (used by DataDog, New Relic, and OpenTelemetry).

Javassist offers a higher-level source-like API but is slower and less flexible for complex transformations. You should not use Java Agent instrumentation when you can solve the problem with compile-time weaving (AspectJ), runtime proxies (Spring AOP), or when you're targeting Java 9+ modules without proper module exports—the module system can block access to internal JDK classes that agents often need to transform.

Plain-English First

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 Java Agent Instrumentation Actually Rewrites Your Bytecode

Java agent instrumentation is the ability to modify class bytecode at load time via the java.lang.instrument API. An agent registers a ClassFileTransformer that intercepts every class definition before the JVM links it. The transformer receives the raw byte array and can return a modified version — adding methods, injecting fields, or altering control flow. This happens in the primordial class loader context, which is the root cause of many crashes.

Agents attach either at startup (-javaagent) or dynamically via the Attach API. The critical property: the transformer runs inside the system class loader, not the target class's loader. When your transformed code references a class that isn't visible to the system loader, the JVM throws NoClassDefFoundError or, worse, a ClassCircularityError during COMPUTE_FRAMES. The ASM library's COMPUTE_FRAMES option, which calculates stack map frames automatically, triggers class loading for supertypes and interfaces — and if that load fails, the transformation crashes the JVM.

Use agents when you need cross-cutting behavior without source changes: APM tools, profilers, security scanners, or AOP frameworks. The cost is fragility around class loading boundaries. In production, a single missing dependency in the agent's classpath can silently corrupt stack frames, leading to VerifyError at runtime — not at agent load time.

COMPUTE_FRAMES triggers class loading
ASM's COMPUTE_FRAMES resolves supertypes at transform time. If the supertype is not visible to the system class loader, the agent crashes — not the target class.
Production Insight
A monitoring agent using COMPUTE_FRAMES crashed every deployment that included a custom ClassLoader loading classes from a non-standard repository.
Symptom: JVM exits with 'java.lang.NoClassDefFoundError: com/example/CustomSuperClass' during class loading of an unrelated application class.
Rule: Never use COMPUTE_FRAMES in production agents — precompute frames with COMPUTE_MAXS or provide explicit stack map frames.
Key Takeaway
Agent transformers run in the system class loader — any class reference must be visible there or you get a crash.
COMPUTE_FRAMES is the #1 cause of agent-induced VerifyErrors in production; precompute frames or use COMPUTE_MAXS.
Always test agent transformations against the actual class loading hierarchy of your target application.
Java Agent COMPUTE_FRAMES Crash on Missing ClassLoader THECODEFORGE.IO Java Agent COMPUTE_FRAMES Crash on Missing ClassLoader Flow from agent loading to bytecode rewrite failure premain() Entry Agent loaded via -javaagent or attach ClassFileTransformer Intercept class loading with ASM/ByteBuddy COMPUTE_FRAMES ASM recomputes stack map frames Missing ClassLoader Frames computation fails without loader Class Retransformation Runtime attach triggers re-transform Shaded Agent JAR Bundle dependencies to avoid conflicts ⚠ COMPUTE_FRAMES crashes when ClassLoader is null Always provide ClassLoader or use COMPUTE_MAXS THECODEFORGE.IO
thecodeforge.io
Java Agent COMPUTE_FRAMES Crash on Missing ClassLoader
Java Agent Instrumentation

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.

TimingAgent.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.lang.instrument.Instrumentation;

/**
 * Entry point for a startup-time Java agent.
 *
 * Compile this class, package it in a JAR, and declare it in MANIFEST.MF:
 *   Premain-Class: TimingAgent
 *   Can-Retransform-Classes: true
 *
 * Launch with:
 *   java -javaagent:timing-agent.jar -jar your-app.jar
 */
public class TimingAgent {\n\n    /**\n     * The JVM calls this BEFORE your application's main() method.\n     *\n     * @param agentArgs  Any string args passed after '=' in -javaagent:jar=args\n     * @param instrumentation  The gateway to all bytecode transformation power\n     */\n    public static void premain(String agentArgs, Instrumentation instrumentation) {\n        System.out.println(\"[TimingAgent] Agent loaded. JVM classes currently loaded: \"\n                + instrumentation.getAllLoadedClasses().length);\n\n        // Register our transformer — it will be called for EVERY class the JVM loads\n        // from this point forward, including JDK classes if we don't filter carefully\n        MethodTimingTransformer transformer = new MethodTimingTransformer(agentArgs);\n        instrumentation.addTransformer(transformer, /* canRetransform= */ true);\n\n        System.out.println(\"[TimingAgent] ClassFileTransformer registered. Ready.\");\n    }\n\n    /**\n     * Called when the agent is attached to a RUNNING JVM via the Attach API.\n     * The signature is identical to premain but the JVM lifecycle moment differs.\n     */\n    public static void agentmain(String agentArgs, Instrumentation instrumentation) {\n        // Reuse the same startup logic — safe because we're stateless here\n        premain(agentArgs, instrumentation);\n    }\n}",
        "output": "[TimingAgent] Agent loaded. JVM classes currently loaded: 412\n[TimingAgent] ClassFileTransformer registered. Ready."
      }

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:

META-INF/MANIFEST.MFTEXT
1
2
3
4
5
Manifest-Version: 1.0
Premain-Class: io.thecodeforge.agent.MyAgent
Agent-Class: io.thecodeforge.agent.MyAgent
Can-Retransform-Classes: true
Can-Redefine-Classes: false
Output
Verified with: unzip -p agent.jar META-INF/MANIFEST.MF
Whitespace Kills Agents
A missing newline at the end of the manifest file causes the JVM to ignore the last attribute. Always verify with unzip -p agent.jar META-INF/MANIFEST.MF and check the output ends with a newline.
Production Insight
A missing newline at end of MANIFEST.MF silently skips the Premain-Class attribute. The JVM logs nothing — the agent just never loads. Rule: always end MANIFEST.MF with a blank line. In Maven, ensure the ManifestResourceTransformer produces a proper final newline.
Key Takeaway
MANIFEST.MF must end with a newline. Use 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.

FeatureASMByteBuddyJavassist
API LevelInstruction-levelHigh-level (fluent)Source-level (String)
Learning CurveSteepModerateShallow
PerformanceFastestVery fast (on ASM)Moderate
Stack Frame HandlingManual or COMPUTE_FRAMESAutomaticAutomatic
Class CreationPossible but manualBuilt-in (DynamicType)Easy (new ClassPool)
File Size (jar)~500 KB~1.5 MB~800 KB
Production UseJaCoCo, OpenTelemetryMockito, HibernateSpring 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.

ByteBuddyAgent.javaJAVA
1
2
3
4
5
6
7
8
9
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;
import java.lang.instrument.Instrumentation;

public class ByteBuddyAgent {
    public static void premain(String args, Instrumentation inst) {\n        new AgentBuilder.Default()\n            .type(ElementMatchers.nameStartsWith(\"io.codeforge\"))\n            .transform((builder, type, cl, mod) ->\n                builder.method(ElementMatchers.any())\n                    .intercept(Advice.to(MethodTimer.class)))\n            .installOn(inst);\n    }\n\n    public static class MethodTimer {\n        @Advice.OnMethodEnter\n        static long enter() { return System.nanoTime(); }\n        @Advice.OnMethodExit\n        static void exit(@Advice.Enter long start, @Advice.Origin String method) {\n            System.out.println(method + \" took \" + (System.nanoTime() - start) + \" ns\");\n        }\n    }\n}",
        "output": "// Build dependency: net.bytebuddy:byte-buddy-agent:1.14.12 + byte-buddy:1.14.12\n// Run with -javaagent:bytebuddy-agent.jar\n// Output: io.codeforge.demo.OrderService#processOrder took 4321871 ns"
      }

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.

MethodTimingTransformer.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import org.objectweb.asm.*;
import org.objectweb.asm.commons.AdviceAdapter;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;

/**
 * Intercepts class loading and injects timing code into every method
 * of classes whose package matches our target prefix.
 *
 * ASM version: 9.6  (org.ow2.asm:asm-commons:9.6)
 */
public class MethodTimingTransformer implements ClassFileTransformer {

    // Only instrument classes under this package to avoid touching JDK internals
    private final String targetPackagePrefix;

    public MethodTimingTransformer(String agentArgs) {
        // agentArgs might be something like "io/codeforge/demo"
        this.targetPackagePrefix = (agentArgs != null && !agentArgs.isBlank())
                ? agentArgs.replace('.', '/')
                : "io/codeforge"; // default to our own package
    }

    @Override
    public byte[] transform(
            ClassLoader loader,
            String className,           // Internally formatted: "com/example/MyClass"
            Class<?> classBeingRedefined,
            ProtectionDomain protectionDomain,
            byte[] originalClassBytes) {

        // Fast-path: skip anything that's not our target to keep overhead minimal
        if (className == null || !className.startsWith(targetPackagePrefix)) {
            return null; // null = "I didn't change anything, use original bytes"
        }

        try {\n            ClassReader classReader = new ClassReader(originalClassBytes);\n            // COMPUTE_FRAMES tells ASM to recalculate stack frames automatically\n            // This is essential — manual frame calculation is error-prone\n            ClassWriter classWriter = new ClassWriter(\n                    classReader, ClassWriter.COMPUTE_FRAMES);\n\n            // Wrap the writer with our visitor that injects timing into each method\n            TimingClassVisitor timingVisitor = new TimingClassVisitor(\n                    Opcodes.ASM9, classWriter, className);\n\n            // EXPAND_FRAMES is required when using AdviceAdapter with COMPUTE_FRAMES\n            classReader.accept(timingVisitor, ClassReader.EXPAND_FRAMES);\n\n            System.out.println(\"[TimingAgent] Instrumented: \" + className);\n            return classWriter.toByteArray(); // Return the rewritten bytecode\n\n        } catch (Exception transformationError) {\n            // NEVER let a transformer exception crash the JVM — log and return null\n            System.err.println(\"[TimingAgent] Failed to instrument \" + className\n                    + \": \" + transformationError.getMessage());\n            return null; // Fall back to the original unmodified class\n        }\n    }\n\n    // ── Inner class: visits each class and delegates method visiting ─────────\n\n    static class TimingClassVisitor extends ClassVisitor {\n\n        private final String ownerClassName;\n\n        TimingClassVisitor(int api, ClassVisitor delegate, String ownerClassName) {\n            super(api, delegate);\n            this.ownerClassName = ownerClassName;\n        }\n\n        @Override\n        public MethodVisitor visitMethod(\n                int access, String methodName, String descriptor,\n                String signature, String[] exceptions) {\n\n            // Get the default MethodVisitor from the ClassWriter chain\n            MethodVisitor originalVisitor = super.visitMethod(\n                    access, methodName, descriptor, signature, exceptions);\n\n            // Skip abstract and native methods — they have no bytecode body to visit\n            boolean isAbstract = (access & Opcodes.ACC_ABSTRACT) != 0;\n            boolean isNative   = (access & Opcodes.ACC_NATIVE) != 0;\n            if (isAbstract || isNative || originalVisitor == null) {\n                return originalVisitor;\n            }\n\n            // Wrap with our timing advisor\n            return new TimingMethodAdvisor(\n                    Opcodes.ASM9, originalVisitor, access,\n                    methodName, descriptor, ownerClassName);\n        }\n    }\n\n    // ── Inner class: injects nanoTime calls at method entry and exit ──────────\n\n    static class TimingMethodAdvisor extends AdviceAdapter {\n\n        private final String ownerClass;\n        private final String methodName;\n        private int startTimeLocalVarIndex; // index of our injected local variable\n\n        TimingMethodAdvisor(int api, MethodVisitor delegate, int access,\n                            String methodName, String descriptor, String ownerClass) {\n            super(api, delegate, access, methodName, descriptor);\n            this.ownerClass  = ownerClass;\n            this.methodName  = methodName;\n        }\n\n        @Override\n        protected void onMethodEnter() {\n            // Inject: long __startTime = System.nanoTime();\n            // INVOKESTATIC pushes the long result onto the stack\n            mv.visitMethodInsn(\n                    Opcodes.INVOKESTATIC,\n                    \"java/lang/System\",\n                    \"nanoTime\",\n                    \"()J\",\n                    false);\n\n            // Store that long into a new local variable\n            // newLocal() allocates the next available slot in the local variable table\n            startTimeLocalVarIndex = newLocal(Type.LONG_TYPE);\n            mv.visitVarInsn(Opcodes.LSTORE, startTimeLocalVarIndex);\n        }\n\n        @Override\n        protected void onMethodExit(int opcode) {\n            // This is called for EVERY exit: RETURN, IRETURN, ARETURN, ATHROW, etc.\n            // AdviceAdapter handles all the variants — we just implement this once\n\n            // Inject: long __elapsed = System.nanoTime() - __startTime;\n            mv.visitMethodInsn(\n                    Opcodes.INVOKESTATIC, \"java/lang/System\",\n                    \"nanoTime\", \"()J\", false);\n            mv.visitVarInsn(Opcodes.LLOAD, startTimeLocalVarIndex);\n            mv.visitInsn(Opcodes.LSUB); // stack now has elapsed nanoseconds (long)\n\n            // Inject: TimingReporter.record(\"io/codeforge/Foo#doWork\", elapsed);\n            // We push the method label as a constant string first\n            mv.visitLdcInsn(ownerClass + \"#\" + methodName);\n            // Swap so the stack order is (String label, long elapsed) matching the descriptor\n            // Actually we need to restructure: push label first, then elapsed\n            // Easiest: store elapsed, push label, reload elapsed\n            int elapsedLocalVarIndex = newLocal(Type.LONG_TYPE);\n            mv.visitVarInsn(Opcodes.LSTORE, elapsedLocalVarIndex);\n            mv.visitLdcInsn(ownerClass + \"#\" + methodName);\n            mv.visitVarInsn(Opcodes.LLOAD, elapsedLocalVarIndex);\n\n            mv.visitMethodInsn(\n                    Opcodes.INVOKESTATIC,\n                    \"io/codeforge/agent/TimingReporter\",\n                    \"record\",\n                    \"(Ljava/lang/String;J)V\",\n                    false);\n        }\n    }\n}",
        "output": "[TimingAgent] Instrumented: io/codeforge/demo/OrderService\n[TimingAgent] Instrumented: io/codeforge/demo/PaymentGateway\n[TimingAgent] Instrumented: io/codeforge/demo/InventoryRepository\n[TimingReporter] io/codeforge/demo/OrderService#processOrder => 4,312,871 ns (4.3 ms)\n[TimingReporter] io/codeforge/demo/PaymentGateway#charge => 231,048,203 ns (231 ms)\n[TimingReporter] io/codeforge/demo/InventoryRepository#findById => 892,441 ns (0.9 ms)"
      }

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 attach() 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).

LiveAttachDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
import java.util.List;

/**
 * Attaches a Java agent to a RUNNING JVM without restarting it.
 *
 * Requires: JDK (not just JRE), and the target JVM must allow self-attach or
 * be a separate process. Run this as a standalone program.
 *
 * Compile: javac --add-exports java.base/sun.nio.ch=ALL-UNNAMED LiveAttachDemo.java
 * Run:     java -cp .:$JAVA_HOME/lib/tools.jar LiveAttachDemo <target-pid> <agent-jar-path>
 *
 * Java 9+ note: tools.jar is gone; the Attach API is in jdk.attach module:
 *   java --add-modules jdk.attach LiveAttachDemo <pid> <agent.jar>
 */
public class LiveAttachDemo {\n\n    public static void main(String[] args) throws Exception {\n        if (args.length < 2) {\n            System.err.println(\"Usage: LiveAttachDemo <target-pid> <path-to-agent.jar>\");\n            System.exit(1);\n        }\n\n        String targetPid   = args[0];\n        String agentJarPath = args[1];\n\n        // List all JVMs on this machine so the user can verify the PID\n        System.out.println(\"\\n=== JVMs currently running on this machine ===\");\n        List<VirtualMachineDescriptor> jvmList = VirtualMachine.list();\n        for (VirtualMachineDescriptor descriptor : jvmList) {\n            System.out.printf(\"  PID: %-8s  Display: %s%n\",\n                    descriptor.id(), descriptor.displayName());\n        }\n        System.out.println();\n\n        // Attach to the target JVM — this opens a socket to its attach listener\n        System.out.println(\"[Attacher] Attaching to PID: \" + targetPid);\n        VirtualMachine targetJvm = VirtualMachine.attach(targetPid);\n\n        try {\n            // The JVM will call agentmain() in our TimingAgent class\n            // The second argument (agentOptions) becomes the agentArgs param in agentmain\n            String agentOptions = \"io/codeforge/demo\"; // package prefix to instrument\n            targetJvm.loadAgent(agentJarPath, agentOptions);\n            System.out.println(\"[Attacher] Agent loaded successfully into PID \" + targetPid);\n\n        } finally {\n            // Always detach to close the socket — a leaked attach can prevent\n            // future attaches and consume file descriptors\n            targetJvm.detach();\n            System.out.println(\"[Attacher] Detached cleanly.\");\n        }\n    }\n}",
        "output": "=== JVMs currently running on this machine ===\n  PID: 18443     Display: io.codeforge.demo.OrderServiceApp\n  PID: 22901     Display: LiveAttachDemo\n  PID: 31042     Display: org.gradle.launcher.daemon.bootstrap.GradleDaemon\n\n[Attacher] Attaching to PID: 18443\n[Attacher] Agent loaded successfully into PID 18443\n[Attacher] Detached cleanly.\n\n--- Output from PID 18443 (the target JVM) ---\n[TimingAgent] Agent loaded. JVM classes currently loaded: 5,847\n[TimingAgent] ClassFileTransformer registered. Ready.\n[TimingAgent] Instrumented: io/codeforge/demo/OrderService\n[TimingReporter] io/codeforge/demo/OrderService#processOrder => 6,102,887 ns (6.1 ms)"
      }

Shading Dependencies: Why Your Agent JAR Must Bundle Its Own ASM

One of the most common production failures with Java agents is a NoSuchMethodError or ClassNotFoundException for ASM classes at class load time. The root cause is a version conflict between the ASM version your agent bundles and the ASM version already on the application classpath — often from Spring, Hibernate, or another framework.

The solution is **shading**: repackaging your dependencies under a different package namespace so they don't collide. Use the Maven Shade plugin or Gradle Shadow plugin to relocate ASM from org.objectweb.asm to io.thecodeforge.shaded.org.objectweb.asm. Then update your transformer code to use the shaded imports.

Without shading, the JVM's classloader picks whichever ASM version loads first. If Spring's older ASM loads first, your agent's calls to newer ASM APIs fail with NoSuchMethodError. If your agent's ASM loads first, Spring breaks. Shading avoids this entirely.

pom.xml (shade plugin config)XML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <version>3.5.0</version>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <relocations>
              <relocation>
                <pattern>org.objectweb.asm</pattern>
                <shadedPattern>io.thecodeforge.shaded.org.objectweb.asm</shadedPattern>
              </relocation>
            </relocations>
            <filters>
              <filter>
                <artifact>*:*</artifact>
                <excludes>
                  <exclude>META-INF/MANIFEST.MF</exclude>
                </excludes>
              </filter>
            </filters>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>
Output
After shading, your agent JAR contains ASM classes under io.thecodeforge.shaded.org.objectweb.asm. No conflicts with application's ASM.
Don't Forget to Update Your Imports
After shading, you must update your transformer code to import from the shaded package (e.g., import io.thecodeforge.shaded.org.objectweb.asm.*). For ByteBuddy users, the ByteBuddy Agent Builder automatically handles shading. If you forget, your agent will still compile but fail at runtime with ClassNotFoundException.
Production Insight
ASM version conflicts cause silent startup failures that are notoriously hard to debug.
The error appears as VerifyError or NoSuchMethodError unrelated to your logic.
Shading is the only reliable fix — don't rely on classloader isolation hacks.
Always test your agent JAR with a real application that includes popular frameworks.
Key Takeaway
Shade ASM into your own package to avoid classpath conflicts.
Update imports after shading.
Test against Spring/Hibernate before shipping.

Static vs Dynamic Loading — When -javaagent Bites You in Production

The JVM gives you two ways to load an agent: premain (static) and agentmain (dynamic). Static loading uses the -javaagent flag at startup. The agent gets a premain method and runs before your main class. Dynamic loading uses the Attach API to hook into a running JVM. It calls agentmain instead.

Static loading is simpler. You drop the flag, the agent transforms classes as they load. No retransformation needed. But here's the trap: static agents see every class once. If you need to instrument a class that was loaded before your agent attached, you're screwed. That's where dynamic loading helps. You can retransform already-loaded classes using retransformClasses() on the Instrumentation instance.

Dynamic loading sounds great until you hit classloader hell. The Attach API requires the target JVM to have a compatible agent. Different JDK distributions handle this differently. OpenJDK-based JVMs work fine. IBM J9? Good luck. Also, dynamic loading can trigger ClassCircularityError if your transformer accidentally triggers class loading during a transform. That's a recursive nightmare that kills your JVM cold.

AgentLoadDecision.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// io.thecodeforge — java tutorial

import java.lang.instrument.Instrumentation;
import java.lang.management.ManagementFactory;
import com.sun.tools.attach.VirtualMachine;

public class AgentLoadDecision {
    public static void main(String[] args) throws Exception {
        // Check if agent was statically loaded
        boolean staticLoad = System.getProperty("javaagent") != null;
        
        if (!staticLoad) {
            // Dynamically attach to ourselves
            String pid = ManagementFactory.getRuntimeMXBean().getName().split("@")[0];
            VirtualMachine vm = VirtualMachine.attach(pid);
            try {
                vm.loadAgent("/path/to/my-agent.jar");
            } finally {
                vm.detach();
            }
        }
        
        System.out.println("Agent attached: " + staticLoad);
    }
}
Output
Agent attached: false
Production Trap: Dynamic Attach Hangs
VirtualMachine.attach() can hang indefinitely if the target JVM is not responding. Always wrap it in a timeout thread. Default timeout? There isn't one. You've been warned.
Key Takeaway
Use static loading for startup instrumentation. Use dynamic loading only when you absolutely need to instrument a class that was loaded before your agent existed — and be ready for classloader recursion.

Writing a Transformer That Doesn't Blow Up — ClassLoader Context Matters

Your ClassFileTransformer runs inside the class loading chain. That means your transformer's own classes must not trigger a class load that your transformer is expected to transform. If they do, you get a stall or a crash.

Rule one: never use the transformed class's ClassLoader to load anything in your transformer. If you do, you'll deadlock when that ClassLoader tries to load the transformer's helper class while the transformer holds the lock. Use a separate thread or pre-load everything in premain/agentmain.

Rule two: your transformer must be stateless unless you've synchronized access. Multiple threads can call transform() concurrently. If you're collecting metrics inside the transformer, use a ConcurrentHashMap or an atomic counter. Don't allocate memory inside transform() unless you want to flood the young gen with garbage. Pre-allocate buffers or use ThreadLocal pools.

Rule three: return null from transform() if you don't need to modify a class. Returning the original byte array is fine but wastes cycles. Returning null tells the JVM to skip retransformation entirely. That's faster. Every microsecond in transform() delays application startup by seconds in aggregate.

SafeTransformer.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// io.thecodeforge — java tutorial

import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

public class SafeTransformer implements ClassFileTransformer {
    private final ConcurrentHashMap<String, AtomicInteger> transformCount;
    
    public SafeTransformer() {
        this.transformCount = new ConcurrentHashMap<>();
    }
    
    @Override
    public byte[] transform(
            ClassLoader loader,
            String className,
            Class<?> classBeingRedefined,
            ProtectionDomain protectionDomain,
            byte[] classfileBuffer) {
        
        if (className == null || className.startsWith("java/")) {
            return null;  // Skip JDK classes — no allocation, no delay
        }
        
        // Only transform our target packages
        if (!className.startsWith("com/mycompany/")) {
            return null;
        }
        
        transformCount.computeIfAbsent(className, k -> new AtomicInteger()).incrementAndGet();
        return classfileBuffer;  // Return unchanged for now
    }
}
Output
(No output unless transformer triggers class loading — then you get a ClassCircularityError)
Senior Shortcut: Skip java/* Classes Entirely
Key Takeaway
Your transformer is part of the class loading chain. Keep it stateless, stateless, stateless. Return null for classes you don't need. Never allocate inside transform().

Agent Security — Why Your Agent Is a Backdoor Unless You Lock It Down

A Java agent runs with the same permissions as the JVM. That means your agent can read files, open sockets, and execute arbitrary code. If someone drops a malicious JAR into your classpath with an agent manifest, congratulations — you've just handed them the keys to your kingdom.

Security managers are deprecated starting Java 17. That old safety net is gone. The replacement is not here yet. So you need to validate what gets loaded. Sign your agent JAR and verify the signature before loading. Use a whitelist of allowed packages that your transformer will touch. Never transform classes outside that whitelist.

Another attack vector: the Attach API requires no authentication by default. If you're running on a shared host or in a container, any code with access to the JVM's temporary directory can attach an agent. Use Java's built-in agent attach permission check, or disable the Attach API entirely by setting the system property jdk.attach.allowAttachSelf=false.

For production, run a security audit on any third-party agent before deployment. OpenTelemetry, New Relic, AppDynamics — they all use the same API. One vulnerability in a popular agent can compromise thousands of deployments. Don't trust blindly.

AgentSecurityCheck.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// io.thecodeforge — java tutorial

import java.io.File;
import java.security.MessageDigest;
import java.util.Base64;

public class AgentSecurityCheck {
    
    // In production, store this hash securely (HSM, vault, etc.)
    private static final String EXPECTED_HASH = "a1b2c3d4e5f6g7h8i9j0klmnop";
    
    public static boolean verifyAgentIntegrity(String agentJarPath) throws Exception {
        File agentFile = new File(agentJarPath);
        if (!agentFile.exists() || !agentFile.isFile()) {
            System.err.println("[SECURITY] Agent JAR not found: " + agentJarPath);
            return false;
        }
        
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] hash = md.digest(java.nio.file.Files.readAllBytes(agentFile.toPath()));
        String computedHash = Base64.getEncoder().encodeToString(hash);
        
        if (!EXPECTED_HASH.equals(computedHash)) {
            System.err.println("[SECURITY] Agent tampered! Expected: " + EXPECTED_HASH);
            return false;
        }
        return true;
    }
    
    public static void main(String[] args) throws Exception {
        System.out.println("Agent integrity check: " + 
            verifyAgentIntegrity("/opt/agents/my-agent.jar"));
    }
}
Output
Agent integrity check: false
Security Hole: Unverified Agent JAR Loading
Always verify the integrity of any agent JAR before loading it, especially in shared environments. The Attach API doesn't check signatures. If an attacker replaces your agent JAR, they own your JVM.
Key Takeaway
Agent code runs with JVM-level trust. Sign your JAR, verify on load, disable the Attach API in multi-tenant environments. Security is not optional — it's table stakes.

Comparison to Other Approaches — AOP Frameworks, Proxies, and Compile-Time Weaving

Java agents aren't the only way to intercept or augment behavior. Before committing, compare how alternatives solve the same problem. Spring AOP uses runtime proxies (JDK dynamic proxies or CGLIB) — it only intercepts Spring beans and fails on final classes or self-invocation. Compile-time weaving (AspectJ with ajc) modifies bytecode at build time, giving you full control over join points without runtime overhead, but requires a different compilation pipeline and can't alter code you don't recompile. Byte-buddy agent-style instrumentation is the only approach that modifies any class loaded by the JVM, including third-party libraries and JDK internals, without source access or build changes. The trade-off: agents operate at class-load time and must handle concurrency, retransformation limits, and class loader isolation. Performance is comparable once applied, but agent startup and retransformation are heavier. Choose agents when you need system-wide interception, cannot control the build, or need runtime attach. Prefer proxies for simple bean-level logging and AspectJ for compile-time guarantees.

AgentVsAspect.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — java tutorial

// Proxy approach: only intercepts Spring beans
public interface Service {
    void execute();
}

// Agent approach: intercepts every Service implementation
// even ones you never compiled
public class MyAgent {
    public static void premain(String args, Instrumentation inst) {
        inst.addTransformer((loader, name, cl, pd, buf) -> {
            // modify bytecode of every class
            return transform(buf);
        });
    }
}

// AspectJ compile-time: requires ajc compiler
// aspect Logging {
//     pointcut call(): execution(* Service.*(..));
//     before(): call() { System.out.println("called"); }
// }
Output
Agent: system-wide, any class, runtime attach.
Proxy: Spring beans only, no final classes.
AspectJ: compile-time, full control, no runtime attach.
Production Trap:
If you only need to intercept your own beans, agents add complexity without benefit. Start with proxies. Upgrade to agents only when you must instrument external libraries or JDK classes.
Key Takeaway
Choose the interception technique that matches your scope: proxies for beans, AspectJ for build-time, agents for any class anywhere.

Java agents break silently. Here are the three worst pitfalls and how to fix them. First, class loader leaks: your transformer holds a reference to a ClassLoader, preventing GC of that loader and its classes. Always store loader as a WeakReference or clear mappings in transformer‘s transform method after processing. Second, retransformation limits: the JVM caps how many times you can retransform a class (default 100). Hitting this in production causes a Throwable that crashes the agent. Track retransformation counts per class and avoid repeated retransformations. Third, agent conflicts: two agents using different ASM versions can corrupt each other’s bytecode. Shade your own ASM copy and rename packages to avoid classpath clashes. Bonus: transformer exceptions are swallowed silently by the JVM. Wrap your transform method in a try-catch that logs errors and returns null to let the original bytecode load. Test with a production-like class load order — agents fail most often when transforming classes that are only present in certain environments.

PitfallFix.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — java tutorial

import java.lang.instrument.*;
import java.util.Map;
import java.util.WeakHashMap;

public class SafeTransformer implements ClassFileTransformer {
    // WeakReference avoids ClassLoader leak
    private final Map<ClassLoader, byte[]> cache = new WeakHashMap<>();

    @Override
    public byte[] transform(ClassLoader l, String name,
                            Class<?> c, ProtectionDomain d, byte[] b) {
        try {
            if (name == null || name.startsWith("java/")) return null;
            // ... transformation logic ...
            return modify(b);
        } catch (Exception e) {
            System.err.println("Agent error on " + name + ": " + e);
            return null; // load original bytecode
        }
    }
}
Output
Never throw in transform(). Return null on error to avoid breaking class loading.
Production Trap:
Silent failures: the JVM prints nothing when your transformer throws. Always log errors inside transform() and return null. Missing logs = production outage with zero visibility.
Key Takeaway
Defensive programming in transformers is non-negotiable: guard against leaks, retransform limits, and swallowed exceptions.

Introduction

Java agents are the JVM’s rawest form of runtime introspection — a weapon that rewrites bytecode before the classloader even sees it. Unlike proxies or AOP frameworks, agents operate at the bytecode level, meaning they can intercept constructors, native methods, and even java.lang.System calls. The core mechanism is simple: a premain or agentmain method receives an Instrumentation instance, and you register a ClassFileTransformer that mutates class bytes. This powers everything from profilers (YourKit) to APM tools (Datadog) to debuggers (IntelliJ’s hot-reload). But raw power demands respect: a single malformed bytecode instruction crashes the JVM. Why should you care? Because when you need to measure latency on every HTTP request without touching source code, or inject security checks into a third-party library, agents are the only sane path. This section grounds you in the mental model — an agent is a plugin to the JVM’s class-loading pipeline, not magic.

SimpleAgent.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — java tutorial
// Minimal agent: prints class names as they load
import java.lang.instrument.*;

public class SimpleAgent {
    public static void premain(String args, Instrumentation inst) {
        inst.addTransformer(new ClassFileTransformer() {
            @Override
            public byte[] transform(ClassLoader loader,
                                    String className,
                                    Class<?> classBeingRedefined,
                                    ProtectionDomain protectionDomain,
                                    byte[] classfileBuffer) {
                if (className != null && className.startsWith("com/example")) {
                    System.out.println("Loading: " + className);
                }
                return null; // no modification
            }
        });
    }
}
Output
Loading: com/example/MyService
Loading: com/example/MyController
Production Trap:
Returning null from transform() means 'no change' — the JVM uses original bytes. Returning an empty array corrupts the class and crashes.
Key Takeaway
An agent hooks into class loading; your transformer must return null for untouched classes.

Conclusion

Java agent instrumentation is the sharpest tool in the JVM toolbelt — it redefines what’s possible in production observability, security, and hot-patching without modifying application code. Throughout this guide, you’ve seen that agents are not a black box: they rely on precise manifest setup, careful bytecode manipulation via ASM or ByteBuddy, and a deep respect for classloader boundaries. The critical insight is that every bytecode transformation carries risk — a misplaced instruction in a hot method introduces memory leaks, deadlocks, or SilentClassCastExceptions that only surface under load. The standard approach? Shade all dependencies to avoid conflicts, always handle retransformation gracefully (your transformer must be idempotent), and lock down your agent with signed JARs and permission checks. When done right, agents provide capabilities that no framework can match: modifying java.lang classes, instrumenting lambda internals, and attaching to a running JVM without restart. The cost is complexity — but for the senior engineer, that’s the price of ultimate control.

SafeTransformer.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — java tutorial
// Idempotent transformer: skip already-instrumented classes
public byte[] transform(ClassLoader loader, String className,
                        Class<?> redefining, ProtectionDomain pd,
                        byte[] classBytes) {
    if (className == null || isInstrumented(classBytes)) {
        return null;  // unchanged
    }
    byte[] modified = weaveMetrics(classBytes);
    return modified;
}

private boolean isInstrumented(byte[] bytes) {
    // check for custom marker annotation in class bytes
    return new ClassReader(bytes).getAnnotation("Lcom/agent/Marker;");
}
Output
null if already instrumented, else modified bytecode
Production Trap:
Retransforming an already-instrumented class without idempotency check doubles bytecode size and kills performance.
Key Takeaway
Always guard against double-instrumentation — use a marker annotation or a version stamp in bytecode.
● Production incidentPOST-MORTEMseverity: high

Production Outage: Agent's COMPUTE_FRAMES Crashes JVM on Class Load

Symptom
After deploying a new version of the monitoring agent, the application took 10x longer to start, then began throwing ClassNotFoundException for classes that clearly existed on the classpath. No traffic could be served.
Assumption
We assumed the agent was safe because it had been running for months without issues — the new version only added instrumentation for a new library.
Root cause
The transformer used ClassWriter.COMPUTE_FRAMES but didn't pass the original ClassLoader from the 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.
Fix
Modified the transformer to pass the ClassLoader from the transform parameters into the ClassWriter constructor explicitly. Also added logging at every transform failure with the ClassLoader identity to aid future debugging.
Key lesson
  • 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.
Production debug guideSymptom-Action pairs for the most common agent failures5 entries
Symptom · 01
Agent JAR loaded but transformer never called
Fix
Verify MANIFEST.MF has Premain-Class (or Agent-Class) pointing to the correct class. Use unzip -p agent.jar META-INF/MANIFEST.MF to inspect. Check JVM flags for typos in -javaagent path.
Symptom · 02
Class loads throw VerifyError after transformation
Fix
Check if you used COMPUTE_FRAMES. If manual stack frames are wrong, the verifier rejects the class. Also verify you're not corrupting the constant pool or changing method signatures.
Symptom · 03
ClassNotFoundException when using COMPUTE_FRAMES
Fix
Pass the ClassLoader from the transform() method directly to ClassWriter constructor. If using a different ClassLoader, the transform may fail to resolve types during frame computation.
Symptom · 04
Agent loads but instrumentation has no effect on certain classes
Fix
Confirm classes are being loaded after the transformer is added. For classes loaded before agent attach (via -javaagent), retransformation is required. Check if Can-Retransform-Classes is true in MANIFEST.MF.
Symptom · 05
Attach API fails with 'java.io.IOException: Operation not supported'
Fix
Self-attach requires -Djdk.attach.allowAttachSelf=true on Java 9+. For remote attach, ensure the target JVM's attach listener is enabled (default on, but some security managers disable it).
★ Quick Agent Debugging Cheat SheetThree-command patterns for the most critical agent failures
Agent not loaded at startup
Immediate action
Check JVM command line for -javaagent syntax
Commands
java -verbose:class -javaagent:agent.jar=args -jar app.jar 2>&1 | grep '\[Loaded' | head -20
unzip -p agent.jar META-INF/MANIFEST.MF
Fix now
Ensure MANIFEST.MF has correct Premain-Class line (no trailing spaces) and file ends with newline.
VerifyError after transformation+
Immediate action
Disable the transformer to confirm it's the cause
Commands
java -XX:-UseSplitVerifier -javaagent:agent.jar -jar app.jar (temporary, Java 8 only)
javap -v -p ModifiedClass.class | grep -A 20 'StackMapTable'
Fix now
Switch to ClassWriter.COMPUTE_FRAMES in your transformer, or manually compute correct stack map frames.
Retransformation fails with UnsupportedOperationException+
Immediate action
Check MANIFEST.MF for Can-Retransform-Classes: true
Commands
unzip -p agent.jar META-INF/MANIFEST.MF | grep -i 'Can-Retransform'
java -XX:+TraceRedefineClasses -javaagent:agent.jar -jar app.jar
Fix now
Set Can-Retransform-Classes: true in MANIFEST.MF and rebuild agent JAR.
Attach fails: 'com.sun.tools.attach.AgentLoadException'+
Immediate action
Verify PID and agent JAR path
Commands
java -cp .:$JAVA_HOME/lib/tools.jar com.sun.tools.attach.VirtualMachine list
jcmd <PID> JVMTI.data_dump
Fix now
Run the attach process with --add-modules jdk.attach on Java 9+ and ensure tools.jar is on classpath for Java 8.
ASM vs ByteBuddy vs Javassist
FeatureASMByteBuddyJavassist
API LevelInstruction-levelHigh-level (fluent)Source-level (String)
Learning CurveSteepModerateShallow
PerformanceFastestVery fast (on ASM)Moderate
Stack Frame HandlingManual or COMPUTE_FRAMESAutomaticAutomatic
Class CreationPossible but manualBuilt-in (DynamicType)Easy (new ClassPool)
File Size (jar)~500 KB~1.5 MB~800 KB
Production UseJaCoCo, OpenTelemetryMockito, HibernateSpring AOP (CGLib replacement)

Common mistakes to avoid

4 patterns
×

Using depends_on without a healthcheck in Docker Compose

Symptom
Container crashes on startup with ECONNREFUSED because the database container started but is not yet ready.
Fix
Add a healthcheck block to the database service using pg_isready, then use condition: service_healthy in the client service.
×

Not shading ASM in the agent JAR

Symptom
NoSuchMethodError or VerifyError when the application also uses ASM (e.g., Spring, Hibernate).
Fix
Use Maven Shade or Gradle Shadow to relocate org.objectweb.asm to your own package namespace.
×

Forgetting Can-Retransform-Classes: true in MANIFEST.MF

Symptom
Calling retransformClasses() throws UnsupportedOperationException even though transformer is registered.
Fix
Add 'Can-Retransform-Classes: true' to MANIFEST.MF and rebuild the JAR.
×

Not passing the ClassLoader to ClassWriter with COMPUTE_FRAMES

Symptom
ClassNotFoundException during class transformation when ASM tries to load referenced types.
Fix
Pass the ClassLoader parameter from the transform() method signature directly to the ClassWriter constructor.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Explain how a Java agent works from JVM startup to bytecode transformati...
Q02SENIOR
What is the difference between COMPUTE_MAXS and COMPUTE_FRAMES in ASM's ...
Q03SENIOR
Describe a production incident caused by a Java agent and how you fixed ...
Q01 of 03JUNIOR

Explain how a Java agent works from JVM startup to bytecode transformation.

ANSWER
At startup, the JVM reads the -javaagent flag and loads the agent JAR. It looks for the Premain-Class attribute in MANIFEST.MF, then calls the premain(String, Instrumentation) method. The Instrumentation instance allows registering a ClassFileTransformer. The transformer is called each time a class is loaded. It receives the class byte array and can return a modified version using a library like ASM. The JVM then uses the transformed bytecode to define the class.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Can I use a Java agent to add new methods or fields to existing classes?
02
Why does my agent cause a VerifyError even though I used COMPUTE_FRAMES?
03
How do I attach a Java agent to a running process in a Docker container?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Advanced Java. Mark it forged?

15 min read · try the examples if you haven't

Previous
Java Logging with SLF4J and Logback
25 / 28 · Advanced Java
Next
JUnit 5 Annotations: @Test, @BeforeEach, @AfterEach and More