Explain the @Async self-invocation pitfall and the ways to work around it.
answer
- proxy intercepts only external calls
- this.asyncMethod() = synchronous, bypasses proxy
- same trap as @Transactional/@Cacheable
- fixes: separate bean / self-inject / AopContext / ASPECTJ
- public methods only in proxy mode
basics
~20 s@Async works through a proxy. If a bean calls its own @Async method directly (this.method()), the call skips the proxy and runs synchronously on the same thread. Fix it by calling through another bean, self-injecting the proxy, or using AspectJ mode.
solid answer
~40 sIn default (proxy) mode Spring adds async behavior by wrapping the bean in a proxy; the interceptor only fires when the call goes **through** that proxy. An internal call — `this.doAsync()` from another method of the same class — targets the raw object, bypasses the proxy, and therefore runs **synchronously** with no threading. The same trap affects `@Transactional` and `@Cacheable`. Workarounds: (1) move the `@Async` method to a **separate bean** and call it through that bean's proxy (cleanest); (2) **self-inject** the bean's own proxy (e.g. an `@Autowired` field referencing itself, or `ApplicationContext.getBean`) and call `self.doAsync()`; (3) obtain the current proxy via `AopContext.currentProxy()` (requires `exposeProxy=true`); (4) switch to `@EnableAsync(mode = AdviceMode.ASPECTJ)`, which weaves the advice into the bytecode so even internal and non-public calls become async. Prefer option 1 for clarity.
code
java · 19 lines@Service
public class ReportService {
// BROKEN: internal call bypasses the proxy -> runs synchronously
public void handleWrong() {
this.generate(); // NOT async
}
// FIX via self-injection
@Autowired
private ReportService self; // Spring injects the proxy, not 'this'
public void handleRight() {
self.generate(); // goes through proxy -> async
}
@Async
public void generate() { /* heavy work on a background thread */ }
}go deeper
Recognizes that calling an @Async method from the same class runs synchronously.
Explains the proxy-boundary cause and can apply the separate-bean fix.
Lists multiple workarounds with trade-offs and generalizes to other proxy-based annotations.
Chooses proxy vs AspectJ deliberately, weighing weaving setup cost against eliminating an entire class of self-invocation bugs, and structures beans to keep proxied concerns on collaborators.
**Why it happens.** With `@EnableAsync` in its default `AdviceMode.PROXY`, Spring doesn't modify your class. It creates a **proxy** around the bean (JDK dynamic proxy if there's an interface, else a CGLIB subclass). The async interception logic lives in the proxy. When another bean calls your method, it holds a reference to the *proxy*, so the interceptor runs and dispatches to the executor. But when a method inside the class calls a sibling `@Async` method as `this.doWork()` (or an unqualified `doWork()`), the call goes straight to the **real object's** method — `this` is the raw target, not the proxy. The proxy never sees the call, no interceptor fires, and the method runs **inline, synchronously, on the caller's thread**. It looks annotated but behaves as if it isn't. This is identical to the self-invocation problem for `@Transactional`, `@Cacheable`, `@Retryable` — anything proxy-based. **Symptoms.** The method 'works' but never runs on a background thread; thread-name prefixes show the caller's thread; parallelism you expected doesn't materialize; a request that should return fast still blocks on the slow work. **Workarounds (default proxy mode):** 1. **Extract to another bean (recommended).** Put the `@Async` method on a different `@Service`/`@Component` and inject it. The call now crosses a proxy boundary: ```java @Service class Worker { @Async void run() { ... } } @Service class Orchestrator { private final Worker worker; Orchestrator(Worker worker) { this.worker = worker; } void handle() { worker.run(); } // goes through Worker's proxy -> async } ``` 2. **Self-injection.** Inject the bean into itself so you hold your own proxy: ```java @Service class Reports { @Autowired private Reports self; // Spring injects the proxy void handle() { self.build(); } // async @Async void build() { ... } } ``` (Constructor self-injection causes a cycle; use a setter/field, or `@Lazy`, or fetch from `ApplicationContext`.) 3. **AopContext.currentProxy().** With `@EnableAsync(... )` plus proxy exposure enabled (`@EnableAspectJAutoProxy(exposeProxy = true)` or the corresponding setting), call `((Reports) AopContext.currentProxy()).build();`. Works but couples code to Spring AOP internals. 4. **AspectJ mode.** `@EnableAsync(mode = AdviceMode.ASPECTJ)` weaves the async advice directly into the class bytecode (compile-time or load-time weaving), so there is no proxy indirection — internal `this` calls and even non-public methods become async. Requires `spring-aspects` and the AspectJ weaver; heavier to set up but eliminates the pitfall entirely. **Related constraints in proxy mode.** The method must be **public** (private/package methods aren't proxied). Final classes/methods can't be CGLIB-proxied. These are all facets of the same proxy model. **When to worry.** Any time you refactor a slow method into an `@Async` one and keep calling it from the same class. The safest default is to keep proxied concerns on collaborator beans so the boundary is always crossed.
- Which other Spring annotations suffer the identical self-invocation limitation?Any proxy-based advice: @Transactional, @Cacheable/@CacheEvict, @Retryable, @Validated method validation, @PreAuthorize on methods. All only apply when the call crosses the proxy boundary.
- Why does AspectJ mode not have this problem?AspectJ weaves the advice into the class's own bytecode rather than wrapping it in a proxy, so the behavior is part of the method itself. Even a this-call or a non-public method triggers the async advice.
saying these in an interview costs you the question
- Insisting a this.asyncMethod() call still runs on another thread
- Trying to fix self-invocation by making the method final or static
- Not knowing @Transactional/@Cacheable share the same pitfall