Skip to content
Chapter 16 · Spring Boot

@Async Part 1: Thread Pools and @Async ​

Every request so far ran start to finish on one thread, and the client waited until all the work was done. @Async moves a method onto another thread, so the caller does not wait.

This chapter builds on three Java topics: creating threads and their lifecycle, thread pools with ThreadPoolExecutor, and Future/CompletableFuture. Here is a short recap of the thread pool part.

Thread pools in brief ​

A thread pool is a set of threads (workers) that wait for tasks. When a thread finishes a task, it goes back to the pool and picks the next one: threads are reused instead of created for every task.

Three numbers control a pool:

  • min (core) pool size: threads kept ready
  • max pool size: the most threads the pool may ever have
  • queue size: how many tasks may wait
java
ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
        minPoolSize, maxPoolSize, 1, TimeUnit.HOURS,     // keep-alive for extra threads
        new ArrayBlockingQueue<>(queueSize));

Take min = 2, max = 4, queue = 3, and tasks that each take a while:

TaskWhat happens
1, 2Thread 1 and thread 2 take them. Both busy.
3, 4, 5No free thread, so they wait in the queue. The queue is now full.
6No free thread and no room in the queue, but only 2 of max 4 threads exist: thread 3 is created for task 6.
7Same: thread 4 is created.
84 threads busy, queue full, max reached: task 8 is rejected.
laterThread 1 finishes task 1 and takes task 3 from the front of the queue.

Note the order: new threads beyond the core size are created only once the queue is full.

@Async ​

@Async marks a method to run in a new thread, so the calling thread does not wait for it.

First, enable it on the application class:

java
@SpringBootApplication
@EnableAsync
public class SpringbootApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringbootApplication.class, args);
    }
}

@EnableAsync makes Spring create the beans that async processing needs (interceptors and so on). Without it, @Async is ignored.

java
@RestController
@RequestMapping("/api")
public class UserController {

    @Autowired
    UserService userService;

    @GetMapping("/getuser")
    public String getUserMethod() {
        System.out.println("inside getUserMethod: " + Thread.currentThread().getName());
        userService.asyncMethodTest();
        return "done";
    }
}
java
@Component
public class UserService {

    @Async
    public void asyncMethodTest() {
        System.out.println("inside asyncMethodTest: " + Thread.currentThread().getName());
    }
}

Calling the API several times:

inside getUserMethod: http-nio-8080-exec-1
inside asyncMethodTest: task-1
inside getUserMethod: http-nio-8080-exec-2
inside asyncMethodTest: task-2
...
inside getUserMethod: http-nio-8080-exec-8
inside asyncMethodTest: task-8
inside getUserMethod: http-nio-8080-exec-9
inside asyncMethodTest: task-1

The async method runs in a different thread from the request. And after task-8, the names start again from task-1: threads are being reused, so there is a pool behind this.

Which executor does @Async use? ​

You will often read: "Spring Boot uses SimpleAsyncTaskExecutor by default, which creates a new thread every time." That is not the full answer; the output above already contradicts it.

Spring's AsyncExecutionInterceptor first looks for a default executor, and only if it finds none does it fall back to SimpleAsyncTaskExecutor:

java
protected Executor getDefaultExecutor(BeanFactory beanFactory) {
    Executor defaultExecutor = super.getDefaultExecutor(beanFactory);
    return (defaultExecutor != null ? defaultExecutor : new SimpleAsyncTaskExecutor());
}

Which executor it finds depends on what you have defined. Four cases.

Case 1: nothing defined ​

At startup, Spring Boot sees no executor bean, so it creates its own ThreadPoolTaskExecutor, and @Async uses it:

SettingValue
core pool size8
max pool sizeInteger.MAX_VALUE
queue capacityInteger.MAX_VALUE
keep-alive60 seconds

That is why the threads were task-1 to task-8, then reused.

ThreadPoolTaskExecutor is Spring's wrapper around Java's ThreadPoolExecutor: internally it creates a plain ThreadPoolExecutor, with more readable setters.

Not recommended, because of the huge queue:

  1. Under-used threads. Extra threads are created only when the queue is full, and a queue of 2 billion is practically never full. The pool stays at 8 threads even when the machine could run more.
  2. High latency. Under load, tasks pile up in the queue waiting for one of the 8 threads.
  3. High memory use. Every waiting task takes memory, and nothing limits how many wait.
  4. Thread exhaustion, in theory: if the queue ever did fill, new threads would be created up to Integer.MAX_VALUE, far more than the server can handle.
java
@Configuration
public class AppConfig {

    @Bean(name = "myThreadPoolExecutor")
    public Executor taskPoolExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(3);
        executor.setThreadNamePrefix("MyThread-");
        executor.initialize();
        return executor;
    }
}

At startup Spring Boot sees an executor bean, so it does not create its own. @Async without a name picks up yours as the default, and @Async("myThreadPoolExecutor") does the same thing explicitly:

inside getUserMethod: http-nio-8080-exec-1
inside asyncMethodTest: MyThread-1
inside getUserMethod: http-nio-8080-exec-2
inside asyncMethodTest: MyThread-2
inside getUserMethod: http-nio-8080-exec-3
inside asyncMethodTest: MyThread-1

