Java Concurrency: Threads, Executors, CompletableFuture, and Virtual Threads

Modern Java applications often need to handle thousands of simultaneous operations without creating a thread for every task. Java provides several concurrency mechanisms, from traditional threads and executor pools to CompletableFuture and virtual threads.

This guide explains how these models work, when to use each one, and how to build high-throughput concurrent applications with practical code examples.

1. Choosing the Right Concurrency Model

Java provides multiple approaches to concurrent execution.

Platform Threads: Traditional OS-backed threads suitable for CPU-bound work and tasks requiring explicit thread control.

ExecutorService: Manages reusable worker threads and separates task submission from thread management.

CompletableFuture: Provides asynchronous pipelines, composition, parallel execution, and error handling.

Virtual Threads: Lightweight threads introduced in Java 21 that are designed for applications with large numbers of concurrent blocking I/O operations.

2. Traditional Java Threads

The simplest concurrency mechanism is creating a Thread directly. A thread can execute work independently while the main application continues running.

Running Tasks Concurrently

Java
Create and wait for a traditional Java thread
public class ThreadExample {

    public static void main(String[] args) throws InterruptedException {

        Thread worker = new Thread(() -> {
            System.out.println(
                "Running on: " + Thread.currentThread().getName()
            );

            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            System.out.println("Worker finished");
        });

        worker.start();
        worker.join();

        System.out.println("Main thread finished");
    }
}

3. ExecutorService: Reusing Worker Threads

Creating a new platform thread for every operation can become expensive. ExecutorService provides reusable worker threads and manages task execution through a thread pool.

Fixed Thread Pool

