How to Implement Dynamic Proxies in Java from Scratch
A deep dive into java.lang.reflect.Proxy, InvocationHandler, MethodHandle performance, CGLIB class proxying, and how Spring AOP, Hibernate, and Mockito are all built on this one mechanism.
Dynamic Proxies are one of Java's most powerful and widely-used yet least-understood features. Every time you annotate a method with @Transactional, call a Mockito mock, or load a Hibernate entity with a lazy relationship, a dynamic proxy is silently working behind the scenes.
Rather than hardcoding delegation and cross-cutting logic into every class, a proxy intercepts method calls at runtime and allows you to inject behavior — logging, timing, access control, transaction management — without touching the original business logic. In this guide, you will build dynamic proxies from scratch, understand the JVM machinery powering them, trace call paths through InvocationHandler, compare Reflection vs MethodHandle performance mathematically, and learn why Spring Boot practically runs on top of this feature.
1. What Is a Proxy? Building the Right Mental Model
1.1 The Credit Card Terminal Analogy
Imagine you walk into a shop and hand your credit card to a payment terminal. You are not directly interacting with your bank. The terminal intercepts the payment request, adds security checks, logs the transaction, and then forwards it to your bank. To you, the experience looks identical to paying directly — but significant cross-cutting work happened invisibly in the middle. A Java Dynamic Proxy works identically.
In object-oriented terms, a Proxy is an object that implements the same interface as a target object, intercepts method calls made to it via an InvocationHandler, executes custom cross-cutting logic (logging, timing, security), and then optionally delegates to the real target. The caller sees no difference — they simply call a method on what appears to be the target object.
1.2 Static Proxy vs Dynamic Proxy
Before dynamic proxies existed, developers wrote static proxies manually: a hand-crafted wrapper class implementing the same interface, delegating every method to the target and adding cross-cutting logic inline. The fatal problem is that for $N$ methods on $M$ interfaces, you must write $N \times M$ wrapper methods by hand — a maintenance nightmare.
A Dynamic Proxy is generated at runtime by the JVM using bytecode generation. You write a single InvocationHandler class, and the JVM creates a proxy class implementing any number of interfaces automatically. Every method call on the proxy routes through your single invoke() method — no matter how many methods or interfaces are involved.
Pitfall — JDK Dynamic Proxy only works with interfaces: java.lang.reflect.Proxy can only create proxies for Java interfaces, not concrete classes. If you need to proxy a class with no interface (e.g., a plain POJO), you must use CGLIB or ByteBuddy, which use bytecode subclass generation. Spring uses CGLIB by default when no interface is present on a @Transactional bean.
2. Java Reflection API Fundamentals
2.1 The Class Object as a Runtime Blueprint
Java's Reflection API, housed in java.lang.reflect, lets you inspect and manipulate classes, methods, fields, and constructors at runtime. The entry point for every Reflection operation is the Class<T> object — a JVM-level metadata descriptor for every loaded class. You obtain it via MyClass.class, obj.getClass(), or Class.forName("com.example.MyClass").
From a Class object, you can enumerate all declared methods via getDeclaredMethods(), retrieve fields, access constructors, read annotations, and inspect generic type parameters. By calling method.setAccessible(true), you can bypass Java's access modifiers and invoke even private methods from external code — a capability that both unit testing frameworks and dependency injection containers depend on.
2.2 Method.invoke() Call Path
When you call method.invoke(target, args), the JVM does not call the method directly. It first validates parameter types, resolves the method to a native function pointer, then dispatches the call. For the first 15 invocations, Java uses a slow interpreted path. After that, the JVM inflates the reflective call into a generated native accessor for speed. The invocation overhead model is:
This 15-invocation inflation threshold (controlled by JVM flag -Dsun.reflect.inflationThreshold) means cold Reflection calls are 10x slower than warmed-up calls. Proxy-heavy frameworks (Spring, Hibernate) benefit enormously from long-running JVM processes where inflation has occurred.
Pitfall — Calling setAccessible(true) in Java 9+ modules: The Java Module System (JPMS) introduced strong encapsulation. Calling setAccessible(true) on a method in an unexported module package throws InaccessibleObjectException at runtime unless the module explicitly opens the package via opens com.internal to framework.module.
3. How java.lang.reflect.Proxy Works
3.1 Three-Argument Factory Method
Proxy.newProxyInstance() is the single factory method for creating JDK dynamic proxies. It takes exactly three arguments:
- ClassLoader loader — The classloader used to define the generated proxy class. Typically
target.getClass().getClassLoader(). - Class<?>[] interfaces — The set of interfaces the proxy must implement. The proxy class will implement all of them simultaneously.
- InvocationHandler h — The single handler object whose
invoke()method intercepts all method calls on the proxy.
Internally, Proxy.newProxyInstance() calls Proxy.getProxyClass() which generates (or retrieves from a cache) a bytecode class implementing all requested interfaces. It then constructs an instance of that generated class, passing in your InvocationHandler.
3.2 The Proxy Class Generation Lifecycle
Diagram: The JDK Proxy class generation and caching lifecycle inside ProxyClassFactory.
Pitfall — Metaspace leaks from un-cached proxy class creation: Dynamically generating custom proxy classes in a loop with distinct ClassLoader instances creates thousands of unique `Class` objects in the JVM Metaspace. Because class metadata is not freed until the defining ClassLoader is garbage collected, this causes java.lang.OutOfMemoryError: Metaspace. Always leverage `Proxy.newProxyInstance()` which uses `WeakCache` internally to reuse generated proxy classes.
4. Implementing Your First Dynamic Proxy
4.1 A Logging Proxy — Step by Step
Let's build a real dynamic proxy that logs every method call — its name, arguments, return value, and execution time. First, define a simple interface and implementation:
Now create the InvocationHandler that intercepts every method and adds logging:
Finally, wire the proxy together and use it:
5. The InvocationHandler Contract in Depth
5.1 The invoke() Method Signature
The InvocationHandler interface has exactly one method: Object invoke(Object proxy, Method method, Object[] args). Understanding each parameter deeply is essential for writing correct handlers.
- proxy — The proxy instance itself (NOT the target). Never pass this to
method.invoke(); that creates an infinite recursion loop where the proxy calls itself indefinitely. - method — A
java.lang.reflect.Methodobject representing the interface method being called. Usemethod.getName(),method.getReturnType(), andmethod.getAnnotations()to inspect it. - args — The arguments passed by the caller. This is
null(not an empty array) when the method takes no parameters — always null-check before iterating.
Sequence Diagram: End-to-end execution flow of a method call through a JDK Dynamic Proxy and InvocationHandler.
5.2 Exception Unwrapping — The Most Common Bug
When your real target method throws a checked exception (e.g., IOException), method.invoke() wraps it inside an InvocationTargetException. If you catch Throwable and rethrow without unwrapping, the caller receives an unexpected InvocationTargetException instead of the intended IOException.
The correct unwrapping pattern is always:
Pitfall — Using proxy as method.invoke() target: Calling method.invoke(proxy, args) instead of method.invoke(target, args) causes infinite recursion. The proxy's method call triggers invoke() again, which calls method.invoke(proxy, args) again, repeating until a StackOverflowError is thrown.
6. Advanced: MethodHandle API — Faster Reflection
6.1 What Is a MethodHandle?
Introduced in Java 7 as part of JSR 292 (supporting dynamic languages on the JVM), java.lang.invoke.MethodHandle provides a typed, directly invocable reference to a method, constructor, or field. Unlike Method.invoke(), MethodHandles are JIT-compiled by the Hotspot JVM into highly optimized machine code — often reaching performance levels equivalent to direct method calls.
You obtain a MethodHandle via a MethodHandles.Lookup object, specifying the class, method name, and method type signature. The JVM inlines MethodHandle invocations during JIT compilation, eliminating boxing overhead for primitive arguments and removing reflective dispatch overhead entirely.
6.2 MethodHandle Performance Model
The performance difference between Reflection and MethodHandle is significant under JIT compilation. Let $T_{\text{direct}}$ be the baseline direct method call time, $T_{\text{reflect}}$ the warmed Reflection time, and $T_{\text{mh}}$ the MethodHandle time:
In practice, after JIT warmup, MethodHandles reach within $2\times$ of direct calls, while Method.invoke() stays approximately $20\times$ slower due to argument boxing, security stack walking, and type erasure overhead. For high-frequency proxy invocations in serialization or ORM frameworks, this 10x difference is significant.
Pitfall — MethodHandle.invokeExact() signature strictness: Unlike Method.invoke(), invokeExact() requires parameter and return types to match the target signature with 100% precision. Passing a primitive subtype or omitting explicit type casts throws a WrongMethodTypeException at runtime. Use invoke() if automatic type conversions or boxing are required.
7. Advanced: CGLIB Proxies for Concrete Class Proxying
7.1 When JDK Proxies Cannot Help
JDK Dynamic Proxies have a strict limitation: they can only implement Java interfaces. If you attempt to proxy a concrete class (one that doesn't implement any interface), Proxy.newProxyInstance() throws an IllegalArgumentException. This is a critical limitation in practice, because not every business object in a real codebase has been designed with a corresponding interface.
CGLIB (Code Generation Library) solves this by generating a runtime subclass of the target concrete class using ASM bytecode manipulation. The generated subclass overrides all non-final methods, inserting calls to a MethodInterceptor (CGLIB's equivalent of InvocationHandler) before delegating to super.method(). Spring uses CGLIB automatically for @Transactional or @Cacheable beans that have no interface.
7.2 CGLIB vs JDK Proxy Comparison
| Feature | JDK Dynamic Proxy | CGLIB Proxy |
|---|---|---|
| Target Type | Interfaces only | Concrete classes (subclassing) |
| Mechanism | Interface implementation at runtime | Bytecode subclass generation via ASM |
| Can proxy final methods? | N/A (interface methods never final) | NO — final methods cannot be overridden |
| Handler interface | InvocationHandler |
MethodInterceptor |
| Spring Boot default | Used when interface is present | Default since Spring 4.3 |
Pitfall — Proxying final classes or methods with CGLIB: CGLIB cannot subclass final classes or override final methods. Annotating a Spring @Service class or its @Transactional method as final silently breaks transaction management — the proxy is created but the interceptor is never called. Always avoid final on Spring-managed beans.
8. Real-World Use Cases in Java Frameworks
8.1 Spring AOP — Aspect-Oriented Programming
Spring AOP is built entirely on dynamic proxies. When you annotate a method with @Transactional, Spring's AbstractAutoProxyCreator creates a CGLIB or JDK proxy wrapping your bean at container startup. The proxy's MethodInterceptor begins a database transaction before calling the real method, commits on success, and rolls back on unchecked exception — all without any code in your business service.
Similarly, @Cacheable, @Async, @Scheduled, and @Retryable annotations are all implemented as proxy-based interceptors. The method invocation flows through a chain of interceptors (the Spring AOP Interceptor Chain) before reaching the actual target method. This is the Interceptor Chain pattern: each interceptor calls invocation.proceed() to pass control to the next interceptor in the chain.
8.2 Hibernate Lazy Loading and Mockito Mocking
Hibernate uses CGLIB proxies for lazy-loaded entity relationships. When you access order.getCustomer() without JOIN FETCH, Hibernate returns a CGLIB proxy subclass of Customer. The proxy's MethodInterceptor checks if the real entity is loaded; if not, it issues a SQL SELECT query to load data from the database, initializing the proxy before delegating the call. This is the "Proxy Pattern" for deferred loading.
Mockito creates mock objects using ByteBuddy (a modern bytecode library) to generate proxy subclasses of the mocked class. Every method call on a mock is intercepted by Mockito's internal InvocationHandler, which checks when(...).thenReturn(...) stubbing records and returns the configured value or throws the configured exception. Without dynamic proxying, Mockito mocking would be impossible.
9. Performance Benchmarking: Direct vs Reflection vs MethodHandle
The chart below compares method invocation throughput (millions of calls per second) after JVM warmup. Measurements reflect JMH benchmarks on a modern JVM with JIT compilation active:
As shown, direct invocation is the baseline. MethodHandle reaches ~95% of direct performance after JIT inlining. Warmed Reflection reaches ~50-60%, while cold Reflection (first 15 calls) is far slower. CGLIB proxy overhead sits between warmed Reflection and MethodHandle due to the generated subclass method dispatch cost.
10. Interactive: Proxy Method Call Visualizer
Click "Call Method" to trace how a method call flows through the JDK Dynamic Proxy and InvocationHandler before reaching the real target:
11. Common Pitfalls and Developer Traps
11.1 Self-Invocation Breaks AOP Proxies
One of the most confusing Spring AOP bugs is self-invocation. If a @Transactional method inside a Spring bean calls another @Transactional method in the same bean using this.otherMethod(), the proxy is bypassed entirely. The call goes directly to the real object, and no transaction is started for otherMethod(). This is because this is a reference to the real target, not the proxy.
The solution is to either inject a self-reference to the bean (via @Autowired of itself), use AopContext.currentProxy(), or restructure the code to avoid self-invocation.
11.2 Proxy Identity and equals()/hashCode()
A JDK dynamic proxy is a new generated class. Calling proxy instanceof TargetClass returns false (for JDK proxies), because the proxy implements an interface, not the class. Comparing proxy instances using == or storing them in identity-based data structures like IdentityHashMap produces unexpected behavior when the same target is proxied twice.
Pitfall — instanceof checks fail for JDK proxies: Code like if (service instanceof PaymentServiceImpl) returns false when service is a JDK proxy. Use AopUtils.isJdkDynamicProxy(service) or AopUtils.getTargetClass(service) to unwrap the proxy and get the real class in Spring-managed contexts.
12. Frequently Asked Questions
Q1: Why does Spring use CGLIB by default instead of JDK proxies?
Since Spring 4.3, CGLIB is the default because it can proxy both classes and interfaces. It avoids runtime failures when developers forget to program to interfaces, and it provides slightly better performance due to direct method dispatch through the generated subclass.
Q2: Can I proxy a class with private constructors using CGLIB?
No. CGLIB generates a subclass and must instantiate it. If all constructors are private, CGLIB cannot subclass or instantiate the proxy. Use Objenesis (which Spring ships with) to instantiate without calling any constructor as a workaround.
Q3: What is the difference between invoke() and invokeExact() on a MethodHandle?
invokeExact() requires argument types to match exactly (no auto-widening/boxing). It is fastest because no type conversions occur. invoke() permits automatic type conversions and boxing, making it more flexible but slightly slower.
Q4: How does Mockito create mocks for final classes?
Mockito uses a Java agent (via mock-maker-inline or MockMaker SPI) that instruments the JVM at classloading time using instrumentation APIs, bypassing the subclassing limitation of CGLIB. This is configured via src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker.
Q5: Does Proxy.newProxyInstance() create a new class on every call?
No. JDK caches generated proxy classes in a WeakCache keyed by the ClassLoader and the set of interfaces. Repeated calls with the same ClassLoader and interfaces return the cached class, only instantiating a new object with the new handler.
Q6: How do I access the real target object from a Spring proxy?
Use AopTestUtils.getUltimateTargetObject(proxy) in tests or ((Advised) proxy).getTargetSource().getTarget() in production code to unwrap the proxy and retrieve the underlying bean.
Q7: Can I chain multiple InvocationHandlers together?
Yes — by wrapping handlers: your outer handler creates a proxy of the inner handler's proxy. Spring implements this as an AdvisedInterceptor chain where each interceptor calls invocation.proceed() to pass to the next, forming a responsibility chain.
Q8: Why does my @Transactional annotation on a private method not work?
Spring AOP proxies (both JDK and CGLIB) can only intercept public and protected methods. Private methods are not visible to the proxy subclass and are called directly without interception. Always declare @Transactional on public methods.