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.
64 lines
2.6 KiB
Markdown
64 lines
2.6 KiB
Markdown
prev: [What `@Async` actually does](01-what-async-actually-does.md) · [README](../README.md) · next: [What the proxy cannot see](03-what-the-proxy-cannot-see.md)
|
|
|
|
# 2. The self-invocation trap
|
|
|
|
The proxy wraps the bean. It does not replace `this`.
|
|
|
|
```java
|
|
@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`](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>`:
|
|
|
|
```java
|
|
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.
|
|
|
|
next: [What the proxy cannot see](03-what-the-proxy-cannot-see.md)
|