O cenário é comum. Um serviço tem um método público anotado com @Async, a aplicação tem @EnableAsync, e outro método da mesma classe chama esse método com this.asyncWork(), ou só com asyncWork(). O código compila, o teste fica verde e a revisão aprova. O trabalho, porém, roda na thread de quem chamou. Nenhum executor participou.
O retorno é um CompletableFuture e o get() responde na hora, então tudo parece assíncrono. Só que o future completou na main.
No fim do texto você vai ter uma fixture Maven com três testes, que mostra a thread do trabalho em cada caso: main quando a chamada passa pelo this, task-1 quando passa pelo proxy e task-2 depois de extrair o método para outro bean. Também vai ver o que essa correção resolve e o que ela não resolve.
O que rodou e com quais versões
A fixture rodou em 23/10/2026 com Spring Boot 4.1.2 (a GA atual), Spring Framework 7.0.10, Temurin 25.0.4 LTS e Maven 3.9.12. O JUnit Jupiter vem do BOM do Boot, sem versão fixada no POM, e nesta execução foi a 6.0.3. O Surefire está fixado em 3.6.0. A página de requisitos do Spring Boot diz que o 4.1.2 exige pelo menos Java 17, é compatível até o Java 27 e precisa do Spring Framework 7.0.10 ou superior. O JDK 25 está dentro da matriz.
Não há servidor web nem container. O contexto sobe com webEnvironment = NONE. O mvn -q test passou, e o relatório do Surefire registra tests=3 failures=0 errors=0 skipped=0.
Sobre dependências: spring-boot-starter e @EnableAsync bastam. O spring-context já depende de spring-aop 7.0.10, e o proxy do @Async vem daí. Não procure um spring-boot-starter-aop para a linha 4.1, porque ele não tem release GA 4.1.x no Maven Central e a resolução falha. O spring-boot-starter-aspectj existe na 4.1.2, mas serve ao auto-proxy de aspectos AspectJ e não é necessário para @Async no modo padrão.
O contrato: o proxy só intercepta o que passa por ele
No modo padrão, o @Async é implementado com Spring AOP. O bean que o contêiner injeta não é a sua classe, e sim um proxy que intercepta a chamada, entrega o trabalho a um executor e devolve um Future. Esse desvio só acontece quando a chamada entra pelo proxy.
A documentação de proxies do Spring Framework descreve o que acontece depois que a chamada chega ao objeto real:
> "once the call has finally reached the target object (the SimplePojo reference in this case), any method calls that it may make on itself, such as this.bar() or this.foo(), are going to be invoked against the this reference, and not the proxy."
E tira a consequência logo em seguida:
> "self invocation via an explicit or implicit this reference will bypass the advice."
Repare em "explicit or implicit". Chamar asyncWork() sem o this. dá no mesmo.
O javadoc de @EnableAsync fala especificamente do @Async. O atributo mode tem default AdviceMode.PROXY, e a documentação desse atributo diz:
> "Local calls within the same class cannot get intercepted that way; an Async annotation on such a method within a local call will be ignored since Spring's interceptor does not even kick in for such a runtime scenario."
Esse é o escopo documentado do modo proxy, e não um bug do Boot 4.1. O modo AdviceMode.ASPECTJ interceptaria chamadas locais, mas depende de weaving AspectJ. Não é o default do Boot 4.1 e muda todo o modelo de build, por isso fica fora daqui.
Dois detalhes do proxy ajudam a ler a fixture:
- CGLIB é o default no Boot. A página de AOP do Spring Boot diz que a auto-configuração usa proxies CGLib e que
spring.aop.proxy-target-class=falsemuda para proxies JDK. Por isso a classe concreta, sem interface, recebe uma subclasse gerada. - Use métodos públicos. Um proxy por subclasse não intercepta métodos
private. A mesma página de proxying avisa que métodosfinalnão podem ser interceptados, porque não podem ser sobrescritos. Um@Asyncem método privado nunca é visto pelo interceptor, nem quando a chamada vem de fora.
A regra de revisão sai direto disso: com @EnableAsync no modo PROXY, this.metodoAsync() é bug.
A fixture
Tudo está no pacote com.devdojo.asyncdemo.
POM
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
</plugins>
</build>
Aplicação
package com.devdojo.asyncdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
O serviço que chama a si mesmo
package com.devdojo.asyncdemo;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class SelfInvokingService {
public String callerThreadViaThis() {
try {
return this.asyncWork().get(Duration.ofSeconds(2).toMillis(), TimeUnit.MILLISECONDS);
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
public String callerThreadViaBean(SelfInvokingService proxy) {
try {
return proxy.asyncWork().get(Duration.ofSeconds(2).toMillis(), TimeUnit.MILLISECONDS);
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
@Async
public CompletableFuture<String> asyncWork() {
return CompletableFuture.completedFuture(Thread.currentThread().getName());
}
}
O método @Async devolve o nome da thread em que rodou, e quem chama espera com get e timeout de 2 segundos. Não há Thread.sleep. A prova é o nome da thread, não o relógio.
Os dois chamadores são parecidos, mas não iguais. callerThreadViaThis() chama o método pelo this. callerThreadViaBean(proxy) chama pela referência que recebe como argumento, e o teste passa ali o bean injetado, que é o proxy. Os dois métodos são públicos e chamados de fora, pelo proxy. Como nenhum deles tem @Async, o proxy só repassa a chamada ao objeto real, e o que importa é a chamada seguinte, feita lá dentro.
Os três testes
package com.devdojo.asyncdemo;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class AsyncSelfInvocationTest {
@Autowired
SelfInvokingService selfInvokingService;
@Autowired
ExtractedCaller extractedCaller;
@Test
void selfInvocationRunsOnCallerThread() {
String caller = Thread.currentThread().getName();
String worker = this.selfInvokingService.callerThreadViaThis();
System.out.println("self-invocation caller=" + caller + " worker=" + worker);
assertThat(worker).isEqualTo(caller);
}
@Test
void beanInvocationRunsOnAsyncExecutor() {
assertThat(AopUtils.isAopProxy(this.selfInvokingService)).isTrue();
assertThat(AopUtils.isCglibProxy(this.selfInvokingService)).isTrue();
System.out.println("proxy class=" + this.selfInvokingService.getClass().getName());
String caller = Thread.currentThread().getName();
String worker = this.selfInvokingService.callerThreadViaBean(this.selfInvokingService);
System.out.println("bean-invocation caller=" + caller + " worker=" + worker);
assertThat(worker).isNotEqualTo(caller);
assertThat(worker).contains("task-");
}
@Test
void extractedBeanRunsOnAsyncExecutor() {
String caller = Thread.currentThread().getName();
String worker = this.extractedCaller.callerThreadViaExtractedBean();
System.out.println("extracted-bean caller=" + caller + " worker=" + worker);
assertThat(worker).isNotEqualTo(caller);
assertThat(worker).contains("task-");
}
}
O primeiro teste afirma o bug: a thread do trabalho é a mesma de quem chamou. Os outros dois afirmam a correção: a thread é outra e vem do executor.
O que os testes registraram
| cenário | caller | worker |
|---|---|---|
this.asyncWork() dentro do serviço | main | main |
proxy.asyncWork() pelo bean injetado | main | task-1 |
AsyncWorker extraído, chamado por ExtractedCaller | main | task-2 |
No segundo teste, AopUtils.isAopProxy e AopUtils.isCglibProxy deram true, e a classe do bean injetado é com.devdojo.asyncdemo.SelfInvokingService$$SpringCGLIB$$0. O proxy estava lá o tempo todo, inclusive no primeiro teste. Ele foi gerado para o @Async, está no contexto e fica sem uso quando a chamada sai pelo this.
As threads task-1 e task-2 vêm do ThreadPoolTaskExecutor que o Boot configura automaticamente para o @EnableAsync quando não há AsyncConfigurer definido. Nesta fixture, virtual threads estão desligadas. O prefixo task- só mostra que o interceptor rodou. Não é uma medida de desempenho.
A correção: tirar o @Async da classe que chama
A opção que a documentação de proxying trata como a melhor, e a menos invasiva, é não fazer self-invocation. O método assíncrono vai para outro bean:
package com.devdojo.asyncdemo;
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
@Component
public class AsyncWorker {
@Async
public CompletableFuture<String> asyncWork() {
return CompletableFuture.completedFuture(Thread.currentThread().getName());
}
}
Quem chama recebe esse bean por injeção:
package com.devdojo.asyncdemo;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Service;
@Service
public class ExtractedCaller {
private final AsyncWorker worker;
public ExtractedCaller(AsyncWorker worker) {
this.worker = worker;
}
public String callerThreadViaExtractedBean() {
try {
return this.worker.asyncWork().get(Duration.ofSeconds(2).toMillis(), TimeUnit.MILLISECONDS);
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}
O this.worker aqui não é problema, porque worker é o proxy de outro bean, e a chamada entra por ele. O terceiro teste confirma: caller=main worker=task-2.
A mesma página documenta outras duas saídas:
- Injetar o próprio bean e chamar o método pela referência injetada, nunca pelo
this. É uma alternativa documentada, e o segundo teste usa exatamente esse mecanismo, passando o proxy como argumento. Funciona, mas a classe passa a depender de si mesma. A DevDojo prefere a extração. AopContext.currentProxy(), que a documentação classifica como "highly discouraged". Fica só a menção.
Tipos de retorno e o que o javadoc não promete
O javadoc de @Async na versão 7.0.10 restringe o retorno:
> "the return type is constrained to either void or Future. In the latter case, you may declare the more specific CompletableFuture type"
Os tipos aceitos são void ou Future, com CompletableFuture como forma mais específica. ListenableFuture não aparece nessa lista.
O javadoc também não promete que o trabalho rode numa thread diferente da de quem chamou. Quando o AsyncExecutionInterceptor roda, ele envia a execução do método para o executor e devolve o controle imediatamente. A thread diferente é consequência de o interceptor ter rodado. No caso do this, ele não roda, o método executa como qualquer método Java e devolve um future já completo. A anotação compila e não faz nada, e nenhum aviso aparece.
O que a correção não faz
Extrair o método, ou chamar pelo proxy, só garante que o interceptor rode. Isso não:
- adiciona timeout à I/O que o método faz;
- substitui um
Executorpróprio, umAsyncConfigurerou o pool configurado emspring.task.execution.*; - muda a política de virtual threads ou platform threads, assunto do post sobre virtual threads e I/O bloqueante, que trata de quantas esperas cabem no sistema e não de quem intercepta a chamada;
- torna observável um
@Asyncque retornavoid. SemFuture.getou latch, o teste pode passar sem provar que alguma thread de trabalho existiu.
Também não é o mesmo problema dos listeners AFTER_COMMIT que não disparam em teste @Transactional. Lá o interceptor roda e o commit é que não acontece.
Próximo passo
- Liste as classes com
@Async(grep -rln "@Async" src/main/java) e, em cada uma, procure chamadas ao método anotado feitas de dentro da própria classe, comthis.ou sem receptor. Ogrepporthis.não encontra a chamada implícita. Esse caso você precisa ler. - Para cada método
@Asyncque importa, escreva o teste com as duas asserções:workerdiferente decallereworkercom o prefixo do executor (task-no executor auto-configurado do Boot; ajuste ao executor do projeto). Se o método retornavoid, mude paraCompletableFutureou exponha um latch. Sem isso, o teste não tem o que medir. - Se encontrar uma self-invocation, extraia o método para um
@Componentpróprio e deixe o teste mostrartask-Nonde antes apareciamain.