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
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
Annotation | Purpose | Example |
---|---|---|
@OnMethodEnter | Code before method | Initialize timers, log entry |
@OnMethodExit | Code after method | Log results, record metrics |
Parameter Binding
Annotation | Purpose | Type | Example |
---|---|---|---|
@Advice.This | Instance being called | Object | Access fields |
@Advice.Origin | Method signature | String/Method | Get method name |
@Advice.AllArguments | All arguments | Object[] | Log all params |
@Advice.Argument(n) | Specific argument | Any | Access first param |
@Advice.Return | Return value | Same as method | Capture result |
@Advice.Thrown | Thrown exception | Throwable | Handle errors |
@Advice.Enter | Value from Enter | Any | Pass 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):
Approach | Time | Overhead |
---|---|---|
Baseline | 50ms | - |
MethodDelegation | 850ms | 17x slower |
Advice (inline) | 52ms | 1.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.