Spring
Threading
TaskExecutor
Concurrency
Java

Using Spring threading and TaskExecutor, how do I know when a thread is finished?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Spring's TaskExecutor runs tasks asynchronously but does not directly tell you when a task finishes. To know when a thread is finished, use @Async with a Future or CompletableFuture return type, use ListenableFuture (Spring 5) or CompletableFuture (Spring 6+) with callbacks, or submit Callable tasks to an AsyncTaskExecutor and call .get() on the returned Future. For waiting on multiple tasks, use CompletableFuture.allOf().

Method 1: @Async with CompletableFuture

The cleanest approach — annotate a method with @Async and return a CompletableFuture:

java
1@Service
2public class DataService {
3
4    @Async
5    public CompletableFuture<String> fetchData(String url) {
6        // Runs in a separate thread
7        String result = restTemplate.getForObject(url, String.class);
8        return CompletableFuture.completedFuture(result);
9    }
10}
11
12@RestController
13public class DataController {
14
15    @Autowired
16    private DataService dataService;
17
18    @GetMapping("/data")
19    public String getData() throws Exception {
20        CompletableFuture<String> future = dataService.fetchData("https://api.example.com");
21
22        // Block until done
23        String result = future.get();  // Waits for completion
24
25        // Or with timeout
26        String result2 = future.get(5, TimeUnit.SECONDS);
27
28        return result;
29    }
30}

Enable async support in your configuration:

java
1@Configuration
2@EnableAsync
3public class AsyncConfig {
4
5    @Bean
6    public Executor taskExecutor() {
7        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
8        executor.setCorePoolSize(5);
9        executor.setMaxPoolSize(10);
10        executor.setQueueCapacity(25);
11        executor.setThreadNamePrefix("async-");
12        executor.initialize();
13        return executor;
14    }
15}

Method 2: CompletableFuture Callbacks

Use callbacks instead of blocking with .get():

java
1@Service
2public class ProcessingService {
3
4    @Async
5    public CompletableFuture<Integer> processItem(int id) {
6        int result = heavyComputation(id);
7        return CompletableFuture.completedFuture(result);
8    }
9}
10
11// Usage with callbacks
12CompletableFuture<Integer> future = processingService.processItem(42);
13
14future.thenAccept(result -> {
15    System.out.println("Task completed with result: " + result);
16});
17
18future.exceptionally(ex -> {
19    System.err.println("Task failed: " + ex.getMessage());
20    return null;
21});

Method 3: Waiting for Multiple Tasks

java
1@Service
2public class BatchService {
3
4    @Autowired
5    private DataService dataService;
6
7    public List<String> fetchAll(List<String> urls) throws Exception {
8        // Launch all tasks concurrently
9        List<CompletableFuture<String>> futures = urls.stream()
10            .map(dataService::fetchData)
11            .collect(Collectors.toList());
12
13        // Wait for all to complete
14        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
15
16        // Collect results
17        return futures.stream()
18            .map(CompletableFuture::join)
19            .collect(Collectors.toList());
20    }
21}

Method 4: Submit Callable to TaskExecutor

For lower-level control without @Async:

java
1@Service
2public class TaskService {
3
4    @Autowired
5    private AsyncTaskExecutor taskExecutor;
6
7    public Future<String> submitTask(String input) {
8        return taskExecutor.submit(() -> {
9            Thread.sleep(2000);  // Simulate work
10            return "Processed: " + input;
11        });
12    }
13
14    public void runAndWait() throws Exception {
15        Future<String> future = submitTask("data");
16
17        // Poll for completion
18        while (!future.isDone()) {
19            System.out.println("Waiting...");
20            Thread.sleep(500);
21        }
22
23        String result = future.get();
24        System.out.println(result);
25    }
26}

Method 5: CountDownLatch for Fire-and-Forget Tasks

When you need to wait for void tasks that do not return a value:

java
1@Service
2public class NotificationService {
3
4    @Autowired
5    private TaskExecutor taskExecutor;
6
7    public void sendNotifications(List<String> recipients) throws InterruptedException {
8        CountDownLatch latch = new CountDownLatch(recipients.size());
9
10        for (String recipient : recipients) {
11            taskExecutor.execute(() -> {
12                try {
13                    sendEmail(recipient);
14                } finally {
15                    latch.countDown();
16                }
17            });
18        }
19
20        // Wait for all notifications to complete (with timeout)
21        boolean completed = latch.await(30, TimeUnit.SECONDS);
22        if (!completed) {
23            System.err.println("Some notifications timed out");
24        }
25    }
26}

Method 6: TaskExecutor with TaskDecorator

Add completion logging via a decorator:

java
1@Configuration
2@EnableAsync
3public class AsyncConfig {
4
5    @Bean
6    public Executor taskExecutor() {
7        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
8        executor.setCorePoolSize(5);
9        executor.setMaxPoolSize(10);
10        executor.setTaskDecorator(runnable -> () -> {
11            long start = System.currentTimeMillis();
12            try {
13                runnable.run();
14            } finally {
15                long elapsed = System.currentTimeMillis() - start;
16                System.out.println("Task finished in " + elapsed + "ms");
17            }
18        });
19        executor.initialize();
20        return executor;
21    }
22}

Common Pitfalls

  • Calling an @Async method from within the same class: Spring's @Async works through proxies. If you call an async method from another method in the same bean, the proxy is bypassed and the method runs synchronously. Extract the async method into a separate @Service bean.
  • Not enabling @EnableAsync in the configuration: Without @EnableAsync, the @Async annotation is ignored and all methods run synchronously on the caller's thread. Add @EnableAsync to a @Configuration class.
  • Ignoring the returned Future from an @Async method: If an @Async method returns CompletableFuture but the caller ignores it, exceptions thrown in the async method are silently swallowed. Always handle the Future or configure an AsyncUncaughtExceptionHandler.
  • Blocking the main thread with future.get() without a timeout: Calling future.get() blocks indefinitely if the task never completes. Always use future.get(timeout, TimeUnit.SECONDS) or use non-blocking callbacks with thenAccept().
  • Not configuring a custom ThreadPoolTaskExecutor: Without a custom executor, Spring uses SimpleAsyncTaskExecutor, which creates a new thread for every task with no pooling or queue limit. This can exhaust system resources under load. Always define a ThreadPoolTaskExecutor bean.

Summary

  • Return CompletableFuture from @Async methods to track completion
  • Use future.get() to block until done, or thenAccept() for non-blocking callbacks
  • Use CompletableFuture.allOf() to wait for multiple concurrent tasks
  • Use CountDownLatch for fire-and-forget void tasks that need synchronization
  • Always configure a ThreadPoolTaskExecutor with bounded pool size and queue capacity

Course illustration
Course illustration

All Rights Reserved.