Parallelism in Java — A 360° Perspective from Threads to Project Loom

  • 4.6/5
  • 146
  • Jul 26, 2025

Topics Covered

We delve into Java's concurrency toolbox — covering threads, thread pools, Fork/Join framework, CompletableFuture, and more — to help you choose the right tool for the job.

  1. Threads and Runnable / Callable
    1. Using Runnable Interface
    2. Using Thread class directly
    3. Using Callable with ExecutorService
    4. Using Runnable with ExecutorService
    5. Runnable vs Callable
  2. Thread Pools & ExecutorService
    1. Benefits of Thread Pools
    2. What is ExecutorService?
    3. Thread Pool Methods
    4. How to use ExecutorService
    5. Example: Fixed Thread Pool
    6. Methods of ExecutorService
  3. Fork/Join Framework
    1. Overview
    2. Core Classes and Interfaces
    3. How It Works
    4. Example: Computing Sum of an Array
    5. Fork/Join Framework vs ExecutorService
  4. Parallel Streams (parallelStream())
  5. CompletableFuture (Async Programming)
    1. Basic Usage
    2. Chaining Example
    3. Combining Multiple Tasks
    4. Error Handling
  6. Virtual Threads (Java 21+)
    1. Using Thread.ofVirtual()
    2. Using Executor with Virtual Threads
  7. Reactive Programming (Project Reactor, RxJava, Akka)
    1. Project Reactor
    2. RxJava
    3. Akka (Actor Model)
  8. Conclusion


Java provides multiple ways to achieve parallelism, spanning from high-level abstractions like parallelStream() to low-level thread manipulation. Each approach suits different use cases — from simple data processing to complex concurrent applications.

1. Threads and Runnable / Callable

This is the lowest-level concurrency model — you manually create and manage threads.

A Thread is a lightweight process; it's the smallest unit of execution. In Java, threads allow concurrent execution of two or more parts of a program for maximum utilization of CPU. There are four main ways to create and run threads in Java:

1.1 Using Runnable Interface

The Runnable interface is the most common way to create threads. Runnable has a single method: run(). It does not return a value or throw checked exceptions. It's preferred when you don't need the thread to return a result.

1.2 Using Thread class directly

You can also create a thread by extending the Thread class, but it's less flexible than using Runnable. Downside: Java doesn't support multiple inheritance, so if you extend Thread, you can't extend any other class.

1.3 Using Callable with ExecutorService

Unlike Runnable, Callable can return a result and throw checked exceptions. It is part of the java.util.concurrent package. Callable<V> returns a result of type V. Works with ExecutorService and returns a Future. Use when you want to execute tasks that return a result or throw exceptions

Can you use Callable without ExecutorService in Java?

Not directly — Callable is designed to be used with concurrency frameworks like ExecutorService that can handle return values via Future.

Callable<T> defines the method T call() throws Exception; Unlike Runnable, you cannot pass a Callable directly to a Thread constructor. This is because Thread expects a Runnable and doesn't support returning results or checked exceptions.

You can call call() directly, but this won't run it in a separate thread: Limitation: No multithreading is involved here — it's just a normal method call.

If you really want to run Callable in a separate thread without using ExecutorService, you'd need to wrap it in a Runnable and manage the result manually: Downside: You lose the clean Future interface and thread pooling benefits of ExecutorService.

1.4 Using Runnable with ExecutorService

You can definitely use a Runnable with an ExecutorService in Java — and it's actually one of the most common ways to manage threads in a clean, scalable way. A Runnable task does not return any result when it is executed. When you submit a Runnable to an ExecutorService using the submit() method, it returns a Future<?> object; however, calling get() on this future will return null because Runnable does not produce a result.

On the other hand, using the execute() method to submit a Runnable will run the task but does not return anything.

The difference between submit() and execute() is that execute() returns void and is used when you simply want to run a task without tracking its status, while submit() returns a Future<?> which allows you to monitor, cancel, or check the completion of the task.

Runnable vs Callable

Feature Runnable Callable
Return Value No Yes
Exceptions Cannot throw checked Can throw checked
Functional Interface Yes (Java 8+) Yes (Java 8+)
Use With Thread ExecutorService
Method run() call()


