
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.
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.👇
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.
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:
- Manually handle threads, i.e. starting and stopping.
- Manual monitoring of Thread state: Start, Stop, Abort, Error, etc.
- In case a thread fails and throws an exception, you’ll have to handle that manually too.
- 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.
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? 🤔
- No more manual thread lifecycle handling.
- 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.
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? 🤔
- 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.
- 👉 Work-stealing: Idle threads can “steal” work from busy threads.
- 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.
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
?
- 👉 Chaining operations instead of blocking on
get()
. thenCombine
combines two async results.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.
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.
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?
- It’s ideal for streaming large volumes of data.
- 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.
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.
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?
- Scoped concurrency means tasks are grouped and managed together.
- If one task fails, all others can be cancelled implicitly, reducing complexity and improving safety.
- Treating sub-tasks as steps in an atomic task.
- 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 Case | Recommended API |
---|---|
Simple parallel tasks | Thread, ExecutorService or CompletableFuture |
CPU-bound tasks | ForkJoinPool, ParallelStreams |
Multiple I/O-bound tasks | Virtual Threads |
Event-driven/reactive systems | Flow API |
If you liked this piece on Java, you’ll also like the new features introduced in JDK24.
Subscribe to my newsletter today!