dynamic proxies
fake objects that intercept every call. spring's favourite trick.
- chapter
- chapter 11.04
- reading time
- 8 min read
Proxy.newProxyInstance makes an object that implements any interfaces you name, at runtime, and sends every call on it to one method you write: InvocationHandler.invoke(proxy, method, args). log it, check it, wrap it in a transaction, forward it to a real object, or answer it yourself.
that one trick is how Spring's @Transactional, Spring Data repositories, Retrofit and Feign clients work. and it only works for interfaces.
the picture
you know how you can write interface UserRepository extends JpaRepository<User, Long>, never implement it, and still call repo.findById(7)? somebody implemented it. at runtime.
think of a receptionist who answers every extension in a building. you dial "billing" or "support" (a method), the receptionist (the handler) picks up every single call, notes who called and why, and then either transfers you to the real person (the target), or just answers the question themselves. the caller can't tell the difference: it dialed the number it wanted, it got an answer.
a logging proxy
import java.lang.reflect.*;
import java.util.Arrays;
interface Greeter {
String greet(String name);
int count();
}
class RealGreeter implements Greeter {
public String greet(String name) { return "hi " + name; }
public int count() { return 1; }
}
void main() {
Greeter real = new RealGreeter();
InvocationHandler logging = (proxy, method, args) -> {
IO.println("→ " + method.getName() + " " + Arrays.toString(args));
try {
Object result = method.invoke(real, args);
IO.println("← " + result);
return result;
} catch (InvocationTargetException e) {
throw e.getCause(); // rethrow what the real method threw
}
};
Greeter g = (Greeter) Proxy.newProxyInstance(
Greeter.class.getClassLoader(),
new Class<?>[] { Greeter.class },
logging);
IO.println(g.greet("ada"));
g.count();
IO.println(g.getClass().getName() + " implements " + Arrays.toString(g.getClass().getInterfaces()));
}→ greet [ada] ← hi ada hi ada → count null ← 1 $Proxy0 implements [interface Main$Greeter]
this one needs a real JVM (reflection), so it doesn't run in the browser. copy it into java Main.java to try it.
three arguments: a class loader to define the new class in, the interfaces it should implement, and the handler. what comes back is an instance of $Proxy0, a class that didn't exist until that line ran. count() has no arguments, and notice the handler gets null for that, not an empty array.
here's what one call actually does:
stdout
main calls greet on a Greeter. it has no idea the object is a $Proxy0, a class the JVM generated at runtime that implements Greeter.
g.toString(): it goes through the handler too. then try the throwing path to see why the handler above unwraps exceptions.what the JVM generated
the proxy class is real bytecode, generated by the JDK and defined on the spot. i dumped it (with -Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true; on jdk 25.0.4 that writes the file and then crashes the program, so it's strictly a debugging trick) and read it with javap:
final class $Proxy0 extends java.lang.reflect.Proxy implements Main$Greeter {
private static final java.lang.reflect.Method m0; // hashCode
private static final java.lang.reflect.Method m1; // equals
private static final java.lang.reflect.Method m2; // toString
private static final java.lang.reflect.Method m3; // greet
private static final java.lang.reflect.Method m4; // count
public $Proxy0(java.lang.reflect.InvocationHandler);
public final int hashCode();
public final boolean equals(java.lang.Object);
public final java.lang.String toString();
public final java.lang.String greet(java.lang.String);
public final int count();
static {};
}and every method has the same body. here's greet, from javap -c:
0: aload_0
1: getfield #19 // Field java/lang/reflect/Proxy.h:Ljava/lang/reflect/InvocationHandler;
4: aload_0
5: getstatic #55 // Field m3:Ljava/lang/reflect/Method;
8: iconst_1
9: anewarray #11 // class java/lang/Object
12: dup
13: iconst_0
14: aload_1
15: aastore
16: invokeinterface #25, 4 // InterfaceMethod java/lang/reflect/InvocationHandler.invoke:(Ljava/lang/Object;Ljava/lang/reflect/Method;[Ljava/lang/Object;)Ljava/lang/Object;
21: checkcast #85 // class java/lang/String
24: areturnload the handler, load the cached Method, box the arguments into an array, call invoke, cast the result back. count() does the same and then unboxes an Integer with intValue(). that's why returning the wrong type from a handler is a ClassCastException at the call site, and returning null from an int method is a NullPointerException.
Runnable r = (Runnable) Proxy.newProxyInstance(
ClassLoader.getSystemClassLoader(),
new Class<?>[] { Runnable.class },
(proxy, method, args) -> { IO.println("called " + method.getName()); return null; });
r.run();
IO.println(r.toString());import java.lang.reflect.Proxy;
void main() {
Runnable r = (Runnable) Proxy.newProxyInstance(
ClassLoader.getSystemClassLoader(),
new Class<?>[] { Runnable.class },
(proxy, method, args) -> { IO.println("called " + method.getName()); return null; });
r.run();
IO.println(r.toString());
}called run called toString null
this one needs a real JVM (reflection), so it doesn't run in the browser. copy it into java Main.java to try it.
interfaces only
the generated class already extends java.lang.reflect.Proxy (that's where the h field lives), and a java class gets one superclass. so it can implement as many interfaces as you like, but it can't extend your class. ask for one anyway:
Exception in thread "main" java.lang.IllegalArgumentException: java.util.ArrayList is not an interfacewhen you need to proxy a class, you need a library that generates a subclass and overrides each method: ByteBuddy (what Hibernate and Mockito use today) or the older cglib (which Spring repackaged into spring-core). subclass proxies can't touch final classes or final methods, and can't intercept private ones, for the same reason a normal subclass can't.
where you've already used one
| library | what you write | what the proxy does |
|---|---|---|
| Spring Data | interface UserRepo extends JpaRepository | parses method names like findByEmail into queries |
| Retrofit, Feign | interface Api { @GET("/users") ... } | turns each call into an HTTP request |
| MyBatis | a mapper interface | runs the SQL mapped to each method |
| Spring AOP | @Transactional, @Cacheable, @Async on a bean | wraps calls in a transaction, a cache lookup, a thread hop |
| the JDK itself | @Route(path = "/x") | answers annotation element calls from a map (annotations) |
Spring AOP uses a JDK dynamic proxy when the bean implements an interface and class-based proxies otherwise (Spring Boot defaults to class-based for everything). either way the mechanism is the same, and so is the most famous bug in Spring:
controller → proxy.placeOrder() proxy: begin transaction proxy → target.placeOrder() proxy: commit
the controller was injected with the proxy, not your object. so its call goes through the proxy, which wraps it in a transaction and then calls the real placeOrder.
this.audit(): the call never leaves your object, so REQUIRES_NEW is silently ignored.what it costs
measured with JMH on jdk 25 (apple M4 Pro, 2 forks × 5 one-second iterations):
add(2) call. in a benchmark this small the JIT inlines the whole handler, and escape analysis deletes the argument array and the boxing. forwarding to the target adds a Method.invoke it can't fold away.about two nanoseconds in the best case, and real code is rarely the best case: one handler shared by hundreds of methods doesn't inline this nicely, and real advice does real work. but for a repository call that then waits a millisecond on a database, the proxy is noise. it only matters in a loop that runs a billion times, which is not where proxies live.
where the proxy class lives, and default methods+
for interfaces that are all public, the JDK defines the proxy class in a dynamic module made just for proxies: Runnable's proxy came back as jdk.proxy1.$Proxy0, in module jdk.proxy1. if any interface is package-private, the proxy has to live in that same package and class loader instead, which is why the Greeter proxy above was just $Proxy0. proxy classes are cached per class loader and interface list, so the second newProxyInstance with the same interfaces doesn't generate anything.
a default method on the interface also goes to the handler. to run the interface's own default body from inside your handler, call InvocationHandler.invokeDefault(proxy, method, args) (java 16+). before that, people needed a private-access MethodHandles.Lookup hack.
Proxy.newProxyInstancegenerates a class at runtime that implements your interfaces.- every call, including
toString,equalsandhashCode, lands inInvocationHandler.invoke. - unwrap
InvocationTargetException, return the right type, and never returnnullfor a primitive. - interfaces only. classes need a subclass-generating library like ByteBuddy.
- the advice is in the proxy.
this.method()goes around it.