Dynamic Proxies: JDK Proxy vs CGLIB
The previous article said AOP works by "building a stand-in at runtime". This article takes that stand-in apart: who builds it, what it looks like, and how a call finally reaches your code. Three terms, pinned down for someone who has never written Java:
- Reflection — a program's ability to operate on classes themselves while running, e.g. holding a
Methodobject and callingmethod.invoke(target, args). Think of writing the method name on paper and asking someone to perform it, instead of hard-wiring which line runs at compile time. - Bytecode — the intermediate language the JVM reads after
.javais compiled, stored in.classfiles. CGLIB can "conjure a subclass out of thin air" precisely because it assembles those instructions in memory, never going throughjavac. - Dynamic proxy — a class manufactured while the program is already running, shaped exactly like your target and instantiated on the spot. No such class exists anywhere in your source.
a JDK dynamic proxy is a receptionist stand-in. You come to see the CEO; a receptionist who looks just like him hands you a card with the same title (implements the same interface). Everything you say (every method call) is noted down and relayed to the real CEO, and the answer comes back through the same desk. The CEO neither knows nor changes anything — only who receives you changed. CGLIB is more like adopting a son: it generates a subclass whose parent is your target class and tampering happens inside the overridden methods, so the parent cannot be final (no father to adopt) and the method cannot be final (overriding forbidden).
an InvocationHandler is a parcel sorting hub. Every package (call), whatever its addressee, first arrives at this single place carrying a label with "recipient + waybill number" (Method + args). The hub decides whether to scan and register it first (before advice), deliver as-is (pass-through), or send it back (throw). That is why enhancement written once covers every method.

