ByteBuddy with Java - Part 3: Advice API and Code Injection

Master ByteBuddy's Advice API for surgical code injection. Learn @OnMethodEnter, @OnMethodExit, inline bytecode insertion, and building aspect-oriented programming.

October 7, 2025
35 minutes
By Prashant Chaturvedi
ByteBuddy Java Bytecode AOP Advice Code Injection

ByteBuddy with Java - Part 3: Advice API and Code Injection

Parts 1-2 used MethodDelegation for interception. Part 3 introduces the Advice API - a more powerful approach that injects bytecode directly into methods without creating proxy objects.

What You’ll Build

Performance monitoring that injects timing code into methods:

public class UserService {

    @Monitored
    public User getUser(String id) {
        return database.find(id);
    }
}

ByteBuddy transforms this into:

public User getUser(String id) {
    long startTime = System.nanoTime();
    try {
        return database.find(id);
    } finally {
        long duration = System.nanoTime() - startTime;
        MetricsCollector.record("getUser", duration);
    }
}

No proxies. No delegation. Just injected bytecode.

Advice API vs MethodDelegation

MethodDelegation (Parts 1-2)

Creates proxy classes:

UserService proxy = new UserService$ByteBuddy() {
    @Override
    public User getUser(String id) {
        return (User) interceptor.intercept(...);
    }
};

Pros:

  • Easy to write and test
  • Full Java features available
  • Dynamic behavior

Cons:

  • Extra method call overhead
  • Creates proxy instances
  • Harder to optimize by JIT

Advice API (Part 3)

Injects code directly:

public User getUser(String id) {
    // INJECTED: long startTime = System.nanoTime();
    User result = database.find(id);
    // INJECTED: MetricsCollector.record("getUser", duration);
    return result;
}

Pros:

  • Zero overhead (inline bytecode)
  • No proxy objects
  • JIT-friendly
  • Can modify local variables

Cons:

  • More restrictive API
  • Limited to static advice methods
  • Harder to debug

Use Advice when: Performance critical, simple cross-cutting concerns (logging, metrics) Use MethodDelegation when: Complex logic, dynamic behavior, need instance state

Basic Advice: Method Entry

Advice methods use @OnMethodEnter to inject code at method start.

Simple Logging Advice

package com.example;

import net.bytebuddy.asm.Advice;

public class LoggingAdvice {

    @Advice.OnMethodEnter
    public static void enter(@Advice.Origin String method) {
        System.out.println("[ENTER] " + method);
    }
}

Applying advice:

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;

Class<? extends UserService> instrumented = new ByteBuddy()
    .subclass(UserService.class)
    .method(ElementMatchers.any())
    .intercept(Advice.to(LoggingAdvice.class))
    .make()
    .load(getClass().getClassLoader())
    .getLoaded();

UserService service = instrumented.getDeclaredConstructor().newInstance();
service.getUser("123");  // [ENTER] com.example.UserService.getUser

How It Works

Diagram 1

ByteBuddy copies bytecode from LoggingAdvice.enter() and injects it at the start of getUser(). The JVM sees a single method - no delegation, no proxy.

Method Exit: Capturing Return Values

Use @OnMethodExit to inject code when method returns.

Timing Advice

package com.example;

import net.bytebuddy.asm.Advice;

public class TimingAdvice {

    @Advice.OnMethodEnter
    public static long enter() {
        return System.nanoTime();
    }

    @Advice.OnMethodExit
    public static void exit(@Advice.Enter long startTime,
                           @Advice.Origin String method) {
        long duration = System.nanoTime() - startTime;
        System.out.println("[TIMING] " + method + " took " +
                         (duration / 1_000_000) + "ms");
    }
}

Key concept: @Advice.Enter passes values from @OnMethodEnter to @OnMethodExit.

Generated bytecode equivalent:

public User getUser(String id) {
    long startTime = System.nanoTime();  // From @OnMethodEnter
    try {
        return database.find(id);
    } finally {
        long duration = System.nanoTime() - startTime;  // From @OnMethodExit
        System.out.println("[TIMING] getUser took " + (duration / 1_000_000) + "ms");
    }
}

Advice Annotations Reference

Method Entry/Exit