Put a Thread.sleep in the async method and call the API 8 times quickly, and the pool behaves exactly as in the table at the top: threads 1 and 2, three tasks queued, threads 3 and 4 created, and the 8th task rejected with TaskRejectedException. You chose the numbers for your system, and the pool respects them. This fixes all the problems of case 1.

Case 3: your own plain Java ThreadPoolExecutor ​

java
@Configuration
public class AppConfig {

    @Bean(name = "myThreadPoolExecutor")
    public Executor taskExecutor() {
        return new ThreadPoolExecutor(2, 4, 1, TimeUnit.HOURS,
                new ArrayBlockingQueue<>(3), new CustomThreadFactory());
    }
}

class CustomThreadFactory implements ThreadFactory {
    private final AtomicInteger threadNo = new AtomicInteger(1);

    public Thread newThread(Runnable runnable) {
        Thread th = new Thread(runnable);
        th.setName("MyThread-" + threadNo.getAndIncrement());
        return th;
    }
}

Now, with a plain @Async:

inside asyncMethodTest: SimpleAsyncTaskExecutor-1
inside asyncMethodTest: SimpleAsyncTaskExecutor-2
...
inside asyncMethodTest: SimpleAsyncTaskExecutor-9
inside asyncMethodTest: SimpleAsyncTaskExecutor-10

Spring Boot sees an executor bean, so it does not create its default pool. But the lookup for a default wants a Spring TaskExecutor (or a bean named taskExecutor), and a plain Java ThreadPoolExecutor named myThreadPoolExecutor is neither. No default is found, so SimpleAsyncTaskExecutor is used: a new thread for every call, numbers climbing forever.

SimpleAsyncTaskExecutor is not recommended:

  1. Thread exhaustion: a new thread for every async call, with no limit.
  2. Creation overhead: threads are never reused, so creating and destroying them costs time.
  3. High memory use: every running thread needs memory.

The fix: with a plain Java executor, always name it in the annotation:

java
@Async("myThreadPoolExecutor")
public void asyncMethodTest() { ... }

Output: MyThread-1, MyThread-2, MyThread-1, ...

Case 4: AsyncConfigurer (one default for everyone) ​

Cases 1 to 3 are confusing: sometimes the name is optional, sometimes required. To make your executor the default whatever @Async looks like, implement AsyncConfigurer and override getAsyncExecutor():

java
@Configuration
public class AppConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        return new ThreadPoolExecutor(2, 4, 1, TimeUnit.HOURS,
                new ArrayBlockingQueue<>(3), new CustomThreadFactory());
    }
}

Now a plain @Async always uses this executor, whether it is a plain Java ThreadPoolExecutor or a ThreadPoolTaskExecutor (for the latter, call initialize() before returning it). This is common in industry.

getAsyncExecutor() is not an @Bean method, so you may see code that caches the executor in a field inside a synchronized method, to be sure only one pool exists. Spring already calls getAsyncExecutor() only once and keeps the result, so that is not required.

Next: Part 2 covers where @Async silently fails, how it combines with @Transactional, returning results, and handling exceptions.

Check yourself ​

  1. In a pool with min 2, max 4, queue 3, all tasks slow, what happens to tasks 3, 6 and 8?
  2. What does @EnableAsync do?
  3. The output shows task-1 … task-8 and then task-1 again. What does that tell you?
  4. What does Spring look for before falling back to SimpleAsyncTaskExecutor?
  5. What are the settings of Spring Boot's default executor, and why is it not recommended?
  6. What is the relationship between ThreadPoolTaskExecutor and ThreadPoolExecutor?
  7. You define a ThreadPoolTaskExecutor bean named myThreadPoolExecutor. Which executor does a plain @Async use?
  8. You define a plain Java ThreadPoolExecutor bean instead. Which executor does a plain @Async use, and how do you fix it?
  9. Give three problems with SimpleAsyncTaskExecutor.
  10. How does AsyncConfigurer remove the confusion between the cases?
Answers
  1. Task 3 waits in the queue. Task 6 finds the queue full, so a third thread is created for it. Task 8 finds 4 threads busy and the queue full, so it is rejected.
  2. It makes Spring create the beans that process @Async; without it @Async does nothing.
  3. Threads are being reused: a pool (Spring Boot's default, with 8 core threads) is behind it.
  4. A default executor: a unique TaskExecutor bean, or an executor bean named taskExecutor.
  5. Core 8, max Integer.MAX_VALUE, queue Integer.MAX_VALUE. The queue never fills, so threads stay at 8 (under-use, latency), waiting tasks use unbounded memory, and in theory threads could grow without limit.
  6. ThreadPoolTaskExecutor is Spring's wrapper; it creates a ThreadPoolExecutor inside.
  7. Yours (MyThread-...), with or without the name in @Async.
  8. SimpleAsyncTaskExecutor, a new thread per call. Write @Async("myThreadPoolExecutor").
  9. Thread exhaustion, thread creation overhead, high memory use.
  10. Its getAsyncExecutor() becomes the default for every @Async, whatever kind of executor it returns.