2. Thread Pools & ExecutorService

A Thread Pool is a group of pre-instantiated reusable threads ready to perform tasks. Instead of creating a new thread each time, the pool picks an available thread to execute submitted tasks.

Benefits of Thread Pools

- Improved performance: Reusing threads reduces overhead.
- Better resource management: Limits the number of concurrent threads.
- Task scheduling: Supports managing, scheduling, and controlling tasks.
- Flexible: Can be fixed size, cached, scheduled, or single-thread pools.

What is ExecutorService?

ExecutorService is a high-level Java concurrency framework introduced in Java 5 (java.util.concurrent package). It abstracts away the low-level thread management and provides a pool of threads to execute asynchronous tasks efficiently.

Managing threads directly (via Thread class) is error-prone and inefficient because:
- Creating and destroying threads for each task is expensive.
- Too many threads can overload the system.
- No easy way to control, monitor, or manage thread life cycle.
- Handling concurrency issues (like synchronization) becomes complicated.

ExecutorService solves these problems by managing a thread pool — a fixed or dynamic set of threads reused to execute multiple tasks. You can create different types of thread pools using Executors factory methods:

Thread Pool Methods

Method Description
newFixedThreadPool(int n) Fixed-size pool with n threads
newCachedThreadPool() Pool that creates new threads as needed, reuses idle threads
newSingleThreadExecutor() Single thread executor
newScheduledThreadPool(int n) For scheduled tasks (delays or periodic execution)


How to use ExecutorService?

Step 1: Create the executor (thread pool)
ExecutorService executor = Executors.newFixedThreadPool(3);
Step 2: Submit tasks (Runnable or Callable)
executor.submit(() -> {
    System.out.println("Task executed by: " + Thread.currentThread().getName());
});
Step 3: Shutdown the executor
executor.shutdown();  // Initiates orderly shutdown after submitted tasks complete
Example: Fixed Thread Pool


Methods of ExecutorService

Method Purpose
submit(Runnable/Callable) Submit a task for execution, returns a Future
execute(Runnable) Submit a task, returns void (fire and forget)
shutdown() Initiates graceful shutdown (no new tasks accepted)
shutdownNow() Attempts to stop all running tasks immediately
awaitTermination(timeout, unit) Blocks until all tasks finish or timeout occurs
invokeAll(Collection<Callable>) Runs a batch of callables, waits for all to complete


3. Fork/Join Framework

The Fork/Join Framework is a framework introduced in Java 7 (java.util.concurrent package) designed to take advantage of multiple processors by breaking a task into smaller subtasks, executing them in parallel, and then combining (joining) their results.

It follows the divide-and-conquer algorithmic approach, making it ideal for recursive, parallelizable problems such as searching, sorting, or mathematical computations.

- Fork: Split a big task into smaller subtasks that can run concurrently.
- Join: Combine the results of these subtasks after they finish.

The framework manages a pool of worker threads called a ForkJoinPool which efficiently schedules these subtasks.

Core Classes and Interfaces

Class/Interface Description
ForkJoinPool Manages and schedules worker threads
ForkJoinTask<V> Abstract base class for tasks
RecursiveTask<V> A ForkJoinTask that returns a result (V)
RecursiveAction A ForkJoinTask that returns no result (void)

How It Works

- Divide: The main task checks if it’s small enough to compute directly.
- Fork: If not, it splits itself into smaller subtasks.
- Execute: Subtasks are submitted to the pool to run in parallel.
- Join: Results from subtasks are combined as they complete.

Computing Sum of an Array Using RecursiveTask

The problem is recursively divided until the subtask size is small enough (THRESHOLD). Smaller tasks compute the sum sequentially. Larger tasks split into two subtasks, one is forked (scheduled for parallel execution), and the other is computed in the current thread.

Results are joined and combined up the recursion. Advantages: It uses work-stealing algorithm — idle threads can steal work from busy threads, improving load balancing. Simplifies writing parallel recursive algorithms.

It shows better performance compared to manual thread management for divide-and-conquer tasks.

