Backend Development 23 min read

How to Use Java Agents for Non‑Intrusive Method Timing and Bytecode Tracing

This article explains how to replace invasive timing code with a Java Agent using the Instrumentation API, demonstrates both premain and attach approaches, shows ASM‑based bytecode transformation examples, and explores how Arthas trace leverages similar techniques for runtime monitoring.

Selected Java Interview Questions
Selected Java Interview Questions
Selected Java Interview Questions
How to Use Java Agents for Non‑Intrusive Method Timing and Bytecode Tracing

Story's Little Yellow Flower

A teammate was adding massive timing code to methods because the infrastructure lacked performance metrics, using

StopWatch

to log execution time.

<code>@Override
public void method(Req req) {
    StopWatch stopWatch = new StopWatch();
    stopWatch.start("某某方法-耗时统计");
    method();
    stopWatch.stop();
    log.info("查询耗时分布:{}", stopWatch.prettyPrint());
}</code>

Seeing the high intrusiveness, we turned to Java Agent technology for a non‑intrusive solution and built a tiny demo.

Instrumentation

Since JDK 1.5, the

java.lang.instrument

package provides tools for bytecode enhancement via the

Instrumentation

interface. Common methods include:

<code>public interface Instrumentation {
    /** Register a class file transformer */
    void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
    /** Remove a transformer */
    boolean removeTransformer(ClassFileTransformer transformer);
    /** Retransform already loaded classes */
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
    /** Check if retransformation is supported */
    boolean isRetransformClassesSupported();
    /** Get all loaded classes */
    Class[] getAllLoadedClasses();
}</code>

There are two ways to use it:

Add an

Agent jar

when the JVM starts.

After the JVM is running, load an agent jar remotely via the

Attach API

.

Agent

The

premain

method runs before

main()

during JVM startup. Inside it we can register a

ClassFileTransformer

to modify class bytecode.

<code>public static void premain(String agentArgs, Instrumentation instrumentation) {
    System.out.println("premain方法");
    instrumentation.addTransformer(new MyClassFileTransformer(), true);
}</code>

Key transformer classes:

<code>public class MyClassFileTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) {
        if ("com/example/aop/agent/MyTest".equals(className)) {
            ClassReader cr = new ClassReader(classfileBuffer);
            ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
            ClassVisitor cv = new TimeStatisticsVisitor(Opcodes.ASM7, cw);
            cr.accept(cv, ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG);
            return cw.toByteArray();
        }
        return classfileBuffer;
    }
}

public class TimeStatisticsVisitor extends ClassVisitor {
    // ... uses ASM to insert start/end calls
}

public class TimeStatistics {
    static ThreadLocal<Long> t = new ThreadLocal<>();
    public static void start() { t.set(System.currentTimeMillis()); }
    public static void end() {
        long time = System.currentTimeMillis() - t.get();
        System.out.println(Thread.currentThread().getStackTrace()[2] + " spend: " + time);
    }
}

public class AgentMain {
    public static void premain(String agentArgs, Instrumentation instrumentation) {
        System.out.println("premain方法");
        instrumentation.addTransformer(new MyClassFileTransformer(), true);
    }
}</code>

Build the agent with Maven (assembly plugin to create a jar‑with‑dependencies) and run the test class:

<code>java -javaagent:/path/to/aop-demo.jar com.example.aop.agent.MyTest</code>

The output shows the method execution time.

Method timing result
Method timing result

Attach

When the JVM is already running,

agentmain

is invoked after the agent is attached. Its signature mirrors

premain

:

<code>public static void agentmain(String agentArgs, Instrumentation inst) { }</code>

Using

agentmain

, we can retransform classes at any time. The example modifies a class that continuously prints

100

to print

50

instead.

<code>public class PrintNumTest {
    public static void main(String[] args) throws InterruptedException {
        while (true) {
            System.out.println(getNum());
            Thread.sleep(3000);
        }
    }
    private static int getNum() { return 100; }
}</code>

Transformer that replaces the return value:

<code>public class PrintNumTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        if ("com/example/aop/agent/PrintNumTest".equals(className)) {
            ClassReader cr = new ClassReader(classfileBuffer);
            ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
            ClassVisitor cv = new TransformPrintNumVisitor(Opcodes.ASM7, cw);
            cr.accept(cv, ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG);
            return cw.toByteArray();
        }
        return classfileBuffer;
    }
}

public class TransformPrintNumVisitor extends ClassVisitor {
    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        if ("getNum".equals(name)) {
            return new TransformPrintNumAdapter(api, mv, access, name, descriptor);
        }
        return mv;
    }
}

public class TransformPrintNumAdapter extends AdviceAdapter {
    @Override
    protected void onMethodEnter() {
        super.visitIntInsn(BIPUSH, 50);
        super.visitInsn(IRETURN);
    }
}</code>

Agent that registers the transformer and triggers retransform:

<code>public class PrintNumAgent {
    public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException {
        System.out.println("agentmain");
        inst.addTransformer(new PrintNumTransformer(), true);
        for (Class c : inst.getAllLoadedClasses()) {
            if (c.getSimpleName().equals("PrintNumTest")) {
                System.out.println("Reloading: " + c.getName());
                inst.retransformClasses(c);
                break;
            }
        }
    }
}