After this article you should be able to answer:
- Why can
$Proxy0only implement interfaces rather than extend my implementation class? - When is Spring forced into CGLIB, and how do the consequences of a
finalclass differ from afinalmethod? - Why does
(UserServiceImpl) proxyexplode, and why does a self-invoked@Transactionalfail silently?
Last section we hand-wrote a static proxy; its "you can never finish" property was only a vague ache then. Scale the scene up and it becomes visceral: the system has three service interfaces.
public interface UserService { User findById(Long id); void save(User user); }public interface OrderService { Order create(OrderDTO dto); void cancel(Long id); }public interface PayService { void pay(BigDecimal amount); }// Each new interface needs another proxy class; each method needs the same "forward + log" codepublic class UserServiceLogProxy implements UserService { /* forward every method... */ }public class OrderServiceLogProxy implements OrderService { /* forward every method... */ }public class PayServiceLogProxy implements PayService { /* forward every method... */ }- Proxies bind 1:1 to interfaces, so proxy count grows linearly with interface count
- Every new interface method forces a near-identical forwarding snippet in the proxy
- Add a second aspect (transactions, authorization) and proxy count multiplies further by aspect combination
The root problem: "forward + enhance" is identical for every interface, yet we wrote it out by hand many times. Since it has nothing to do with the interface, the program should generate it at runtime — that is the motivation for a dynamic proxy.
The JDK ships a dynamic proxy mechanism built on just two classes: java.lang.reflect.Proxy and InvocationHandler. This example runs as-is:
package com.example.proxy;import java.lang.reflect.*;import java.util.Arrays;public class JdkProxyDemo { public static void main(String[] args) { UserService target = new UserServiceImpl(); // the real target UserService proxy = (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), // 1. classloader for the proxy class new Class<?>[]{ UserService.class }, // 2. interfaces the proxy implements new LogInvocationHandler(target) // 3. who handles the forwarded call ); proxy.save(new User("alice")); System.out.println("main got: " + proxy.findById(1L)); }}class LogInvocationHandler implements InvocationHandler { private final Object target; LogInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("[before] " + method.getName() + " args=" + Arrays.toString(args)); Object result = method.invoke(target, args); // reflective call to the real target System.out.println("[after] " + method.getName() + " result=" + result); return result; }}Attention: Proxy.newProxyInstance only works when an interface is present. Passing UserServiceImpl.class instead of UserService.class would not change the essence — the proxy still only implements interfaces and never extends the implementation class. With no interface at all, JDK proxying has no foothold.
Output:
[before] save args=[User{name='alice'}][after] save result=null[before] findById args=[1][after] findById result=User{name='alice'}main got: User{name='alice'}- The three arguments are: a classloader, the interfaces to implement, and the
InvocationHandlerthat forwards calls - All enhancement logic lives in the single
invokemethod, independent of which interface or method it is method.invoke(target, args)uses reflection to reach the real target — that is how "forwarding" is done
Why must the target implement an interface? Because in Java a class extends only one parent, and the generated proxy already extends Proxy, so it cannot also extend your implementation. It can only achieve "same type as the target" by implementing interfaces. That restriction defines its boundary and leads straight to CGLIB.
At runtime the JDK generates a proxy class in memory named $Proxy0, $Proxy1... It looks roughly like this (simplified):
// Generated at runtime by the JDK (illustrative, not source)public final class $Proxy0 extends Proxy implements UserService { private static Method m3; // corresponds to save(User) public $Proxy0(InvocationHandler h) { super(h); // hand the handler to the superclass, Proxy } @Override public void save(User user) { try { // The essence: pack "method + args" as-is and hand it to handler.invoke super.h.invoke(this, m3, new Object[]{ user }); } catch (Throwable e) { throw new UndeclaredThrowableException(e); } }}- The proxy class is generated at runtime; add
-Djdk.proxy.ProxyGenerator.saveGeneratedFiles=trueto dump it to disk - It
extends Proxy, so it can only be "same type" as the target via interfaces — a direct consequence of Java's single inheritance - Each interface method body is nearly one line: hand
Methodandargstoh.invoke; all real logic lives in the handler

This animation slows the snippet above into six frames. Notice your own code runs for the first time only after frame three — from $Proxy0's point of view the world contains exactly two activities: pack, and hand over.
CGLIB (Code Generation Library) takes the other road: it generates a subclass of the target class at runtime, overrides its methods and inserts callbacks. It uses ASM to manipulate bytecode directly. The core is Enhancer and MethodInterceptor:
package com.example.proxy;import net.sf.cglib.proxy.*;public class CglibProxyDemo { public static void main(String[] args) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OrderService.class); // a "superclass", not an interface enhancer.setCallback(new LogMethodInterceptor()); // the method interceptor OrderService proxy = (OrderService) enhancer.create(); // create the subclass instance proxy.create(new OrderDTO("A1001")); }}class LogMethodInterceptor implements MethodInterceptor { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy mp) throws Throwable { System.out.println("[before] " + method.getName()); Object result = mp.invokeSuper(obj, args); // call the superclass (real) method System.out.println("[after] " + method.getName()); return result; }}- The CGLIB proxy object is a subclass of the target, so the target needs no interface
mp.invokeSuper(obj, args)calls the "superclass original method", the CGLIB analogue ofmethod.invoke(target, args)- Not needing an interface is exactly what closes the JDK proxy's gap
"Conjuring a subclass out of thin air" sounds like magic, so this six-frame animation turns it into an assembly line. Watch frame ② in particular: the bytecode is stitched together in memory by ASM, never by javac, which is why you will never find this class under target/; frame ⑤ is the moment the original parent method finally runs:

But it comes at a price: since it is inheritance, it inherits every restriction of Java inheritance. These two are the worst offenders:
public final class FinalService { // final class: cannot be subclassed, CGLIB fails outright public void hello() { }}public class NormalService { public final void fast() { } // final method: not intercepted; advice silently lost private void inner() { } // private method: likewise not intercepted}Trap: CGLIB is blunt about a final class — it throws IllegalArgumentException: Cannot subclass final class, so you find it immediately. The genuinely nasty case is a final / private / static method: the proxy builds fine, the app runs fine, but advice on those methods silently stops firing and nothing shows up in the logs.

| Dimension | JDK dynamic proxy | CGLIB |
|---|---|---|
| Mechanism | Generates $Proxy0 implementing interfaces | Generates a subclass of the target |
| Prerequisite | Target must implement an interface | Target class and methods must not be final |
| Generation timing | Generated and cached on first call | Generated and cached on first call |
| Performance | Greatly optimized since JDK 8; gap now small | Faster historically; slower to create, faster to call |
| Best for | Interface-based design, stable APIs | No interface, or proxying a concrete class |
| Typical exception | ClassCastException when casting to the impl | IllegalArgumentException for a final class |
One-line memory hook: JDK proxying relies on interfaces, CGLIB relies on inheritance. Whether an interface exists is the first fork in the road.
Spring AOP centralizes the choice in DefaultAopProxyFactory, which decides before creating a proxy. Simplified:
// Core decision in DefaultAopProxyFactory.createAopProxy (simplified)if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) { // CGLIB: forced, or the target has no usable interface return new CglibAopProxy(config);}// Has interfaces and no override -> JDKreturn new JdkDynamicAopProxy(config);- Default rule: target implements an interface → prefer JDK; no interface → CGLIB is the only option
- Force switch:
@EnableAspectJAutoProxy(proxyTargetClass = true)orspring.aop.proxy-target-class=true - CGLIB is the default since Spring Boot 2.x:
AopAutoConfigurationsetsproxy-target-classtotrueby default, so "inject by implementation class" no longer hitsClassCastException
Three ifs read as abstraction; drawn as a decision tree they become four questions, and each branch maps onto something you have personally seen in your own project:

This rule is practical: if an aspect is configured yet you get ClassCastException, it is most likely "a JDK proxy received into an implementation-class field". Either inject by interface, or turn on proxyTargetClass.
"JDK proxies are slower than CGLIB" is a widely repeated but outdated claim. Since JDK 8, the Proxy call path has been heavily optimized and the gap has shrunk to the point of being negligible in most business scenarios. Here is a simplified benchmark:
@Testvoid proxyThroughput() { // Critical: warm up so the JIT kicks in, otherwise you measure interpretation for (int i = 0; i < 200_000; i++) { jdkProxy.method(); cglibProxy.method(); } long t1 = System.nanoTime(); for (int i = 0; i < 10_000_000; i++) jdkProxy.method(); long jdk = System.nanoTime() - t1; long t2 = System.nanoTime(); for (int i = 0; i < 10_000_000; i++) cglibProxy.method(); long cglib = System.nanoTime() - t2; System.out.printf("JDK=%dms CGLIB=%dms%n", jdk / 1_000_000, cglib / 1_000_000);}Relative magnitudes on an ordinary machine (for a sense of the trend only; numbers vary with JDK and hardware):
| Metric | JDK dynamic proxy | CGLIB | Notes |
|---|---|---|---|
| Proxy creation | Slower (reflection + bytecode gen) | Slowest (ASM subclass gen) | Happens once; negligible |
| Per-call cost | Slightly slower | Slightly faster | Gap is small since JDK 8 |
| Cold-start overhead | Low | Higher | Heavier class generation |
proxy creation happens once at startup; call cost dominates at runtime. Unless you are optimizing a hot path down to nanoseconds, do not agonize over the choice for "performance" — the decisive factor is whether the target has an interface.
casting a proxy to the implementation class only works with CGLIB. A JDK proxy is a $Proxy0 that implements interfaces only and does not extend UserServiceImpl, so (UserServiceImpl) proxy throws ClassCastException. Inject by interface type (UserService) rather than implementation type (UserServiceImpl) and you sidestep it at the root.
CGLIB fails silently on final / private / static methods. They raise no error and fire no advice; you only avoid the issue by remembering that such methods can never be proxied. Conversely, if the target class itself is declared final, CGLIB fails outright — which is at least easier to locate.
The demo below switches between JDK and CGLIB and shows both the "proxy signature shape" and the "advice chain" at once. Seen side by side, it is clear that one implements interfaces and the other subclasses, yet both intercept calls the same way in the end.

Run the four experiments in order. The first is the main course — switch between JDK and CGLIB to compare proxy signatures and advice chains, while error shows how the chain behaves when the target throws:
Only once a stand-in exists does weaving mean anything. weave puts "no aspect / aspect woven / inside the chain / what it costs" side by side for one method, turning Section 3's vocabulary into something visible at runtime:
Who puts the case on the phone? autoptr walks the auto-proxy creator intercepting during post-processing, running the candidate check, then producing the object through a proxy factory — making "the AOP proxy is born at after-init" (article 9) an observable timeline:
Finally the combination question you must know: when several aspects hit one method, @Order decides which way the onion is layered — in and out. txaspect covers exactly the @Transactional-versus-custom-aspect trap:
That last one is about several stand-ins stacked; we never answered what sits inside a single one. advices hangs all five advice types on one method and shows how they are packed into the proxy's call chain:
One step further back in time: at which exact second does the raw object get swapped for the stand-in? The answer is after initialisation (postProcessAfterInitialization) — the bean is built, its properties are filled, and the shell goes on during the last formality before it is discharged:
Section 3 showed the $Proxy0 source sketch and Section 4 animated CGLIB, but "how one call actually travels from your hands into the handler" still has to be walked line by line. Here is the JDK route as a stepping bench: press next and watch the line marked (4) — your code executes for the first time two stack frames after the call site:
UserService svc = ctx.getBean(UserService.class); // svc is really jdk.proxy2.$Proxy78svc.save(new User("alice")); // (1) this line of yours is the call site// ---- the camera moves into $Proxy78 ----public void save(User u) { // (2) the method body generated on the spot super.h.invoke(this, m3, new Object[]{ u }); // (3) pack method id + args, hand off// ---- the camera moves into your LogInvocationHandler ----System.out.println("[before] " + method.getName()); // (4) pre-work: your code starts hereObject r = method.invoke(target, args); // (5) reflective call on the real objectSystem.out.println("[after] " + r); // (6) post-workreturn r; // (7) back through $Proxy78 to the caller} // (8) a checked exception not declared here gets wrapped| svc class | jdk.proxy2.$Proxy78 |
| Proxy.isProxyClass | true |
| target | UserServiceImpl@1a2b3c |
JdkProxyDemo.mainSix JDK / CGLIB fingerprints, each with a different meaning — position tricks won't help you here, every right-hand entry is distinct:
From here you can type the commands yourself. This console talks to the same container running in your browser and every line of output is computed by the kernel — boot, then beans to read each class-name suffix, and the lab lines to walk the three routes:
"Which proxy did Spring actually build for me" is not mysticism — it is a combination of exactly these three conditions:
# DefaultAopProxyFactory: has interface and not forced -> JDKproxy = com.sun.proxy.$Proxy78 implements UserServiceinject by interface: OKVerdict: the textbook default combination
Suggested usage: treat the first cell as the baseline, then move exactly one switch at a time. You will discover the two iron laws yourself: ① once proxyTargetClass=true, whether an interface exists stops mattering for the proxy type; ② the receiving variable's type must be compatible with the stand-in produced — that single fact is the entire origin of ClassCastException.
Half of the errors here explode loudly; the other half are silent — and those are the hard ones.
Start with the stack trace people copy most often:
java.lang.ClassCastException: class com.sun.proxy.$Proxy78 cannot be cast to classcom.example.service.UserServiceImpl (com.sun.proxy.$Proxy78 andcom.example.service.UserServiceImpl are in unnamed module of loader 'app') at com.example.controller.UserController.<init>(UserController.java:23)How to read it: the first half says the stand-in was built by the JDK (com.sun.proxy.$Proxy78); the second half says you tried to receive it with the implementation type. Those can never be compatible — $Proxy0 extends Proxy and implements interfaces only; it shares no inheritance line with your UserServiceImpl.
| Error text (searchable fragment) | Real cause | 30-second self-rescue | Where to dig deeper |
|---|---|---|---|
java.lang.ClassCastException: class com.sun.proxy.$Proxy78 cannot be cast to class com.example.UserServiceImpl | A JDK proxy (interface present, CGLIB not forced) received or cast into the implementation type | Prefer changing the field to the interface type; if you truly must inject by implementation, set spring.aop.proxy-target-class=true (already the Boot default) | Section 6 here |
org.springframework.cglib.core.CodeGenerationException: java.lang.IllegalArgumentException: Cannot subclass final class com.example.FinalService | The target class is final, so CGLIB cannot create a subclass | Remove final; extract an interface so the JDK route applies; or disable proxying for that one bean | Section 4 here |
@Transactional on a final method: startup fine, no warning, yet the update commits after an exception | A subclass proxy cannot override a final method, so the advice is silently dropped | Remove final; print getClass().getName() to confirm a proxy exists, then audit each method modifier | Article 31 |
Advice never runs on private / static methods | Private and static methods are not interception points for a proxy (static belongs to the class; the proxy holds an instance) | Make it a public instance method; if the logic must stay static, move it to a utility called by an advised method | Article 11, Section 7 |
this.save(x) inside the same class kills @Transactional / @Cacheable (no error at all) | this points at the raw object, not the container's stand-in, so the call bypasses the proxy | Extract to another bean; inject your own proxy; or ((MyService) AopContext.currentProxy()).save(x) together with @EnableAspectJAutoProxy(exposeProxy = true) | Articles 14 and 15 |
java.lang.reflect.UndeclaredThrowableException thrown from a proxied call site | InvocationHandler.invoke threw a checked exception not declared on the interface method, so $Proxy0 had to wrap it | In the handler, rethrow RuntimeException/Error untouched and wrap the rest deliberately; or add throws to the interface method | Section 3 here |
NoClassDefFoundError: net/sf/cglib/proxy/MethodInterceptor (while hand-writing a CGLIB demo) | The sample uses the legacy cglib package names, whereas Spring 5+ ships the relocated org.springframework.cglib.* | Switch to org.springframework.cglib.proxy.Enhancer, or simply depend on spring-boot-starter-aop | Section 4 here |
Weird equals / hashCode / toString behaviour on the proxy | These three are routed to the InvocationHandler too, and the AOP machinery treats them specially | Never infer business identity from toString(); compare objects by an explicit ID field | Article 14 |
unsure which stand-in you hold? Print svc.getClass().getName(): $Proxy means JDK, $$SpringCGLIB$$0 means CGLIB, and neither means you were never proxied at all.
This article's most famous ClassCastException has a remarkably short stack — the hard part is not reading it but accepting why $Proxy78 is not a UserServiceImpl. Point at the frame you think is guilty:
A colleague flips spring.aop.proxy-target-class back to false (or you are mid-migration from Boot 1.x), and UserController's constructor parameter is the implementation class. The app dies as soon as it starts.
Build a JDK dynamic proxy yourself, then dump the $Proxy0 the JVM manufactures and actually read it — this single step creates the felt sense that "the stand-in is a real class". Fully runnable code:
package com.example.proxy;public interface UserService { String find(Long id);}public class UserServiceImpl implements UserService { @Override public String find(Long id) { return "user-" + id; }}package com.example.proxy;import java.lang.reflect.InvocationHandler;import java.lang.reflect.Method;import java.lang.reflect.Proxy;import java.util.Arrays;public class JdkProxyLab { public static void main(String[] args) throws Throwable { UserService target = new UserServiceImpl(); UserService proxy = (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), new Class<?>[]{ UserService.class }, new InvocationHandler() { @Override public Object invoke(Object p, Method m, Object[] a) throws Throwable { System.out.println("[handler] call received: " + m.getName() + " args=" + Arrays.toString(a)); Object r = m.invoke(target, a); // reflection reaches the impl System.out.println("[handler] returned: " + r); return r; } }); System.out.println("[class] " + proxy.getClass().getName()); System.out.println("[ifaces] " + Arrays.toString(proxy.getClass().getInterfaces())); System.out.println("[isProxy?] " + Proxy.isProxyClass(proxy.getClass())); System.out.println("[call ] " + proxy.find(7L)); // verify the "cannot cast to the impl" law with your own hands try { UserServiceImpl bad = (UserServiceImpl) proxy; System.out.println("[bad] the cast actually worked?" + bad); } catch (ClassCastException e) { System.out.println("[bad] " + e.getMessage()); } }}Expected output:
[class] jdk.proxy2.$Proxy7[ifaces] [interface com.example.UserService][isProxy?] true[handler] call received: find args=[7][handler] returned: user-7[call ] user-7[bad] class jdk.proxy2.$Proxy7 cannot be cast to class com.example.UserServiceImpl ...Now write the generated proxy class to disk (the Java 9+ flag):
-Djdk.proxy.ProxyGenerator.saveGeneratedFiles=trueAfter the run you get jdk/proxy2/$Proxy7.class in the working directory. Decompiling it with javap -p jdk.proxy2.$Proxy7 shows h.invoke(this, ...) inside every interface method body — line for line what Section 3 illustrated.
Goal: prove that advice on a final method dies silently.
Hint: duplicate UserServiceImpl.find as public final String findCached(Long id), print the method name inside the handler, then call both methods once through the interface. To check the Spring case, add @Transactional public final void tx() to the implementation and call it from outside.
You should observe: non-final find passes through the handler every time; findCached, exposed through the interface, is still intercepted (the JDK proxy only looks at interfaces) — but under CGLIB, tx() triggers not one piece of advice, with no error and no warning. That is precisely why this section keeps insisting: print getClass().getName(), then audit method modifiers one by one.
Build a toy "mini IoC + AOP" container: using JDK dynamic proxies only, give every interface in the registry a "logging + timing" layer, switchable via -Dxxx.proxy=jdk|none.
Acceptance checklist:
- [ ] Beans live in a
Map<String, Object>; on retrieval, wrap them withProxy.newProxyInstancewhen an enhancement applies - [ ] Two consecutive
getBeancalls return the same proxy instance (cache the proxy, do not rebuild it) - [ ] The proxy casts fine to the interface type but fails against the implementation type — encode that difference as a one-line test assertion
- [ ] Your handler special-cases
equals/hashCode/toString, and you explain why it must - [ ] You can answer: if some implementation has no interface at all, can this toy container still enhance it? What would you do instead?
can you recite the three arguments of Proxy.newProxyInstance (classloader, interface array, handler) and say why the second may only contain interfaces?
what is the single line inside a $Proxy0 method body? (h.invoke(this, method, args)) — write it down and you understand JDK proxying completely.
what are Spring's three reasons to choose CGLIB? (forced proxyTargetClass, optimize, or no usable interface)
how does the failure mode of a final class differ from that of a final method? (the former throws at startup, the latter dies silently)
what are the two escape routes from ClassCastException: cannot be cast to ...Impl? (inject by interface; or enable proxyTargetClass)
JDK leans on interfaces, CGLIB on inheritance; a final class explodes on the spot, a final method goes quiet; casting the stand-in to the person always fails, and a self-call is never heard.
dynamic proxies are the engine of Spring AOP, and there are only two routes — the JDK proxy uses Proxy.newProxyInstance to build a $Proxy0 that implements interfaces and forwards calls to an InvocationHandler; CGLIB uses Enhancer to build a subclass of the target and intercepts with a MethodInterceptor. The decisive factor is whether the target has an interface; Spring defaults to JDK with interfaces and CGLIB without, and Spring Boot 2.x defaults to CGLIB. Two traps are enough to remember: casting to the implementation class only works with CGLIB, and final / private / static methods cannot be intercepted by CGLIB.