All articles

// Knowledge.log — 技術記事

@Async on this still runs on the caller thread in Spring Boot 4.1

Spring Boot 4.1.2: this.asyncWork() stays on main; the proxy call runs on task-1. Three Maven tests, no web server, no container.

You've probably seen this setup. A service has a public method annotated with @Async, the application has @EnableAsync, and another method in the same class calls it with this.asyncWork(), or just asyncWork(). It compiles, the test is green, and the review gets approved. But the work runs on the caller's thread, and no executor ever touches it.

The method returns a CompletableFuture and get() comes back right away, so everything looks asynchronous. The future just happened to complete on main.

By the end you'll have a Maven fixture with three tests that report which thread did the work in each case: main when the call goes through this, task-1 when it goes through the proxy, and task-2 after the method moves to a separate bean. You'll also see what that fix solves and what it doesn't.

What ran, and on which versions

The fixture ran on October 23, 2026 with Spring Boot 4.1.2 (the current GA), Spring Framework 7.0.10, Temurin 25.0.4 LTS and Maven 3.9.12. JUnit Jupiter comes from the Boot BOM with no version pinned in the POM, and this run resolved 6.0.3. Surefire is pinned at 3.6.0. The Spring Boot system requirements page says 4.1.2 needs at least Java 17, works up to and including Java 27, and requires Spring Framework 7.0.10 or later. JDK 25 is inside that range.

There's no web server and no container. The context starts with webEnvironment = NONE. mvn -q test passed, and the Surefire report records tests=3 failures=0 errors=0 skipped=0.

On dependencies, spring-boot-starter plus @EnableAsync is all you need. spring-context already depends on spring-aop 7.0.10, and that's where the @Async proxy comes from. Don't go looking for spring-boot-starter-aop on the 4.1 line. It has no 4.1.x GA release on Maven Central, so resolution fails. spring-boot-starter-aspectj does exist at 4.1.2, but it's for auto-proxying AspectJ aspects, and you don't need it for @Async in the default mode.

The contract: the proxy only intercepts what goes through it

In the default mode, @Async is built on Spring AOP. The bean the container injects isn't your class. It's a proxy that intercepts the call, hands the work to an executor and returns a Future. That detour only happens when the call comes in through the proxy.

The Spring Framework proxying documentation describes what happens once the call reaches the real object:

> "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."

It states the consequence right after:

> "self invocation via an explicit or implicit this reference will bypass the advice."

Note the "explicit or implicit" part. Calling asyncWork() without the this. prefix is exactly the same thing.

The @EnableAsync javadoc covers @Async specifically. The mode attribute defaults to AdviceMode.PROXY, and its documentation says:

> "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."

This is how proxy mode is documented to behave. It isn't a Boot 4.1 bug. AdviceMode.ASPECTJ would intercept local calls, but it needs AspectJ weaving. That isn't the Boot 4.1 default and it changes your whole build model, so it's out of scope here.

Two details about the proxy will help you read the fixture:

  • CGLIB is the default in Boot. The Spring Boot AOP page says auto-configuration sets up CGLib proxies and that spring.aop.proxy-target-class=false switches to JDK proxies. That's why a concrete class with no interface gets a generated subclass.
  • Use public methods. A subclass proxy can't intercept private methods, and the same proxying page warns that final methods can't be advised because they can't be overridden. An @Async on a private method is never seen by the interceptor, even when the call comes from outside.

That gives you a review rule: with @EnableAsync in PROXY mode, this.someAsyncMethod() is a bug.

The fixture

Everything lives in the com.devdojo.asyncdemo package.

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>

Application

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);
    }
}

The service that calls itself

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());
    }
}

The @Async method returns the name of the thread it ran on, and the caller waits with get and a 2-second timeout. There's no Thread.sleep. The proof is the thread name, not the clock.