AnnotationPurposeExample
@OnMethodEnterCode before methodInitialize timers, log entry
@OnMethodExitCode after methodLog results, record metrics

Parameter Binding

AnnotationPurposeTypeExample
@Advice.ThisInstance being calledObjectAccess fields
@Advice.OriginMethod signatureString/MethodGet method name
@Advice.AllArgumentsAll argumentsObject[]Log all params
@Advice.Argument(n)Specific argumentAnyAccess first param
@Advice.ReturnReturn valueSame as methodCapture result
@Advice.ThrownThrown exceptionThrowableHandle errors
@Advice.EnterValue from EnterAnyPass data to Exit

Capturing Return Values and Exceptions

Return Value Logging

package com.example;

import net.bytebuddy.asm.Advice;

public class ReturnLoggingAdvice {

    @Advice.OnMethodExit
    public static void exit(@Advice.Origin String method,
                           @Advice.Return Object result) {
        System.out.println("[RETURN] " + method + " returned: " + result);
    }
}

Usage:

service.getUser("123");
// Output: [RETURN] getUser returned: User[id=123, name=John]

Exception Handling Advice

package com.example;

import net.bytebuddy.asm.Advice;

public class ExceptionAdvice {

    @Advice.OnMethodExit(onThrowable = Throwable.class)
    public static void exit(@Advice.Origin String method,
                           @Advice.Thrown Throwable exception) {
        if (exception != null) {
            System.err.println("[ERROR] " + method + " threw " +
                             exception.getClass().getSimpleName() +
                             ": " + exception.getMessage());
        }
    }
}

Important: onThrowable = Throwable.class tells ByteBuddy to inject exit code even when exceptions occur.

Generated equivalent:

public User getUser(String id) {
    Throwable exception = null;
    try {
        return database.find(id);
    } catch (Throwable t) {
        exception = t;
        throw t;
    } finally {
        if (exception != null) {
            System.err.println("[ERROR] getUser threw " + exception);
        }
    }
}

Building a Metrics System

Let’s build a production-ready metrics collector using Advice.

Metrics Annotation

package com.example;

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Monitored {
    String name() default "";
}

Metrics Collector

package com.example;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

public class MetricsCollector {

    private static final Map<String, MethodMetrics> metrics = new ConcurrentHashMap<>();

    public static void record(String methodName, long durationNanos) {
        metrics.computeIfAbsent(methodName, k -> new MethodMetrics())
               .record(durationNanos);
    }

    public static void printReport() {
        System.out.println("\n=== Metrics Report ===");
        metrics.forEach((method, stats) -> {
            System.out.printf("%s: count=%d, avg=%.2fms, min=%dms, max=%dms%n",
                method,
                stats.count.sum(),
                stats.totalNanos.sum() / 1_000_000.0 / stats.count.sum(),
                stats.minNanos.get() / 1_000_000,
                stats.maxNanos.get() / 1_000_000
            );
        });
    }

    static class MethodMetrics {
        final LongAdder count = new LongAdder();
        final LongAdder totalNanos = new LongAdder();
        final AtomicLong minNanos = new AtomicLong(Long.MAX_VALUE);
        final AtomicLong maxNanos = new AtomicLong(Long.MIN_VALUE);

        void record(long nanos) {
            count.increment();
            totalNanos.add(nanos);
            minNanos.updateAndGet(min -> Math.min(min, nanos));
            maxNanos.updateAndGet(max -> Math.max(max, nanos));
        }
    }
}

Monitoring Advice

package com.example;

import net.bytebuddy.asm.Advice;
import java.lang.reflect.Method;

public class MonitoringAdvice {

    @Advice.OnMethodEnter
    public static long enter() {
        return System.nanoTime();
    }

    @Advice.OnMethodExit(onThrowable = Throwable.class)
    public static void exit(@Advice.Enter long startTime,
                           @Advice.Origin Method method,
                           @Advice.Thrown Throwable exception) {

        long duration = System.nanoTime() - startTime;

        Monitored annotation = method.getAnnotation(Monitored.class);
        String metricName = annotation.name().isEmpty()
            ? method.getName()
            : annotation.name();

        MetricsCollector.record(metricName, duration);

        if (exception != null) {
            MetricsCollector.record(metricName + ".error", duration);
        }
    }
}

Service with Monitoring

package com.example;

public class OrderService {

    @Monitored(name = "order.get")
    public Order getOrder(String id) throws InterruptedException {
        Thread.sleep(50);  // Simulate database
        return new Order(id, "Widget");
    }

    @Monitored(name = "order.create")
    public void createOrder(Order order) throws InterruptedException {
        Thread.sleep(100);  // Simulate database
    }

    @Monitored
    public void failingOperation() {
        throw new RuntimeException("Simulated failure");
    }
}

Applying Monitoring

package com.example;

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;

public class MonitoringExample {

    public static void main(String[] args) throws Exception {
        Class<? extends OrderService> monitored = new ByteBuddy()
            .subclass(OrderService.class)
            .method(ElementMatchers.isAnnotatedWith(Monitored.class))
            .intercept(Advice.to(MonitoringAdvice.class))
            .make()
            .load(MonitoringExample.class.getClassLoader())
            .getLoaded();

        OrderService service = monitored.getDeclaredConstructor().newInstance();

        // Generate traffic
        for (int i = 0; i < 100; i++) {
            service.getOrder("order-" + i);
        }

        for (int i = 0; i < 50; i++) {
            service.createOrder(new Order("order-" + i, "Product-" + i));
        }

        for (int i = 0; i < 10; i++) {
            try {
                service.failingOperation();
            } catch (Exception e) {
                // Expected
            }
        }

        MetricsCollector.printReport();
    }
}

Output:

=== Metrics Report ===
order.get: count=100, avg=50.23ms, min=50ms, max=52ms
order.create: count=50, avg=100.45ms, min=100ms, max=102ms
failingOperation: count=10, avg=0.05ms, min=0ms, max=0ms
failingOperation.error: count=10, avg=0.05ms, min=0ms, max=0ms

Modifying Arguments and Return Values

Advice can modify method behavior by changing arguments or return values.

Argument Transformation

package com.example;

import net.bytebuddy.asm.Advice;

public class ArgumentNormalizationAdvice {

    @Advice.OnMethodEnter
    public static void enter(@Advice.Argument(value = 0, readOnly = false) String arg) {
        if (arg != null) {
            // Modify argument in place
            arg = arg.trim().toLowerCase();
        }
    }
}

Important: Use readOnly = false to enable argument modification.

Return Value Transformation

package com.example;

import net.bytebuddy.asm.Advice;

public class ReturnValueAdvice {

    @Advice.OnMethodExit
    public static void exit(@Advice.Return(readOnly = false) String result) {
        if (result != null) {
            result = result.toUpperCase();
        }
    }
}

Suppressing Exceptions

package com.example;

import net.bytebuddy.asm.Advice;

public class ExceptionSuppressionAdvice {

    @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class)
    public static void exit(@Advice.Thrown Throwable exception,
                           @Advice.Return(readOnly = false) String result) {
        if (exception != null) {
            System.err.println("[ERROR] Suppressed: " + exception.getMessage());
            result = "DEFAULT_VALUE";  // Return fallback instead of throwing
        }
    }
}

suppress = Throwable.class tells ByteBuddy to catch and suppress exceptions.

Inline Advice vs Delegation

ByteBuddy supports two modes:

Inline Mode (Default)

Copies bytecode directly into method:

Advice.to(TimingAdvice.class)

Pros:

  • Zero overhead
  • JIT-optimizable
  • No reflection

Cons:

  • Cannot use instance state
  • Limited to static methods
  • Harder to debug

Delegation Mode

Calls advice methods at runtime:

Advice.to(TimingAdvice.class).wrap(
    MethodDelegation.to(new DynamicAdvice())
)

Pros:

  • Can use instance state
  • More flexible
  • Easier debugging

Cons:

  • Method call overhead
  • Less JIT-friendly

Advanced: Local Variables

Advice can create local variables visible to both entry and exit.

Shared State Example

package com.example;

import net.bytebuddy.asm.Advice;
import java.util.HashMap;
import java.util.Map;

public class ContextAdvice {

    @Advice.OnMethodEnter
    public static Map<String, Object> enter() {
        Map<String, Object> context = new HashMap<>();
        context.put("startTime", System.currentTimeMillis());
        context.put("threadId", Thread.currentThread().getId());
        return context;
    }