Java
Execute tasks using a bounded fixed-size worker pool
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ExecutorExample {

    public static void main(String[] args) throws InterruptedException {

        ExecutorService executor = Executors.newFixedThreadPool(4);

        for (int i = 1; i <= 8; i++) {
            int taskId = i;

            executor.submit(() -> {
                System.out.println(
                    "Task " + taskId +
                    " running on " +
                    Thread.currentThread().getName()
                );

                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        executor.shutdown();

        if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
            executor.shutdownNow();
        }

        System.out.println("All tasks completed.");
    }
}

4. Callable and Future

Runnable does not return a result. Callable allows a task to return a value, while Future provides a handle for retrieving that result.

Java
Submit a Callable task and retrieve its result with Future
import java.util.concurrent.*;

public class FutureExample {

    public static void main(String[] args) throws Exception {

        ExecutorService executor =
                Executors.newFixedThreadPool(2);

        Future<Integer> future = executor.submit(() -> {
            Thread.sleep(1000);
            return 42;
        });

        System.out.println("Doing other work...");

        Integer result = future.get();

        System.out.println("Result: " + result);

        executor.shutdown();
    }
}

5. CompletableFuture: Asynchronous Pipelines

CompletableFuture allows asynchronous operations to be chained together without manually coordinating every thread.

Chaining Async Operations

Java
Build an asynchronous processing pipeline with CompletableFuture
import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {

    public static void main(String[] args) {

        CompletableFuture
            .supplyAsync(() -> {
                System.out.println("Fetching user...");
                return "user-42";
            })
            .thenApply(userId -> {
                System.out.println("Loading profile...");
                return "Profile for " + userId;
            })
            .thenAccept(profile -> {
                System.out.println("Result: " + profile);
            })
            .join();
    }
}

6. Running Independent Operations in Parallel

Independent operations can be started at the same time using separate CompletableFuture instances and combined with CompletableFuture.allOf().

Java
Run independent asynchronous operations concurrently
import java.util.concurrent.CompletableFuture;

public class ParallelFutureExample {

    static String fetchUser() {
        sleep(500);
        return "Alice";
    }

    static String fetchOrders() {
        sleep(800);
        return "5 orders";
    }

    static String fetchNotifications() {
        sleep(300);
        return "3 notifications";
    }

    static void sleep(long milliseconds) {
        try {
            Thread.sleep(milliseconds);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) {

        CompletableFuture<String> user =
                CompletableFuture.supplyAsync(ParallelFutureExample::fetchUser);

        CompletableFuture<String> orders =
                CompletableFuture.supplyAsync(ParallelFutureExample::fetchOrders);

        CompletableFuture<String> notifications =
                CompletableFuture.supplyAsync(
                    ParallelFutureExample::fetchNotifications
                );

        CompletableFuture.allOf(
                user,
                orders,
                notifications
        ).join();

        System.out.println("User: " + user.join());
        System.out.println("Orders: " + orders.join());
        System.out.println("Notifications: " + notifications.join());
    }
}

7. Virtual Threads in Java 21+

Virtual threads are lightweight Java threads designed to support very high concurrency, especially for applications that spend significant time waiting on blocking I/O.

Starting a Virtual Thread

Java
Start a lightweight virtual thread
public class VirtualThreadExample {

    public static void main(String[] args) throws InterruptedException {

        Thread worker = Thread.startVirtualThread(() -> {

            System.out.println(
                "Running on: " + Thread.currentThread()
            );

            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            System.out.println("Request completed.");
        });

        worker.join();
    }
}

8. High-Concurrency I/O with Virtual Threads

Virtual threads allow applications to represent many concurrent blocking tasks without requiring an equally large number of platform threads.

Java
Process many concurrent tasks with a virtual-thread-per-task executor
import java.util.concurrent.Executors;

public class VirtualThreadPoolExample {

    static void handleRequest(int requestId) {

        System.out.println(
            "Request " + requestId +
            " started on " +
            Thread.currentThread()
        );

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        System.out.println(
            "Request " + requestId + " completed"
        );
    }

    public static void main(String[] args) {

        try (var executor =
                Executors.newVirtualThreadPerTaskExecutor()) {

            for (int i = 1; i <= 1000; i++) {
                int requestId = i;

                executor.submit(() ->
                    handleRequest(requestId)
                );
            }
        }

        System.out.println("All requests completed.");
    }
}

9. CPU-Bound vs I/O-Bound Work

Concurrency strategy should be based on whether the application is primarily waiting for external resources or actively consuming CPU.

I/O-Bound: HTTP requests, database queries, file operations, message queues, and remote API calls. Virtual threads are well suited to applications with many blocking I/O operations.

CPU-Bound: Image processing, compression, mathematical calculations, large data transformations, and other computational workloads. A bounded executor can limit CPU parallelism.

Java
Use a bounded thread pool for CPU-intensive tasks
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CpuExecutorExample {

    public static void main(String[] args) {

        int cores = Runtime.getRuntime().availableProcessors();

        ExecutorService executor =
                Executors.newFixedThreadPool(cores);

        for (int i = 0; i < 100; i++) {

            int taskId = i;

            executor.submit(() -> {
                long result = 0;

                for (long j = 0; j < 10_000_000L; j++) {
                    result += j;
                }

                System.out.println(
                    "Task " + taskId +
                    " completed with " + result
                );
            });
        }

        executor.shutdown();
    }
}

10. Thread Safety and Shared State

Multiple concurrent tasks accessing shared mutable state can produce race conditions. Operations such as value++ are not atomic.

Java
Thread-safe counter using AtomicInteger
import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {

    private final AtomicInteger counter =
            new AtomicInteger();

    public void increment() {
        counter.incrementAndGet();
    }

    public int getValue() {
        return counter.get();
    }
}

11. Blocking Queues for Producer-Consumer Systems

A common concurrency pattern is a producer generating work while consumers process it. BlockingQueue provides built-in coordination and can provide backpressure when bounded.

Java
Implement a producer-consumer pipeline with a bounded BlockingQueue
import java.util.concurrent.*;

public class ProducerConsumerExample {

    public static void main(String[] args) throws Exception {

        BlockingQueue<String> queue =
                new ArrayBlockingQueue<>(10);

        ExecutorService executor =
                Executors.newFixedThreadPool(2);

        executor.submit(() -> {

            for (int i = 1; i <= 20; i++) {
                try {
                    queue.put("Task-" + i);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
            }
        });

        executor.submit(() -> {

            while (!Thread.currentThread().isInterrupted()) {
                try {
                    String task = queue.take();

                    System.out.println(
                        "Processing " + task
                    );

                    Thread.sleep(100);

                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
            }
        });

        Thread.sleep(3000);
        executor.shutdownNow();
    }
}

12. Hybrid Java Concurrency

Real-world applications can combine multiple concurrency mechanisms. Virtual threads can handle high-volume I/O while a bounded executor handles CPU-intensive operations.

TEXT
Conceptual architecture separating I/O concurrency from CPU-intensive work
HTTP Requests
      │
      ▼
Virtual Threads
      │
      ├──── Database I/O
      │
      ├──── Remote API
      │
      └──── File I/O
               │
               ▼
        CPU-heavy processing
               │
               ▼
      Bounded CPU Executor

Conclusion

Java concurrency has evolved from manually managed threads toward higher-level execution models. Use ExecutorService when you need explicit control over worker pools, CompletableFuture for asynchronous pipelines, and virtual threads for applications with large amounts of blocking I/O.

For CPU-bound workloads, focus on controlling parallelism rather than simply increasing the number of threads.