The two callers look alike, but they differ in one place. callerThreadViaThis() calls the method through this. callerThreadViaBean(proxy) calls it through the reference passed in as an argument, and the test passes the injected bean there, which is the proxy. Both methods are public and get called from outside, through the proxy. Neither has @Async, so the proxy just forwards the call to the real object. What matters is the next call, the one made from inside.

The three tests

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-");
    }
}

The first test asserts the bug: the worker thread is the caller's thread. The other two assert the fix: the thread is different, and it comes from the executor.

What the tests recorded

scenariocallerworker
this.asyncWork() inside the servicemainmain
proxy.asyncWork() through the injected beanmaintask-1
extracted AsyncWorker, called by ExtractedCallermaintask-2

In the second test, AopUtils.isAopProxy and AopUtils.isCglibProxy both returned true, and the injected bean's class is com.devdojo.asyncdemo.SelfInvokingService$$SpringCGLIB$$0. The proxy was there all along, including in the first test. It was generated for @Async and registered in the context, and it never gets used when the call goes out through this.

The task-1 and task-2 threads come from the ThreadPoolTaskExecutor that Boot auto-configures for @EnableAsync when no AsyncConfigurer is defined. Virtual threads are off in this fixture. The task- prefix only shows that the interceptor ran. It says nothing about performance.

The fix: move @Async out of the calling class

The proxying documentation calls this the best and least invasive option: don't self-invoke. Move the async method into another 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());
    }
}

The caller gets that bean through injection:

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);
        }
    }
}

The this.worker here is fine, because worker is another bean's proxy and the call goes in through it. The third test confirms it: caller=main worker=task-2.

The same page documents two other ways out:

  • Inject the bean into itself and call the method through the injected reference, never through this. It's a documented option, and the second test uses exactly that mechanism by passing the proxy in as an argument. It works, but now the class depends on itself. DevDojo prefers extraction.
  • AopContext.currentProxy(), which the docs call "highly discouraged". It gets a mention here and nothing more.

Return types, and what the javadoc doesn't promise

The @Async javadoc for 7.0.10 restricts the return type:

> "the return type is constrained to either void or Future. In the latter case, you may declare the more specific CompletableFuture type"

So you get void or Future, with CompletableFuture as the more specific option. ListenableFuture isn't on that list.

The javadoc also never promises that the work runs on a different thread from the caller. When AsyncExecutionInterceptor runs, it submits the method call to the executor and returns control right away. The different thread follows from the interceptor having run. In the this case it doesn't run, so the method executes like any other Java method and returns a future that's already complete. The annotation compiles, does nothing, and nothing warns you. It's about as quiet as a failure gets.

What the fix doesn't do

Extracting the method, or calling through the proxy, only guarantees that the interceptor runs. It does not:

  • add a timeout to the method's I/O;
  • replace a custom Executor, an AsyncConfigurer, or the pool configured under spring.task.execution.*;
  • change your virtual-thread or platform-thread policy. That's the subject of the post on virtual threads and blocking I/O, which is about how many waits the system can hold, not about who intercepts the call;
  • make a void @Async observable. Without Future.get or a latch, the test can pass without proving any worker thread ever existed.

It's also a different problem from AFTER_COMMIT listeners that don't fire in a @Transactional test. There, the interceptor runs and the commit is what never happens.

Next step

  1. List the classes that use @Async (grep -rln "@Async" src/main/java). In each one, look for calls to the annotated method from inside the same class, with this. or with no receiver at all. Grepping for this. won't catch the implicit call, so you'll have to read that part yourself.
  2. For every @Async method that matters, write the test with both assertions: worker differs from caller, and worker carries the executor's prefix (task- for Boot's auto-configured executor; adjust it for your project's executor). If the method returns void, change it to CompletableFuture or expose a latch. Otherwise the test has nothing to check.
  3. If you find a self-invocation, extract the method into its own @Component and let the test show task-N where it used to show main.
javaspring-boot

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles