<< Home

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.

💡 Concurrency means dealing with many tasks at once, while parallelism means actually performing tasks at the same time. A single-core processor can run concurrent code, but only a multi-core processor can run tasks in true parallel.

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.

⚠️ Writing multithreaded code introduces risks such as deadlocks, data races, and unpredictable timing issues. Always design concurrency carefully, and test under realistic conditions.

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.

💡 Never call 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.

⚠️ Thread scheduling is handled by the Java Virtual Machine and the underlying operating system. You can suggest a thread’s priority, but you cannot guarantee when or how long it runs.

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.

💡 Every Java object has an intrinsic lock. When a synchronized method or block is entered, that object’s lock is acquired. Locks are automatically released when the thread exits the synchronized section, even if an exception occurs.

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();
    }
  }
}
⚠️ Always release locks in a 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.

💡 Always prefer executors to manual thread management. They simplify concurrency and allow better control over performance and scalability.

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().

⚠️ Calling 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:

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.

💡 A well-managed thread pool improves both performance and stability. Always balance thread count with the number of available CPU cores and the nature of your tasks (CPU-bound or I/O-bound).

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.

💡 Use the synchronized wrappers only for simple cases or legacy code. For scalable concurrent access, prefer the collections in 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.

⚠️ Blocking queues cause producer threads to wait when full and consumer threads to wait when empty. This helps prevent overproduction and ensures smooth flow 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.

💡 Use 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());
⚠️ Avoid using blocking calls like 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.