Understanding Java's Asynchronous Journey

4 hours ago 1

Asynchronous programming skills are no longer “nice-to-have”; almost every programming language has it and uses it.

Languages like Go and JavaScript (in Node.js) have concurrency baked into their syntax.

Java, on the other hand, has concurrency, but it’s not quite as seamless at the syntax level when compared to something like JavaScript.

For instance, take a look at how JavaScript handles asynchronous operations. It’s way more compact and arguably easier to write than Java.

JavaScript

function fetchData() { return new Promise((resolve, reject) => { setTimeout(() => { resolve('Data fetched'); }, 10000); }); } fetchData().then(data => console.log(data)); console.log('Prints first'); // prints before the resolved data

Now, this is equivalent in Java.👇

Java

public class Example { public static void main(String[] args) throws ExecutionException, InterruptedException { CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> { try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } return "Data Fetched"; }); future.thenAccept(result -> System.out.println(result)); System.out.println("Prints first"); // prints before the async result } }

Coming from languages like JavaScript or Go, you might wonder 🤔

  • What even is a CompletableFuture?
  • Why does it take this much boilerplate to do something asynchronous in Java?
  • And why do we need to import a whole API just to do it?

🤓 These are valid questions — and believe it or not, this is the most simplified version of achieving Concurrency in Java.

This article walks through the evolution and explanation of concurrent programming in Java, from the early days of Threads in Java 1 to the StructuredTaskScope in Java 21.

Threads in Java 1

Early Java concurrency meant managing Thread objects directly.

To start execution of code in a thread, you’d have to create a thread object and pass a runnable with the actual logic you want to execute.

Take a look at the following example.

Java

public class Example { public static void main(String[] args) throws InterruptedException { final String[] result1 = new String[1]; final String[] result2 = new String[1]; Thread t1 = new Thread(() -> result1[0] = fetchData1()); Thread t2 = new Thread(() -> result2[0] = fetchData2()); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(result1[0] + " & " + result2[0]); // A & B } static String fetchData1() { return "A"; } static String fetchData2() { return "B"; } }

👉 What’s bad with achieving concurrency using Thread objects? 😪

I can think of these:

  1. Manually handle threads, i.e. starting and stopping.
  2. Manual monitoring of Thread state: Start, Stop, Abort, Error, etc.
  3. In case a thread fails and throws an exception, you’ll have to handle that manually too.
  4. Too much code means more potential for making errors ❗.

ExecutorService in Java 5

Java 5 introduced ExecutorService, which abstracted away a lot of the thread lifecycle management with the help of Future object.

Java

public class Example { public static void main(String[] args) throws Exception { ExecutorService executor = Executors.newFixedThreadPool(2); Future<String> f1 = executor.submit(() -> fetchData1()); Future<String> f2 = executor.submit(() -> fetchData2()); System.out.println(f1.get() + " & " + f2.get()); executor.shutdown(); } static String fetchData1() { return "A"; } static String fetchData2() { return "B"; } }

👉A Future is like a Promise object that we saw in the case of JavaScript as well, once the job is finished, it stores the results, which then can be accessed using get() method.

What got better with ExecutorService API? 🤔

  1. No more manual thread lifecycle handling.
  2. You can retrieve results via Future.

⚠️ But one major issue remains:

get() method invocation blocks the thread until the result is ready.

For example, if f1 takes time, everything after f1.get() in the above code also waits, which defeats the purpose of being “concurrent” — you are executing an asynchronous block of code synchronously.

ForkJoinPool in Java 7

Java 7 introduced ForkJoinPool API, designed for CPU-intensive parallel tasks using a work-stealing algorithm.

This is not an update over ExecutorService API, rather, it uses the ExecutorService internals to achieve its objective.

Java

public class Example { public static void main(String[] args) { ForkJoinPool pool = new ForkJoinPool(); FetchTask task1 = new FetchTask(() -> fetchData1()); FetchTask task2 = new FetchTask(() -> fetchData2()); task1.fork(); task2.fork(); String result1 = task1.join(); String result2 = task2.join(); System.out.println("Combined: " + result1 + " & " + result2); pool.shutdown(); } static class FetchTask extends RecursiveTask<String> { private final java.util.function.Supplier<String> supplier; FetchTask(java.util.function.Supplier<String> supplier) { this.supplier = supplier; } @Override protected String compute() { return supplier.get(); } } static String fetchData1() { return "A"; } static String fetchData2() { return "B"; } }

What’s special about ForkJoinPool? 🤔

  1. RecursiveTask: A wrapper for the task that takes a runnable called Supplier, the supplier keeps supplying computations to run on a thread and keeps it away from starvation.
  2. 👉 Work-stealing: Idle threads can “steal” work from busy threads.
  3. Best for CPU-bound tasks (not I/O-bound).

CompletableFuture in Java 8

This is where things start getting nice. 😀

CompletableFuture builds on top of the ExecutorService API, but uses it in a way that allows non-blocking chaining of tasks.

If you remember the problem we discussed with Future.get(), which was blocking the asynchronous task post its invocation, CompletableFuture prevents it by providing a chaining of operations on the received data.

Java

public class Example { public static void main(String[] args) { CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> fetchData1()); CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> fetchData2()); f1.thenCombine(f2, (resultFromF1, resultFromF2) -> resultFromF1 + " & " + resultFromF2) .thenAccept(System.out::println) // once combined then print .join(); // Waits for everything to complete } static String fetchData1() { return "A"; } static String fetchData2() { return "B"; } }

What’s much better with CompletableFuture from Future?

