Chapter 16: Concurrency and Multithreading
Modern applications often need to perform many tasks at once, such as downloading data while updating a user interface, or processing multiple requests on a web server simultaneously. Concurrency and multithreading in Java provide the tools to manage such operations efficiently, allowing multiple parts of a program to run independently or in parallel.
Java has supported threads since its earliest versions, and its concurrency framework has grown into a sophisticated set of tools built around the java.util.concurrent package. From the low-level Thread class and Runnable interface to high-level abstractions like executors, thread pools, and asynchronous pipelines, Java offers fine control over performance, responsiveness, and scalability.
This chapter explores how to create and manage threads, synchronize shared data to prevent race conditions, and make use of the modern concurrency utilities. You will also learn how to coordinate tasks using CompletableFuture and apply asynchronous programming patterns that make code both efficient and easier to reason about.
Threads and the Runnable interface
At the heart of Java’s concurrency model lies the concept of a thread: a lightweight unit of execution that can run independently of other threads within the same program. Every Java application starts with at least one thread (the main thread) which executes the main() method. Additional threads can be created to perform tasks in parallel, improving responsiveness or throughput.
Java provides two primary ways to define a thread: by extending the Thread class, or by implementing the Runnable interface. The latter approach is preferred, as it separates the task being performed from the thread that runs it, making code more flexible and reusable.
Creating a thread with Runnable
The Runnable interface contains a single method, run(), which defines the code that will execute in the new thread. You then pass a Runnable instance to a Thread object and call start() to begin execution.
class MyTask implements Runnable {
public void run() {
System.out.println("Running in a separate thread");
}
}
public class Example {
public static void main(String[] args) {
Thread t = new Thread(new MyTask());
t.start(); // Starts the new thread
System.out.println("Main thread continues...");
}
}
When start() is called, Java’s runtime creates a new thread of execution that runs the run() method concurrently with the rest of the program.
run() directly. That would just invoke the method on the current thread. Always use start() to create a true new thread of execution.
Extending the Thread class
Although less flexible, another approach is to subclass Thread and override its run() method directly. This can be convenient for simple tasks, though it limits the ability to extend other classes.
class MyThread extends Thread {
public void run() {
System.out.println("Thread running: " + getName());
}
}
public class Example2 {
public static void main(String[] args) {
MyThread t = new MyThread();
t.start();
}
}
Thread lifecycle
Once started, a thread moves through several states: new, runnable, running, waiting or sleeping, and finally terminated. These transitions depend on system scheduling and synchronization mechanisms, which determine when each thread gets CPU time.
Using lambda expressions with Runnable
Since Runnable is a functional interface, you can use a lambda expression instead of creating a class. This makes the code concise and readable:
public class Example3 {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("Lambda thread running");
});
t.start();
}
}
This syntax is ideal for short-lived or one-off tasks, and it integrates neatly with the functional features introduced in modern Java.
Synchronized blocks and locks
When multiple threads access shared data, there is a risk that they will interfere with each other. For example, if two threads update the same variable at the same time, the final result may be unpredictable. This type of problem is known as a race condition. Java provides synchronization mechanisms to ensure that only one thread can access a critical section of code at a time.
The synchronized keyword
The simplest way to prevent data corruption is to use the synchronized keyword. It can be applied to methods or blocks of code to enforce exclusive access by a single thread. When a thread enters a synchronized section, it obtains the lock (or monitor) associated with the specified object. Other threads attempting to enter the same section must wait until the lock is released.
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
public class Example {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) counter.increment();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) counter.increment();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + counter.getCount());
}
}
Because the methods are synchronized, the counter’s value will always be correct, even though two threads are incrementing it concurrently.
Synchronized blocks
Instead of synchronizing an entire method, you can limit synchronization to a smaller block of code. This improves performance by reducing the amount of time a lock is held.
class PartialCounter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
synchronized (lock) {
return count;
}
}
}
Here, synchronization is explicit and localized, which helps reduce contention between threads.
Reentrant locks
The java.util.concurrent.locks package introduces more flexible locking mechanisms, such as ReentrantLock. Unlike synchronized methods, these locks allow explicit control over when a lock is acquired and released, as well as features like timed and interruptible waits.
import java.util.concurrent.locks.ReentrantLock;
class SafeCounter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
finally block. Forgetting to unlock will cause other threads to wait indefinitely, potentially freezing the program.
Reentrant and fairness properties
A ReentrantLock is reentrant, meaning a thread that already holds the lock can reacquire it without blocking itself. You can also construct a lock with a fairness policy, ensuring that threads acquire locks in the order they requested them, though this can reduce overall throughput.
ReentrantLock fairLock = new ReentrantLock(true);
While synchronized methods and blocks are simpler and sufficient for most use cases, explicit locks give greater flexibility and control in complex concurrency designs.
Executors and thread pools
Manually creating and managing threads works for simple programs, but it quickly becomes inefficient in larger systems. Constantly starting and stopping threads wastes resources, and it can be difficult to coordinate many concurrent tasks. To solve this, Java provides the Executor framework, which manages a pool of reusable threads that can execute tasks on demand.
The Executor interface
The Executor interface represents a simple abstraction for launching tasks. Instead of directly creating threads, you submit a Runnable (or Callable) to an executor that handles scheduling and execution.
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class Example {
public static void main(String[] args) {
Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> {
System.out.println("Task running in executor thread");
});
}
}
This example uses a single-threaded executor that runs one task at a time. The execute() method accepts a Runnable and handles thread creation, execution, and cleanup automatically.
Thread pools with ExecutorService
The ExecutorService interface extends Executor with lifecycle management and task submission methods that can return results. You typically create one using a factory method in the Executors class.
import java.util.concurrent.*;
public class PoolExample {
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 5; i++) {
int taskId = i;
pool.submit(() -> {
System.out.println("Task " + taskId + " running on " +
Thread.currentThread().getName());
});
}
pool.shutdown(); // Prevent new tasks
pool.awaitTermination(1, TimeUnit.MINUTES);
}
}
Here, a pool of three threads executes five tasks. When one thread finishes, it becomes available for the next queued task. This approach maximizes CPU utilization while avoiding the overhead of creating new threads for each operation.
Callable and Future
The Callable interface is similar to Runnable but can return a result or throw an exception. When you submit a Callable to an executor, it returns a Future object that represents the pending result.
import java.util.concurrent.*;
public class CallableExample {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
Callable<Integer> task = () -> {
Thread.sleep(1000);
return 42;
};
Future<Integer> future = executor.submit(task);
System.out.println("Result: " + future.get());
executor.shutdown();
}
}
The Future allows you to check whether a task is complete, cancel it if necessary, or retrieve its result using get().
get() blocks the current thread until the result is available. To avoid blocking, you can use timeouts or switch to non-blocking patterns like CompletableFuture.
Common executor types
The Executors utility class provides several predefined factory methods:
newSingleThreadExecutor()— One reusable thread.newFixedThreadPool(int n)— A pool with a fixed number of threads.newCachedThreadPool()— A flexible pool that creates threads as needed.newScheduledThreadPool(int n)— Executes tasks after a delay or periodically.
Using these built-in executors makes it easy to tune concurrency behavior for different workloads without manually managing thread creation or cleanup.
Shutting down executors
Executors do not stop automatically when your main thread ends. Always shut them down cleanly to free system resources:
executor.shutdown(); // Graceful stop
// or
executor.shutdownNow(); // Forceful stop
shutdown() lets tasks finish gracefully, while shutdownNow() attempts to interrupt running tasks immediately.
Concurrent collections
In multithreaded applications, sharing data structures between threads can easily lead to race conditions and data corruption if access is not properly synchronized. To make this easier, Java provides a set of thread-safe collection classes in the java.util.concurrent package. These concurrent collections are designed for high performance and safe access without the need for explicit synchronization by the programmer.
Limitations of synchronized collections
Before the java.util.concurrent package was introduced, developers often used synchronized wrappers like Collections.synchronizedList() or Collections.synchronizedMap() to make standard collections thread-safe. While this works, the entire collection is locked for every operation, creating a performance bottleneck when many threads compete for access.
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
These wrappers ensure correctness, but they serialize access to the entire collection, reducing concurrency. Modern concurrent collections overcome this limitation through fine-grained locking or lock-free algorithms.
java.util.concurrent.
ConcurrentHashMap
ConcurrentHashMap is a highly efficient, thread-safe alternative to HashMap. It uses segmented locking to allow multiple threads to read and update the map simultaneously, provided they operate on different parts of the map. This makes it ideal for caches, registries, and other shared data structures.
import java.util.concurrent.ConcurrentHashMap;
public class Example {
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("A", 1);
map.put("B", 2);
map.computeIfAbsent("C", k -> 3);
map.forEach((k, v) -> System.out.println(k + ": " + v));
}
}
Most operations on a ConcurrentHashMap are atomic, meaning you can safely modify it from multiple threads without additional synchronization.
Concurrent queues and deques
Concurrent queue implementations allow safe insertion and retrieval from multiple threads. Common examples include ConcurrentLinkedQueue, which uses a lock-free algorithm for high throughput, and LinkedBlockingQueue, which supports capacity limits and blocking operations.
import java.util.concurrent.*;
public class QueueExample {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> queue = new LinkedBlockingQueue<>(3);
new Thread(() -> {
try {
queue.put("Message 1");
queue.put("Message 2");
queue.put("Message 3");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
Thread.sleep(500);
while (!queue.isEmpty()) {
System.out.println(queue.take());
}
}
}
BlockingQueue is especially useful in producer-consumer designs, where one thread produces items and another consumes them. The queue automatically coordinates access between threads.
CopyOnWrite collections
The CopyOnWriteArrayList and CopyOnWriteArraySet classes provide thread safety by creating a new internal copy of the data each time it is modified. This makes them ideal for collections that are read frequently and modified rarely, such as lists of event listeners or configuration settings.
import java.util.concurrent.CopyOnWriteArrayList;
public class CopyExample {
public static void main(String[] args) {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("One");
list.add("Two");
for (String s : list) {
System.out.println(s);
list.add("Three"); // Safe during iteration
}
}
}
Because iteration is performed on a stable snapshot of the collection, you can safely modify it during iteration without triggering ConcurrentModificationException.
Other concurrent utilities
The java.util.concurrent package also includes supporting classes like DelayQueue, PriorityBlockingQueue, and ConcurrentSkipListMap for specialized scenarios such as delayed execution, priority ordering, or sorted concurrent access.
These collections combine the safety of synchronization with the performance of fine-grained locking and non-blocking algorithms, making them essential tools for modern concurrent programming in Java.
CompletableFuture and async patterns
The CompletableFuture class provides a modern approach to asynchronous programming in Java. It builds upon the traditional Future interface but adds powerful methods for composing, chaining, and combining asynchronous tasks without blocking threads. With CompletableFuture, you can write clean, non-blocking code that efficiently utilizes system resources.
Basic usage
A CompletableFuture represents a computation that may complete at some point in the future, either successfully with a result or exceptionally with an error. You can create one manually or start an asynchronous task using factory methods like supplyAsync() or runAsync().
import java.util.concurrent.*;
public class BasicExample {
public static void main(String[] args) {
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> "Hello, world!");
System.out.println(future.join()); // Waits and retrieves the result
}
}
The join() method blocks until the result is available, but in real applications, you can chain further computations instead of waiting synchronously.
runAsync() for tasks that do not return a value, and supplyAsync() for tasks that do. Both methods use the common ForkJoinPool by default.
Chaining asynchronous tasks
CompletableFuture supports fluent chaining of dependent actions through methods such as thenApply(), thenAccept(), and thenRun(). These methods execute automatically when the preceding stage completes.
CompletableFuture.supplyAsync(() -> "Java")
.thenApply(s -> s + " Concurrency")
.thenApply(String::toUpperCase)
.thenAccept(System.out::println);
This sequence runs each stage asynchronously without blocking, resulting in output like JAVA CONCURRENCY.
Combining multiple futures
You can coordinate several asynchronous computations using methods such as thenCombine() or allOf(). This allows tasks to run in parallel and merge their results when all are complete.
CompletableFuture<String> f1 =
CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> f2 =
CompletableFuture.supplyAsync(() -> "World");
CompletableFuture<String> combined =
f1.thenCombine(f2, (a, b) -> a + " " + b);
System.out.println(combined.join());
Here, both futures run concurrently. When both complete, the results are combined into a single string.
Error handling
Asynchronous tasks may fail or throw exceptions. CompletableFuture provides methods such as exceptionally() and handle() to recover or handle errors gracefully.
CompletableFuture<Integer> result =
CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Oops!");
return 10;
})
.exceptionally(ex -> {
System.out.println("Error: " + ex.getMessage());
return 0;
});
System.out.println("Result: " + result.join());
get() or join() in performance-critical code. Instead, compose further actions using completion stages to keep execution asynchronous.
Running multiple tasks in parallel
With allOf() and anyOf(), you can coordinate many concurrent tasks efficiently. allOf() waits for all to finish, while anyOf() completes as soon as one finishes.
CompletableFuture<String> a =
CompletableFuture.supplyAsync(() -> "A done");
CompletableFuture<String> b =
CompletableFuture.supplyAsync(() -> "B done");
CompletableFuture<String> c =
CompletableFuture.supplyAsync(() -> "C done");
CompletableFuture.allOf(a, b, c)
.thenRun(() -> System.out.println("All tasks finished"));
This model is ideal for tasks such as web requests, computations, or file operations that can be processed concurrently.
Custom executors
By default, CompletableFuture uses the common ForkJoinPool, but you can provide your own executor to control concurrency level and thread management.
ExecutorService pool = Executors.newFixedThreadPool(4);
CompletableFuture<String> task =
CompletableFuture.supplyAsync(() -> "Task completed", pool);
System.out.println(task.join());
pool.shutdown();
Using a custom executor gives you predictable performance and better control over system resources in large applications.
CompletableFuture enables declarative, non-blocking programming. By chaining tasks and handling results asynchronously, you can build responsive systems that scale smoothly with modern multicore hardware.