ASM and ByteBuddy let you modify Java class files after they’ve been compiled, even while the program is running.

Let’s see this in action. Imagine we have a simple class:

public class Greeter {
    public void greet(String name) {
        System.out.println("Hello, " + name + "!");
    }
}

We want to log every time the greet method is called, along with its arguments.

Using ByteBuddy, we can achieve this without touching the original Greeter.java file.

First, add the ByteBuddy dependency to your pom.xml or build.gradle:

<!-- Maven -->
<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
    <version>1.14.10</version> <!-- Use the latest version -->
</dependency>
// Gradle
implementation 'net.bytebuddy:byte-buddy:1.14.10' // Use the latest version

Now, let’s create a "transformer" that intercepts the greet method:

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.implementation.MethodDelegation;
import net.bytebuddy.implementation.bind.annotation.Argument;
import net.bytebuddy.implementation.bind.annotation.Origin;
import net.bytebuddy.implementation.bind.annotation.RuntimeType;
import net.bytebuddy.implementation.bind.annotation.SuperCall;
import net.bytebuddy.matcher.ElementMatchers;

import java.lang.reflect.Method;
import java.util.concurrent.Callable;

public class GreeterTransformer {

    public static class Logger {
        // This method will be delegated to when Greeter.greet is called
        @RuntimeType
        public static Object log(@Origin Method method, @Argument(0) String name, @SuperCall Callable<?> superCall) throws Exception {
            System.out.println(">>> Logging call to " + method.getName() + " with name: " + name);
            Object result = superCall.call(); // Execute the original method
            System.out.println("<<< Finished call to " + method.getName());
            return result;
        }
    }

    public static void main(String[] args) throws Exception {
        // Load the original Greeter class
        Class<?> originalGreeterClass = Greeter.class;

        // Create a new class using ByteBuddy, extending the original
        Class<?> transformedGreeterClass = new ByteBuddy()
                .subclass(originalGreeterClass)
                .method(ElementMatchers.named("greet")) // Match the 'greet' method
                .intercept(MethodDelegation.to(Logger.class)) // Delegate to our Logger
                .make()
                .load(originalGreeterClass.getClassLoader())
                .getLoaded();

        // Instantiate the transformed class and call the method
        Greeter greeterInstance = (Greeter) transformedGreeterClass.getDeclaredConstructor().newInstance();
        greeterInstance.greet("World");
    }
}

When you run GreeterTransformer.main(), you’ll see output like this:

>>> Logging call to greet with name: World
Hello, World!
<<< Finished call to greet

Notice how we didn’t modify Greeter.java. ByteBuddy generated a new class at runtime, Greeter$ByteBuddy$xxxxxx, which extends Greeter and overrides the greet method to include our logging logic. The MethodDelegation.to(Logger.class) part is key: it tells ByteBuddy to invoke the Logger.log method whenever the greet method is called on an instance of the transformed class. The annotations (@Origin, @Argument, @SuperCall) are how ByteBuddy injects information about the intercepted method and allows us to call the original implementation.

This technique is the foundation for many powerful tools. Aspect-Oriented Programming (AOP) frameworks like Spring AOP use this to add cross-cutting concerns (like logging, security, transactions) to methods without modifying their core logic. Java Agents, which can instrument code at JVM startup, often leverage ASM or ByteBuddy. Mocking frameworks, like Mockito, can dynamically create mock objects by generating bytecode on the fly.

The core problem these libraries solve is the "Separation of Concerns." Business logic should be separate from infrastructure concerns. Bytecode manipulation allows you to enforce this separation by weaving infrastructure code into your application at a low level, without cluttering your source code.

Here’s a deeper dive into how ByteBuddy handles method interception. When MethodDelegation.to(Logger.class) is used, ByteBuddy generates bytecode that, at runtime, essentially looks like this for the greet method:

// Inside the generated subclass of Greeter
@Override
public void greet(String name) {
    try {
        // Call the static Logger.log method, passing method metadata, arguments, and a way to call the super method
        Object result = Logger.log(
            // Some representation of this 'greet' method
            this.getClass().getDeclaredMethod("greet", String.class),
            // The actual argument passed to greet
            name,
            // A callable that will invoke the original Greeter.greet method
            () -> {
                super.greet(name); // Call the original implementation
                return null; // greet returns void
            }
        );
        // Handle return value if greet didn't return void
    } catch (Throwable t) {
        throw new RuntimeException(t); // Or rethrow appropriately
    }
}

The @RuntimeType annotation is crucial. Without it, ByteBuddy would try to match the return type of Logger.log to the return type of Greeter.greet (which is void). @RuntimeType tells ByteBuddy to defer the type checking until runtime, allowing Logger.log to return Object and be safely cast or used, even if the original method returned a primitive or void. This flexibility is essential for creating generic interceptors.

ASM is a lower-level library. ByteBuddy is built on top of ASM. While ByteBuddy provides a fluent, high-level API, ASM gives you direct access to the Java bytecode instructions. For example, to add a System.out.println statement using ASM, you’d be working with Opcodes.LDC, Opcodes.INVOKEVIRTUAL, Opcodes.ALOAD, and managing the stack and local variables manually. This offers maximum control but comes with a steeper learning curve. ByteBuddy abstracts away much of this complexity.

Consider the scenario of dynamic proxying. When you create a Proxy object in Java, you’re essentially asking the JVM to create a new class at runtime that implements a given interface. This generated class then delegates method calls to an InvocationHandler. ByteBuddy and ASM allow you to achieve similar, but far more powerful, dynamic class generation. You’re not limited to interfaces; you can subclass concrete classes, modify constructors, add fields, and more.

The most surprising thing is that you can effectively "patch" code that’s already running. Java agents, which attach to a running JVM, can use these libraries to transform classes after they’ve been loaded. This means you can inject logging, profiling, or even bug fixes into an application without restarting it. The premain or agentmain methods in a Java agent receive a ClassFileTransformer that can return modified bytecode for any class loaded by the JVM.

When you decide to use ByteBuddy’s MethodDelegation, you’re choosing a specific strategy for weaving. Another common strategy is SuperMethodCall, which directly calls the superclass method without delegating to a separate class. You can also combine these, perhaps logging entry and exit using delegation, but directly calling the super method for the core logic.

The next step in exploring bytecode manipulation is understanding Java Agents and how they can be used to instrument running applications.

Want structured learning?

Take the full Java course →