Spring Boot AOP Deep Dive: Proxy Internals, Business Patterns & Performance Traps
This article dissects Spring Boot AOP from proxy creation (JDK vs CGLIB) and interceptor chain mechanics to practical aspects for logging, tracing, and idempotency, while exposing eight common pitfalls like self-invocation bypass, final method limits, and synchronous I/O in advice that degrade P99 latency.
Cross-Cutting Concerns and AOP Basics
Repeated boilerplate — authentication, tracing, idempotency, latency logging — clutters business methods. These are cross-cutting concerns . AOP extracts the "wrapping routine" into an Aspect , uses a Pointcut to declare where to weave, and an Advice to declare what to weave, leaving business code clean.
Spring AOP is dynamic-proxy-based ; it only intercepts external method calls. It cannot intercept field access, constructors, or static methods like compile-time AspectJ weaving, but it is lighter and sufficient for most business scenarios.
Two Dynamic Proxy Implementations: JDK vs CGLIB
JDK Dynamic Proxy : Interface-based via Proxy.newProxyInstance. Proxy and target implement the same interface. Method calls go through reflection ( Method.invoke).
CGLIB : Subclass-based; generates a subclass of the target class. Overrides non-final methods. Uses FastClass index for direct invocation, avoiding reflection.
Since Spring Boot 2.x, proxyTargetClass=true is default, so CGLIB is used even when interfaces exist, simplifying injection.
CGLIB limitations : final methods cannot be overridden (advice silently skipped), and static methods belong to the class, not the instance, so they cannot be proxied.
Proxy Object Lifecycle
Bean initialization completes. AbstractAutoProxyCreator post-processor runs.
Scans all Advisor s in the container; each Pointcut matches the bean's class and methods.
Matches are collected into an interceptor chain (cached).
Proxy is created via JDK or CGLIB per configuration.
Container injects the proxy, not the raw bean.
External call → interceptor chain executes first → chain tail invokes the real method.
Key insight : Proxy creation happens after bean initialization but before injection . Therefore internal this calls bypass the proxy (root cause of @Transactional, @Async, @Retryable failures). When multiple aspects match the same method, their advices form a single chain ordered by @Order.
Five Core Source Classes
1. AbstractAutoProxyCreator: Proxy Factory
public Object postProcessAfterInitialization(Object bean, String name) {
if (bean instanceof AopInfrastructureBean) return bean;
Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(bean.getClass(), name, null);
if (specificInterceptors != DO_NOT_PROXY) {
return createProxy(bean.getClass(), name, specificInterceptors);
}
return bean;
}Infrastructure beans are excluded. If interceptors are found, a proxy is created; otherwise the raw bean is returned.
2. CglibAopProxy: Per-Invocation Entry Point
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) {
List<Object> chain = cached(method);
if (chain.isEmpty()) return proxy.invoke(target, args);
return new CglibMethodInvocation(target, method, args, chain).proceed();
}Performance note : CGLIB's MethodProxy uses FastClass for direct calls, not reflection. The interceptor chain is cached per method , so matching cost is paid only once. "Proxy is slow" is a myth; slowness comes from logic inside the advice.
3. ReflectiveMethodInvocation: Chain-of-Responsibility Driver
public Object proceed() throws Throwable {
if (this.currentInterceptorIndex == this.interceptors.size() - 1) {
return invokeJoinpoint();
}
Object interceptor = this.interceptors.get(++this.currentInterceptorIndex);
return interceptor.invoke(this);
} ProceedingJoinPoint.proceed()in @Around advances this chain. Not calling proceed() = method never executes; calling twice = method executes twice — a source of duplicate-submission bugs.
4. AspectJExpressionPointcut: Matching Rules
public boolean matches(Method method, Class<?> targetClass) {
ShadowMatch shadowMatch = getTargetShadowMatch(method, targetClass);
return shadowMatch.matches();
}
// @annotation matching requires the annotation's Retention = RUNTIME
// (SOURCE-level annotations are absent from bytecode)Uses AspectJWeaver to parse expressions; checks method, class, parameters, annotations.
5. ExposeInvocationInterceptor: Chain-Head Sentinel
Fixed at the head of every proxy's interceptor chain. Puts the current MethodInvocation into a
ThreadLocal</strong> so advices can access it (e.g., for binding pointcut parameters). Reminder: aspect context is also <code>ThreadLocal— does not cross threads, must be handled explicitly in async scenarios.
Five Advice Types and Expressions
@Around— wraps the whole invocation; can change return value, swallow exceptions, or skip the method entirely. @Before, @AfterReturning, @AfterThrowing — one-way observers. @After — finally semantics.
Only @Around can modify return value, suppress exceptions, or prevent execution. Use @Before / @After for pure observation; they are safer.
Expression Styles
// execution expression (position-based)
@Before("execution(* com.demo..*Service.*(..))")
// annotation-driven (intent-based, recommended)
@Around("@annotation(opsLog)")
public Object around(ProceedingJoinPoint pjp, OpsLog opsLog) { ... }Prefer annotation-driven : execution matches by package/class position — moving a class breaks it. @annotation matches by intent — a method annotated @OpsLog is intercepted; the scope is obvious to any reader.
Practical Business Aspect Trio
1. Operation Log Aspect
@Around("@annotation(opsLog)")
public Object log(ProceedingJoinPoint pjp, OpsLog opsLog) {
// record: who, when, what method, params, result, latency → async write to DB
// NEVER do heavy synchronous work in an aspect
}2. MDC Propagation Aspect
@Around("@within(trace)")
public Object trace(ProceedingJoinPoint pjp) {
mdc.put("traceId", currentTraceId());
try { return pjp.proceed(); }
finally { mdc.clear(); } // thread reuse demands cleanup
}3. Idempotency Aspect (from previous article)
@Aroundtries to insert into a deduplication table; on hit, returns the original result directly.
These three aspects cover "record it, carry context, prevent duplicates" — three orthogonal needs — without adding a line to business code.
Multi-Aspect Ordering
When multiple aspects hit the same method, @Order smaller = outer layer (first in, last out). Typical order: Idempotency (1) → Tracing (2) → Logging (10) . Idempotency outermost blocks duplicate requests before any heavy logic runs.
Performance Pitfalls: The Invisible P99 Tax
JMH-level reference measurements:
Hit-one-proxy-all : A class with 500 methods; if one method matches, the other 499 still traverse the interceptor chain (cached, but an extra stack frame per call).
Too many aspects on the chain : Each adds a few microseconds; multiplied per request per method, P99 grows visibly.
@Around doing I/O (DB/HTTP) = turning observability into a new bottleneck.
Three rules:
Keep advice logic in microseconds (in-memory ops OK; I/O must be async).
Use precise annotation pointcuts , not broad execution scanning whole packages.
Avoid aspects on hot paths (e.g., gateway forwarding) — every nanosecond is multiplied by QPS.
Real-World Incident
Operation-log aspect wrote to DB synchronously. Normal traffic: unnoticed. During a promotion, DB writes slowed; every interface was dragged down by ~30 ms — the logging aspect became the slowest "service" site-wide. Fixed by switching to in-memory queue + async batch persistence.
Eight Common Pitfalls & Troubleshooting Guide
Self-invocation bypass : Internal this.method() calls skip the proxy. Root cause of @Transactional / @Async / @Retryable failures (appeared three times in this series).
final method ineffective : CGLIB cannot override final; advice silently skipped.
static / private methods ineffective : Proxy only intercepts public instance methods.
Annotation Retention != RUNTIME : @annotation pointcut always false.
@Around forgets proceed() : Method doesn't execute, no error; calling twice = double execution.
Exception swallowed in aspect : @AfterThrowing does not "handle" exceptions; @Around catch without rethrow = exception swallowed.
Circular dependency : Two beans mutually injected and both proxied; Boot 2.6+ refuses startup by default — break the cycle, don't disable validation.
Assuming aspect order : Without @Order, wrapping order drifts with registration order; idempotency inside logging = useless.
Three-Step Diagnosis
1. Check object: is the injected bean a proxy?
System.out.println(bean.getClass())
2. Check caller: is it a same-class this-call?
(same root cause as @Transactional/@Async/@Retryable)
3. Check matching: expression correct? annotation Retention=RUNTIME? method final/static?Learning Roadmap & Interview Questions
Four Stages
Stage 1 (Use) : Five advice types, execution vs @annotation, business aspect trio.
Stage 2 (Understand) : Proxy creation timing, JDK vs CGLIB differences, chain-of-responsibility model, complete explanation of this failure.
Stage 3 (Extend) : Custom annotation + aspect combos, Introduction (declare parents), aspect unit tests + JMH benchmarks.
Stage 4 (Systemize) : AspectJ compile-time weaving boundaries, AOP in framework internals (transaction/cache/retry source reading), bytecode enhancement (Agent/ASM) vs AOP.
12 High-Frequency Interview Questions
Spring AOP vs AspectJ differences?
JDK dynamic proxy vs CGLIB differences and selection?
When is the proxy object created?
How does the interceptor chain advance?
Why does this invocation break aspects? Solutions?
What happens if @Around doesn't call proceed()? Calls twice?
Execution order of the five advice types?
How do multiple aspects decide wrapping order?
Prerequisites for @annotation matching?
Where is AOP overhead? How to optimize?
Relationship between circular dependency and AOP?
Relation of @Transactional, @Cacheable, @Retryable to AOP?
One-Page Cheat Sheet
AOP 实战 · 一页速记
──────────────────────────────
Spring AOP = 动态代理,只拦外部调用
Boot 2.x+ 默认 CGLIB(final 失效)
this / static / private 一律不生效
代理链有缓存,慢的是切面里的逻辑
@Around 不调 proceed = 不执行
调两次 = 执行两次
观测用 @Before,慎用全能 @Around
多切面 @Order:幂等最外层
切面里禁 IO,重活一律异步
注解驱动 > execution(按意图匹配)
Retention 必须 RUNTIMESigned-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