  1. 👉 Chaining operations instead of blocking on get().
  2. thenCombine combines two async results.
  3. thenAccept Consumes the final result.

With CompletableFuture we got much closer to the modern needs of concurrent programming.

ParallelStreams in Java 8

ParallelStreams are not a concurreny specific topic but one that utilises multiple threads beneath to optimise streams in Java.

Java

public class Example { public static void main(String[] args) { List<String> names = List.of("Alice", "Bob", "Charlie", "David"); names.parallelStream() .map(String::toUpperCase) .forEach(System.out::println); // Order not guaranteed } }

In the above code, the names list is processed concurrently by the use of ParallelStream API.

This feature is great when we want to process large quantities of data, which can be processed parallely without order.

Flow API in Java 9

Java 9 introduced the Flow API to support reactive programming patterns, think streams of async data.

What’s Reactive Programming? You can read the reactive manifesto here.

In short, reactive programming is the realm of modern-day event-driven systems where systems have to process large quantities of real-time and historical data.

Think of Kafka or any other message queues that process large quantities of data, where the need for concurrency with excellent resource utilisation is of paramount importance.

Java

public class Example { public static void main(String[] args) throws Exception { SubmissionPublisher<String> publisher1 = new SubmissionPublisher<>(); SubmissionPublisher<String> publisher2 = new SubmissionPublisher<>(); Subscriber<String> subscriber = new Subscriber<>() { private String latest1, latest2; public void onSubscribe(Subscription s) { s.request(Long.MAX_VALUE); } public void onNext(String item) { if (item.startsWith("A")) latest1 = item; else latest2 = item; if (latest1 != null && latest2 != null) System.out.println(latest1 + " & " + latest2); } public void onError(Throwable t) {} public void onComplete() {} }; publisher1.subscribe(subscriber); publisher2.subscribe(subscriber); publisher1.submit(fetchData1()); publisher2.submit(fetchData2()); Thread.sleep(100); publisher1.close(); publisher2.close(); } static String fetchData1() { return "A"; } static String fetchData2() { return "B"; } }

👉 Why use the Flow API?

  1. It’s ideal for streaming large volumes of data.
  2. Perfect for event-driven systems.

Virtual Threads in Java 21

A thread is an operating system entity that executes code through OS-level interfaces.

A common issue is thread starvation — when a thread completes its task but remains idle. The ForkJoinPool mitigates this, but it’s better suited for computation-heavy tasks, not I/O-bound ones.

👉 Virtual threads in Java 21 are lightweight JVM-managed threads that run code concurrently while using a small number of actual OS threads.

Many virtual threads can share a single platform thread, improving CPU utilisation.

They were introduced as a preview in Java 19 and became a stable feature in Java 21.

Java

public class Example { public static void main(String[] args) { ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); // try (executor) { Future<String> f1 = executor.submit(() -> fetchData1()); Future<String> f2 = executor.submit(() -> fetchData2()); String result1 = f1.get(); String result2 = f2.get(); System.out.println(result1 + " & " + result2); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } } static String fetchData1() { return "A"; } static String fetchData2() { return "B"; } }

👉 Similar to completable futures, these too are non-blocking in nature, and you can safely block the tasks like I/O operations without leading to thread starvation.

Structured Concurrency in Java 21 (Preview)

So, all the effort until Java 8 with CompletableFuture and Java 21 with Virtual Threads eased the use of concurrent programming in Java, however, some fundamental issues remain 😲.

These issues have more to do with the management of concurrent programming tasks.

One way of imagining the use of threads is to break the big task into small chunks and then execute them, much like ParallelStreams but in a custom manner.

This means that let’s say if I break my task A into two subtasks A1 and A2, the result R should be R = A1 + A2.

But what if any of the A1 or A2 fails? What happens to the result R?

In the simplest sense, the result for R should also fail as the task as a whole, which is that A did not succeed.

Currently, this kind of thread management, where we are constructing the results by executing the sub-tasks in parallel, can only be achieved manually, and Java don’t have a mechanism to combine multiple sub-tasks to be executed as one atomic task.

Structured Concurrency with StructuredTaskScope API (introduced in Java 21 as a preview) provides a way to group concurrent tasks together and treat them as a single unit of work.

Java

class Example { public static void main() { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Supplier<Integer> x = scope.fork(() -> fun1()); Supplier<Integer> y = scope.fork(() -> fun2()); scope.join().throwIfFailed(); // Wait for all tasks and fail-fast System.out.println("Both tasks completed successfully."); } catch (Exception e) { System.out.println("One of the tasks failed. All tasks are now stopped."); } } }

👉 Why does it matter?

  1. Scoped concurrency means tasks are grouped and managed together.
  2. If one task fails, all others can be cancelled implicitly, reducing complexity and improving safety.
  3. Treating sub-tasks as steps in an atomic task.
  4. Much cleaner than manual coordination of management logic like start, stop, abort, etc.

What should you choose to achieve concurreny in Java?

We can clearly see that Java offers multiple fronts for achieving concurrency. It even offers concurrency in differing paradigms like reactive programming.

Hence, the use of concurrency in Java can be based on many factors like Scale, Data Size, Nature(I/O or CPU), etc.

Here is a summary that you can use to better decide which technique will be suitable for you.

Use CaseRecommended API
Simple parallel tasksThread, ExecutorService or CompletableFuture
CPU-bound tasksForkJoinPool, ParallelStreams
Multiple I/O-bound tasksVirtual Threads
Event-driven/reactive systemsFlow API

If you liked this piece on Java, you’ll also like the new features introduced in JDK24.

Subscribe to my newsletter today!

Read Entire Article