Watch Out: Tasks should be independent and divide-able for maximum benefit. Overhead of splitting too small tasks may degrade performance. Avoid shared mutable state to prevent concurrency issues.

Fork/Join Framework vs ExecutorService

Fork/Join is great for divide-and-conquer problems where a big task is split into smaller subtasks, and their results are combined. You don’t get this automatically with an ExecutorService, which is usually used to run many independent tasks at the same time.

Fork/Join works really well for recursive problems where tasks depend on results from subtasks. If you try to do this with ExecutorService, threads often end up waiting for each other, which is inefficient.

If your tasks are independent and don't need to wait on each other, using Fork/Join doesn't really help.

Also, using normal thread pools (like ExecutorService) for Fork/Join tasks can cause issues like thread starvation or deadlock, because Fork/Join tasks spend a lot of time waiting for subtasks to finish. Normal thread pools expect mostly independent, longer-running tasks, so they’re not optimized for Fork/Join's fine-grained, dependent subtasks.

4. Parallel Streams (parallelStream())

A Parallel Stream is a feature introduced in Java 8 that allows you to process elements in parallel using multiple threads from the Fork/Join framework — without explicitly managing threads.

Instead of writing multithreaded code, you can just call .parallelStream() on a collection and Java handles the concurrency under the hood.

When you call .parallelStream(), Java splits the data into chunks, processes them in parallel across multiple threads, and then combines the results.

It uses a ForkJoinPool.commonPool() by default.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

int sum = numbers.parallelStream()
                 .mapToInt(n -> n * 2)
                 .sum();

System.out.println("Sum: " + sum);
The .parallelStream() splits the list across threads. Each number is doubled in parallel. The results are combined to compute the final sum.

Very easy to use — just replace .stream() with .parallelStream(). No need to manage threads, pools, or synchronization. Good for CPU-bound operations on large data sets.

Watch Out: For small or I/O-bound tasks, parallel overhead can slow it down. Operations like forEach() can process elements in a different order. Use forEachOrdered() if order matters. All parallel streams share the same ForkJoinPool.commonPool() — can be a bottleneck.

5. CompletableFuture (Async Programming)

CompletableFuture is a class introduced in Java 8 in the java.util.concurrent package.

It represents a future result of an asynchronous computation — you can start a task in the background, and retrieve the result later when it's ready.

It helps you write non-blocking, event-driven, and asynchronous code, which is useful when you want your application to do other things while waiting for a task to complete.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    // Simulate some long-running task
    try { Thread.sleep(1000); } catch (InterruptedException e) {}
    return "Hello, World!";
});

System.out.println("Doing something else...");

// Get the result (waits if not yet complete)
String result = future.join();
System.out.println("Result: " + result);

Chaining Example

CompletableFuture.supplyAsync(() -> "Hello")
    .thenApply(greeting -> greeting + " World")
    .thenAccept(System.out::println);  // Prints: Hello World

Combining Multiple Tasks

CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> 10);
CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> 20);

CompletableFuture<Integer> combined = future1.thenCombine(future2, Integer::sum);
System.out.println("Sum: " + combined.join()); // Output: Sum: 30

Error Handling

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    if (true) throw new RuntimeException("Oops!");
    return 10;
}).exceptionally(ex -> {
    System.out.println("Caught exception: " + ex.getMessage());
    return 0;
});

System.out.println("Result: " + future.join()); // Output: 0
Read also: Concurrency in Java: CompletableFuture and its use

6. Virtual Threads (Java 21+)

Virtual threads are lightweight threads managed by the Java Virtual Machine (JVM), not by the operating system. They are designed to handle massive concurrency — you can create millions of them without overwhelming system resources.

Traditional threads are expensive to create and block easily (e.g., during I/O operations). Virtual threads solve this by:

- Reducing memory and CPU cost.
- Allowing huge numbers of concurrent tasks (like handling thousands of HTTP requests).
- Making blocking code (e.g., JDBC, file I/O) scale better.

How to Create Virtual Threads (Java 21+)

Using Thread.ofVirtual() API

Runnable task = () -> System.out.println("Running in: " + Thread.currentThread());

Thread vThread = Thread.ofVirtual().start(task);

