Concurrency - Part 3
Modern Java applications often need to perform multiple tasks asynchronously without blocking threads or slowing down the system. One of the essential tools introduced in Java 8 to address this need is the CompletableFuture.
This blog post focuses on how CompletableFuture makes asynchronous programming easier, compared with JavaScript’s Promise concept, and demonstrates its real-world use in a Java application.
How CompletableFuture Compares to JavaScript's Promise
If you're familiar with JavaScript Promises, understanding CompletableFuture becomes much easier:
| Aspect | JavaScript Promise | Java CompletableFuture |
|---|---|---|
| Creating Async Operation | new Promise((resolve, reject) => {...}) |
CompletableFuture.supplyAsync(() -> {...}) |
| Handling Result | .then(value => {...}) |
.thenAccept(result -> {...}) |
| Handling Errors | .catch(error => {...}) |
.exceptionally(error -> {...}) |
| Execution Context | Event loop, non-blocking | Separate thread pool (e.g., ForkJoinPool or ExecutorService) |
| Blocking Behavior | Non-blocking by design | Supports both non-blocking and blocking (get()) |
✅ CompletableFuture brings Promise-style async composition into Java, with even more control over threading.
Basic Examples
Let's look at two simple cases: runAsync and supplyAsync. Use runAsync when you don't need a result, and supplyAsync when you do.
Example 1: runAsync — Task without a return value
import java.util.concurrent.CompletableFuture;
public class RunAsyncExample {
public static void main(String[] args) {
System.out.println("Main thread: " + Thread.currentThread().getName());
CompletableFuture<Void> future = CompletableFuture.runAsync(() ->
System.out.println("Running async task on: " + Thread.currentThread().getName()));
future.join();
}
}
Example 2: supplyAsync — Task with a return value
import java.util.concurrent.CompletableFuture;
public class SupplyAsyncExample {
public static void main(String[] args) {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() ->
"Hello from " + Thread.currentThread().getName());
String result = future.join();
System.out.println("Result: " + result);
}
}
Real-World Usage: Asynchronous File Import
Let's see a real-world example: handling asynchronous file imports without blocking HTTP request threads.
Note: The async behavior here is achieved using
@Async, which runs on Spring's default async executor (likeSimpleAsyncTaskExecutor) — not onForkJoinPool.commonPool(). The method returnsCompletableFuture<String>, meaning it behaves similarly tosupplyAsync, where a result (jobId) is asynchronously produced and returned.
Service Layer: ImportService
@Async
public CompletableFuture<String> importFile(String jobId, String filePath) {
try {
HSSFWorkbook workbook = new HSSFWorkbook(new FileInputStream(filePath));
HSSFSheet sheet = workbook.getSheetAt(0);
if (sheet.getPhysicalNumberOfRows() > 1) {
processSheetRows(sheet, jobId);
}
return CompletableFuture.completedFuture(jobId);
} catch (Exception e) {
throw new JobException(jobId, e.getMessage());
}
}
@Asyncensures the method runs in a background thread.- Returns
CompletableFuture<String>, behaving like asupplyAsync. - Uses Spring Boot’s default async executor, not ForkJoinPool.
Controller Layer: FileImportController
@PostMapping
public ResponseEntity<JobIdResponse> importFile(@RequestParam("file") MultipartFile file) throws IOException {
File uploadedFile = fileStorageService.saveFile(file);
String filePath = uploadedFile.getAbsolutePath();
Job newJob = jobService.saveNewFileJob(JobType.IMPORT);
importService.importFile(newJob.getId(), filePath)
.whenComplete(fileImportServiceCallback);
return ResponseEntity.status(HttpStatus.CREATED).body(JobMapper.toJobId(newJob));
}
- Saves the uploaded file.
- Triggers
importFileasynchronously. - Registers a
whenCompletecallback to react to completion. - Returns HTTP 201 (Created) immediately, without waiting for import to finish.
Callback Handling: FileServiceCallback
@Component
@RequiredArgsConstructor
public class FileServiceCallback implements BiConsumer<String, Throwable> {
private final JobService jobService;
@Override
public void accept(String jobId, Throwable ex) {
if (ex == null) {
jobService.updateJobState(jobId, JobState.DONE);
} else {
jobService.updateJobState(jobId, JobState.ERROR);
}
}
}
- If the import is successful → mark job as
DONE. - If it fails → mark job as
ERROR.
Key Takeaways
CompletableFutureenables easy asynchronous programming.runAsyncis for tasks without a return;supplyAsyncis for tasks with a return.- Returning
CompletableFuture<T>manually (like withcompletedFuture) is a common pattern when you already have a value. - Using
@AsyncwithCompletableFutureprovides clean, scalable non-blocking designs. - In Spring,
@Asyncuses its own executor, not the ForkJoinPool by default.
Happy coding! 💻