public class MyAttachMain {
    public static void main(String[] args) throws Exception {
        VirtualMachine vm = VirtualMachine.attach(args[0]);
        try {
            vm.loadAgent("/path/to/aop-demo.jar");
        } finally {
            vm.detach();
        }
    }
}</code>

Run the target program, find its PID with

jps

, then attach:

<code>java -cp $JAVA_HOME/lib/tools.jar:/path/to/aop-demo.jar com.example.aop.agent.MyAttachMain 49987</code>
Attach result
Attach result

Arthas

Arthas’s

trace

command uses the same bytecode‑enhancement principle to record method execution time. The command is implemented as a class extending

EnhancerCommand

, which registers a transformer that inserts listeners at method entry/exit.

Arthas trace UI
Arthas trace UI

Setup Debug Environment

Arthas can be debugged remotely via IDEA’s remote‑debug feature. Set the target JVM to listen on a socket (e.g.,

-Xrunjdwp:transport=dt_socket,server=y,address=8000

) and attach the IDE.

IDE remote debug
IDE remote debug
Set breakpoint
Set breakpoint

bytekit

bytekit builds on ASM to provide a concise API for bytecode enhancement. A simple interceptor example:

<code>public class SampleInterceptor {
    @AtEnter(inline = false, suppress = RuntimeException.class, suppressHandler = PrintExceptionSuppressHandler.class)
    public static void atEnter(@Binding.This Object object, @Binding.Class Object clazz,
                               @Binding.Args Object[] args, @Binding.MethodName String methodName,
                               @Binding.MethodDesc String methodDesc) {
        System.out.println("atEnter, args[0]: " + args[0]);
    }

    @AtExit(inline = true)
    public static void atExit(@Binding.Return Object returnObject) {
        System.out.println("atExit, returnObject: " + returnObject);
    }

    @AtExceptionExit(inline = true, onException = RuntimeException.class,
                     suppress = RuntimeException.class, suppressHandler = PrintExceptionSuppressHandler.class)
    public static void atExceptionExit(@Binding.Throwable RuntimeException ex,
                                      @Binding.Field(name = "exceptionCount") int exceptionCount) {
        System.out.println("atExceptionExit, ex: " + ex.getMessage() + ", field exceptionCount: " + exceptionCount);
    }
}</code>

The target method to be enhanced:

<code>public class Sample {
    private int exceptionCount = 0;
    public String hello(String str, boolean exception) {
        if (exception) {
            exceptionCount++;
            throw new RuntimeException("test exception, str: " + str);
        }
        return "hello " + str;
    }
}</code>

After bytekit processing, the decompiled result contains inserted calls to

SpyAPI.atEnter

,

atExit

, and exception handling code, dramatically increasing the method size.

<code>public String hello(String string, boolean bl) {
    try {
        SpyInterceptor.atEnter(this, Sample.class, new Object[]{string, new Boolean(bl)}, "hello", "(Ljava/lang/String;Z)Ljava/lang/String;");
        if (bl) {
            ++this.exceptionCount;
            throw new RuntimeException("test exception, str: " + string);
        }
        String result = "hello " + string;
        System.out.println("atExit, returnObject: " + result);
        return result;
    } catch (RuntimeException e) {
        System.out.println("atExceptionExit, ex: " + e.getMessage() + ", field exceptionCount: " + this.exceptionCount);
        throw e;
    }
}</code>

trace

Arthas trace flow
Arthas trace flow

The

trace

command’s implementation follows the template pattern:

TraceCommand

extends

EnhancerCommand

, which registers a transformer that inserts listeners via

AdviceListenerManager.registerTraceAdviceListener

. The core transformer logic resides in

Enhancer#enhance

, which parses interceptor classes, creates

InterceptorProcessor

objects, and modifies the target method bytecode.

<code>public synchronized EnhancerAffect enhance(Instrumentation inst) throws UnmodifiableClassException {
    // add this enhancer as a transformer
    ArthasBootstrap.getInstance().getTransformerManager().addTransformer(this, isTracing);
    // ... load class, parse interceptors, apply them, dump class if needed ...
    return affect;
}</code>

Two main steps are performed:

Parse interceptor classes to collect

@AtXxx

annotations and build

InterceptorProcessor

objects.

Iterate over matched methods and let each

InterceptorProcessor

modify the method’s bytecode.

The overall workflow is illustrated in the diagram above, and a concrete interceptor example (

SpyInterceptor1

) shows how a simple static method is injected at method entry.

In summary, Java Agents, the Instrumentation API, ASM, and helper libraries like bytekit enable powerful, non‑intrusive runtime instrumentation, which tools such as Arthas leverage to provide real‑time tracing and performance monitoring.

instrumentationPerformance MonitoringJava AgentArthasBytecode Manipulation
Selected Java Interview Questions
Written by

Selected Java Interview Questions

A professional Java tech channel sharing common knowledge to help developers fill gaps. Follow us!

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.