Every @Async behaviour that surprises people, asserted by a test and captured to docs/output/: the self-invocation trap, what CGLIB cannot override, the IllegalArgumentException a plain return type throws, the unbounded queue that makes max-size decoration, spring.task.execution.propagate-context (new in Boot 4.1.0), the two Executor beans that leave @Async on an unpooled SimpleAsyncTaskExecutor, and JEP 491 measured on JDK 21 against JDK 25.
2.6 KiB
prev: What @Async actually does · README · next: What the proxy cannot see
2. The self-invocation trap
The proxy wraps the bean. It does not replace this.
@Async
public CompletableFuture<String> annotated() { ... }
public CompletableFuture<String> viaSelfInvocation() {
return annotated(); // this.annotated() — the proxy is not involved
}
When another bean calls service.annotated(), it holds a reference to the proxy, so the call
is intercepted. When viaSelfInvocation() calls annotated(), the compiler emits
this.annotated(), and this inside the method body is the target object, not the proxy.
There is no interception, so the method runs inline and the CompletableFuture you get back is
already complete.
Measured, in docs/output/self-invocation.txt:
service.annotated() : task-5 (virtual=false)
service.viaSelfInvocation() : main (virtual=false)
service.viaSelfReference() : task-6 (virtual=false)
Nothing is logged. The method returns a valid future with the correct value in it. The only symptom is that the caller waited.
The fixes, in the order you should prefer them
Move the method to another bean. If work is asynchronous, it belongs behind a boundary, and the boundary is a good place for a class. This is the fix that survives a refactor.
Go back out through the proxy on purpose. SelfInvocationService does this with an
ObjectProvider<SelfInvocationService>:
public CompletableFuture<String> viaSelfReference() {
return self.getObject().annotated();
}
ObjectProvider is lazy, so there is no circular dependency to resolve at construction time.
Injecting SelfInvocationService directly also works — Spring special-cases self-references —
but it looks like a bug to every reviewer who has not read this chapter.
@EnableAsync(mode = AdviceMode.ASPECTJ) removes the limitation entirely, because
load-time weaving rewrites the method itself rather than wrapping the object. It also adds an
agent to your startup and a weaving configuration to your build. Very few applications should
pay that.
Why this keeps happening
The trap is not that people do not know about proxies. It is that the calling code often did not
start out in the same class. A method gets extracted, a controller's logic moves into the
service that already had the @Async method, and one day a call that used to arrive from outside
arrives from inside. Nothing fails. The throughput just changes.