    @Advice.OnMethodExit
    public static void exit(@Advice.Enter Map<String, Object> context,
                           @Advice.Origin String method) {
        long duration = System.currentTimeMillis() -
                       (Long) context.get("startTime");
        long threadId = (Long) context.get("threadId");

        System.out.printf("[%d] %s took %dms%n", threadId, method, duration);
    }
}

Performance Comparison

Benchmark comparing Advice vs MethodDelegation:

@Benchmark
public void baseline() {
    service.getValue();
}

@Benchmark
public void withMethodDelegation() {
    delegationProxy.getValue();
}

@Benchmark
public void withAdvice() {
    adviceProxy.getValue();
}

Results (10M iterations):

ApproachTimeOverhead
Baseline50ms-
MethodDelegation850ms17x slower
Advice (inline)52ms1.04x slower

Advice has virtually zero overhead. MethodDelegation adds measurable cost.

Testing

package com.example;

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class AdviceTest {

    @Test
    void testTimingAdvice() throws Exception {
        Class<? extends OrderService> timed = new ByteBuddy()
            .subclass(OrderService.class)
            .method(ElementMatchers.any())
            .intercept(Advice.to(TimingAdvice.class))
            .make()
            .load(getClass().getClassLoader())
            .getLoaded();

        OrderService service = timed.getDeclaredConstructor().newInstance();
        service.getOrder("123");  // Should print timing
    }

    @Test
    void testExceptionAdvice() throws Exception {
        Class<? extends OrderService> resilient = new ByteBuddy()
            .subclass(OrderService.class)
            .method(ElementMatchers.named("failingOperation"))
            .intercept(Advice.to(ExceptionAdvice.class))
            .make()
            .load(getClass().getClassLoader())
            .getLoaded();

        OrderService service = resilient.getDeclaredConstructor().newInstance();

        assertThrows(RuntimeException.class, service::failingOperation);
        // Should log exception but still throw
    }

    @Test
    void testMonitoringAdvice() throws Exception {
        Class<? extends OrderService> monitored = new ByteBuddy()
            .subclass(OrderService.class)
            .method(ElementMatchers.isAnnotatedWith(Monitored.class))
            .intercept(Advice.to(MonitoringAdvice.class))
            .make()
            .load(getClass().getClassLoader())
            .getLoaded();

        OrderService service = monitored.getDeclaredConstructor().newInstance();

        service.getOrder("123");
        service.getOrder("456");

        MetricsCollector.printReport();
        // Verify metrics collected
    }
}

Run tests:

mvn test

Common Patterns

Pattern 1: Transaction Management

public class TransactionAdvice {

    @Advice.OnMethodEnter
    public static Transaction enter() {
        Transaction tx = TransactionManager.begin();
        return tx;
    }

    @Advice.OnMethodExit(onThrowable = Throwable.class)
    public static void exit(@Advice.Enter Transaction tx,
                           @Advice.Thrown Throwable exception) {
        if (exception != null) {
            tx.rollback();
        } else {
            tx.commit();
        }
    }
}

Pattern 2: Security Checks

public class SecurityAdvice {

    @Advice.OnMethodEnter
    public static void enter(@Advice.Origin Method method) {
        RequiresRole annotation = method.getAnnotation(RequiresRole.class);
        if (annotation != null) {
            if (!SecurityContext.hasRole(annotation.value())) {
                throw new SecurityException("Access denied");
            }
        }
    }
}

Pattern 3: Distributed Tracing

public class TracingAdvice {

    @Advice.OnMethodEnter
    public static Span enter(@Advice.Origin String method) {
        return Tracer.startSpan(method);
    }

    @Advice.OnMethodExit(onThrowable = Throwable.class)
    public static void exit(@Advice.Enter Span span,
                           @Advice.Thrown Throwable exception) {
        if (exception != null) {
            span.setError(exception);
        }
        span.finish();
    }
}

What’s Next

Part 4 covers defining new fields, constructors, and implementing interfaces dynamically. We’ll build a complete ORM framework with ByteBuddy that generates entity classes with:

@Entity
public interface User {
    String getName();
    void setName(String name);
}

// ByteBuddy generates full implementation with fields and database mapping

You’ll learn defineField(), defineConstructor(), interface implementation, and building real frameworks.