Using Executor with Virtual Threads

Executors.newVirtualThreadPerTaskExecutor() creates a new virtual thread for each submitted task.
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

executor.submit(() -> {
    System.out.println("Virtual thread: " + Thread.currentThread());
});
executor.shutdown();
Read also: Imperative, Async Blocking, Reactive & Virtual Threads

7. Reactive Programming (Project Reactor, RxJava, Akka)

Reactive Programming is a programming paradigm focused on asynchronous data streams and the propagation of change. It's about building systems that are:
- Responsive (fast to respond)
- Resilient (handles failure gracefully)
- Elastic (scales efficiently)
- Message-driven (uses non-blocking communication)

These are the principles of the Reactive Manifesto.

Popular Reactive Frameworks in Java

Project Reactor (Spring WebFlux)

Developed by the Spring team. Fully supports the Reactive Streams specification. Integrates with Spring WebFlux for building reactive web applications.

Uses Mono<T> (0..1 value) and Flux<T> (0..N values).
Flux<Integer> numbers = Flux.range(1, 5)
    .map(i -> i * 2);

numbers.subscribe(System.out::println); // Outputs 2, 4, 6, 8, 10
Read also: Spring Reactive with PostgreSQL (Spring Boot WebFlux + PostgreSQL)

RxJava

One of the earliest and most popular reactive libraries. Based on the Observer pattern. Uses Observable, Single, Maybe, Flowable, and Completable.
Observable.just("A", "B", "C")
    .map(String::toLowerCase)
    .subscribe(System.out::println); // Outputs a, b, c

Akka (Actor Model)

Built on the Actor model, not streams. Developed by Lightbend (Scala + Java). Great for building distributed, event-driven, and fault-tolerant systems.

Actors communicate via message passing (asynchronously).
public class HelloActor extends AbstractActor {
    public Receive createReceive() {
        return receiveBuilder()
            .match(String.class, msg -> System.out.println("Received: " + msg))
            .build();
    }
}

// Sending a message to the actor
actorRef.tell("Hello, Akka!", ActorRef.noSender());
Read also: Reactive programming in Java with Project Reactor

Conclusion

Java provides a rich and evolving toolkit for parallelism. Whether you need simple parallel collection processing or fine-grained thread management for complex systems, Java has you covered.

Mechanism Level Use Case
Threads / Runnable / Callable Low Fine-grained control
ExecutorService Mid Task execution & thread management
ForkJoinPool Mid-High Recursive, CPU-bound work
parallelStream() High Easy collection-based parallelism
CompletableFuture Mid-High Asynchronous workflows
Virtual Threads (Java 21+) Mid Massive concurrency, simple thread use
Reactive (RxJava, Reactor) High Reactive, non-blocking event pipelines
Index
Modern Java - What’s new in Java 9 to Java 17

32 min

Differences between JDK, JRE and JVM

2 min

What is ClassLoader in Java ?

2 min

Object Oriented Programming (OOPs) Concept

17 min

Concurrency in Java: Creating and Starting a Thread

12 min

Concurrency in Java: Interrupting and Joining Threads

5 min

Concurrency in Java: Race condition, critical section, and atomic operations

13 min

Concurrency in Java: Reentrant, Read/Write and Stamped Locks

11 min

Concurrency in Java: "synchronized" and "volatile" keywords

10 min

Concurrency in Java: using wait(), notify() and notifyAll()

6 min

Concurrency in Java: What is "Semaphore" and its use?

2 min

Concurrency in Java: CompletableFuture and its use

18 min

Concurrency in Java: Producer-consumer problem using BlockingQueue

2 min

Concurrency in Java: Producer-Consumer Problem

2 min

Concurrency in Java: Thread pools, ExecutorService & Future

14 min

Java 8 Lambdas, Functional Interface & "static" and "default" methods

28 min

Method Reference in Java (Instance, Static, and Constructor Reference)

9 min

What's new in Java 21: A Tour of its Most Exciting Features

14 min

Java Memory Leaks & Heap Dumps (Capturing & Analysis)

9 min

Memory footprint of the JVM (Heap & Non-Heap Memory)

15 min