In this article, I will explain parallelism and concurrency in Java, covering its evolution from basic threads to modern concurrency patterns. We’ll explore different approaches to concurrent programming in Java, starting with traditional thread-based concurrency and progressing through more advanced patterns.
Java’s concurrency model has evolved significantly over the years. The latest addition is Structured Concurrency, introduced as a preview feature in Java 19 and currently in its fifth preview in Java 25 (LTS). This powerful new model, specified in JEP 505, addresses many of the challenges of traditional thread management. We’ll examine Structured Concurrency in detail in the final section of this article.
Table of Contents
Thread based parallelism
Let’s explore thread-based parallelism in Java. This approach is particularly useful when you need to run long-running parallel calculations or execute tasks asynchronously without waiting for their completion.
Consider the following example:
| |
result may look like this:
Thread 3 performed a calculation on 2025-10-21T13:06:37.115582969Z
Thread 2 performed a calculation on 2025-10-21T13:06:37.115694670Z
Thread 1 performed a calculation on 2025-10-21T13:06:37.215599083Z
Thread 3 performed a calculation on 2025-10-21T13:06:37.215769435Z
Thread 2 failed: 2025-10-21T13:06:37.215879834Z
Thread Thread-2 failed: Something went wrong
Thread 1 failed to close: Something went wrong during close operation for calculation: 1
Thread 2 closed on 2025-10-21T13:06:37.216543127Z
Thread 3 closed on 2025-10-21T13:06:37.216633187Z
How It Works
- Each thread operates independently, performing its calculations
- If an unrecoverable exception occurs in any thread, the
UncaughtExceptionHandlertriggers the shutdown process - The handler attempts to close all threads gracefully using their
close()method - The
close()method may also fail, so we handle exceptions to ensure all threads get a chance to clean up - Finally, the program terminates after all threads have been processed
This pattern is particularly useful in scenarios where you need to ensure proper resource cleanup and handle failures gracefully. For example, imagine reading events from Kafka and processing them in parallel for a given consumer group. You might have several consumers, and if one fails unrecoverably, you can close all remaining consumers and fail gracefully.
ExecutorService based parallelism
ExecutorService-based parallelism was introduced in Java 5. It provides a higher-level abstraction over threads and offers a way to manage a thread pool. The advantages of this approach are:
- Thread management is handled by the executor service. One may restrict the number of threads.
- Methods like
invokeAllorinvokeAnyprovide ways to wait for all calculations to complete or get the first available result when several calculations are running in parallel. submitmethod returnsFutureobject, which may be used to get result of calculation or check if calculation is done.
Consider the following example:
| |
The execution result:
Preparing 1
Preparing 2
1 processed
Preparing 3
Task finished with: 1 done
We can see that invokeAny returns the first available result, while other calculations are interrupted!
Completable Future based parallelism
A more modern approach is to use CompletableFuture. This method of invocation was introduced in Java 8 and enhanced in Java 9 with new methods like completeOnTimeout, orTimeout, completeExceptionally, and others.
Consider the following example:
| |
result may look like this:
Preparing 1
Preparing 2
2 processed
finished: 1 failed and 2 done
The main progress in this approach is that we can combine results of several tasks and use operations like map, flatMap, thenCombine, reduce, and many others.
Structured concurrency (Java 25)
At the beginning of this section, it’s important to note that to compile and run code using the StructuredTaskScope API, you’ll need Java 25 with preview features enabled:
- Compile the program with javac –release 25 –enable-preview …
- When using the source code launcher, run the program with java –enable-preview …
- When using jshell, start it with jshell –enable-preview.
If you’re using sbt, add these lines to your build.sbt:
For Maven projects, use this configuration:
| |
Using Executors.newVirtualThreadPerTaskExecutor()
Let’s modify our previous example to use virtual threads by replacing Executors.newFixedThreadPool(2) with Executors.newVirtualThreadPerTaskExecutor().
Now, let’s dive into structured concurrency.
| |
Within the created scope, we run two tasks in parallel and wait for both to complete. While this code might look similar to our previous examples, we’ll soon explore the advantages that come from the scoped nature of these tasks.
Joiners
The default joiner is used when no other joiner is specified. It has the following behavior:
- The scope is canceled if any subtask fails
- If a subtask fails, its exception is propagated
- The
join()method returnsVoid, so you must retrieve values from each task individually
Let’s examine the StructuredTaskScope declaration. Understanding its generic type parameters is crucial:
There are two generic types:
T - Individual task result type
- T represents the type of result returned by each individual subtask/fork
- It’s the type that each parallel worker task produces
R - Final Combined Result Type
- R represents the type of the final combined result after joining all tasks
- It’s the overall result type that the entire StructuredTaskScope produces
If we look on Joiner interface we can see similar pattern here :
| |
The default Joiner interface forces R to be Void, which means we can’t use it to retrieve results directly from the scope.
Let’s rewrite our example with a custom joiner that maintains the same semantics:
| |
Now, scope.join() returns a Stream<Subtask<T>> which we can transform to Stream<String> in our case.
While this might not yet show significant advantages over previous examples,
it enables us to leverage the Stream API for result processing.
For instance, we can handle exceptions more gracefully. Let’s explore different Joiner implementations to understand
how they can help manage failures and configure different behaviors.
Joiner.anySuccessfulResultOrThrow()
Here is snipped of code that uses Joiner.anySuccessfulResultOrThrow():
This joiner behaves as follows:
- Returns the first successful result.
- Cancels all remaining tasks.
- Throws an exception only if all subtasks fail.
- The result is a
Stringin our case, as each individual task returns aString.
Joiner.allSuccessfulOrThrow()
This joiner is the default joiner. It behaves as follows:
- Wait for all subtasks to complete.
- If subtask fails exception is propagated and all remaining tasks are canceled.
- Stream of subtasks is returned.
When calling
scope = StructuredTaskScope.open()without an explicitly definedJoiner, the result ofscope.join()isVoid, requiring individual handling of subtasks.
Joiner.awaitAll()
The await all joiner behaves as follows:
- Waits for all subtasks to complete.
- Never throws an exception.
- Returns
Void, requiring manual checking of subtask states to determine success or failure.
Here is example:
| |
Custom Joiners
In some exotic cases, implementing a custom Joiner may be necessary.
Here is the interface:
onForkis called when a subtask is forked.onCompleteis called when a subtask completes.resultis invoked to produce the result for the join method when all subtasks have completed or the scope is cancelled. If the result method throws an exception, the join method will throw a FailedException with that exception as the cause.
Let’s imagine we have a basic implementation of Try like this:
| |
Now we can hold result of calculation in Try object and use it in our Joiner implementation:
| |
Our Joiner implementation returns a stream of results encapsulated in Try instances, allowing us to handle both success and failure cases.
Here’s an example of usage, where we’ve refactored the execute method from the previous example:
| |
This example is more functional and provides greater control over the results. For instance, we can track the position of failed tasks and handle them differently if needed.
Configuration
There is a variant of StructuredTaskScope.open that accepts a Joiner along with a configuration function. This allows you to set the scope’s name for monitoring and management purposes, configure the scope’s timeout, and specify the thread factory to be used by the scope’s fork methods for thread creation.
Here’s an example:
Conclusion
Java’s concurrency journey has been one of continuous evolution, moving from manual thread management towards safer, more structured approaches. Each paradigm we’ve explored represents a significant step forward in making concurrent programming more accessible and less error-prone.
Note: JEP 505 has been released, but remember it remains a preview feature in Java 25.