Skip to content

Concurrency Utilities in Java

The java.util.concurrent package provides higher-level tools for concurrent programs. Prefer these utilities over manually creating and coordinating Thread objects for most application code.


Run Tasks with an ExecutorService

An ExecutorService manages a pool of workers. Submit tasks to it instead of deciding yourself when every thread starts and stops.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
for (int id = 1; id <= 10; id++) {
int taskId = id;
executor.submit(() -> {
System.out.println("Processing task " + taskId);
});
}
}

The fixed pool runs at most four tasks at once. Closing the executor prevents new submissions and waits for submitted work to finish.


Get a Result with Future

Use a Callable when a task returns a value. submit() returns a Future that represents the eventual result.

import java.util.concurrent.Future;
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
Future<Integer> total = executor.submit(() -> 21 + 21);
// get() waits only if the result is not ready yet.
System.out.println(total.get()); // 42
}

get() can throw ExecutionException when the task failed and InterruptedException when the waiting thread is interrupted. Handle both deliberately.


Compose Work with CompletableFuture

CompletableFuture is useful when a later operation depends on an earlier asynchronous result.

import java.util.concurrent.CompletableFuture;
CompletableFuture<String> greeting = CompletableFuture
.supplyAsync(() -> "guide docs")
.thenApply(String::toUpperCase)
.thenApply(name -> "Welcome to " + name);
System.out.println(greeting.join());

Prefer thenCompose() when the next step itself returns a CompletableFuture, and use handle() or exceptionally() to provide failure handling.


Protect Shared State

If multiple tasks modify the same value, an ordinary increment is not safe.

count++; // read, add, and write are separate operations

For a simple counter, use AtomicInteger:

import java.util.concurrent.atomic.AtomicInteger;
AtomicInteger count = new AtomicInteger();
executor.submit(count::incrementAndGet);
executor.submit(count::incrementAndGet);

For multiple related operations, use a ReentrantLock with a finally block so the lock is always released.

import java.util.concurrent.locks.ReentrantLock;
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// Read or update shared state.
} finally {
lock.unlock();
}

Use Concurrent Collections

Collections such as HashMap and ArrayList are not safe for simultaneous modification. Use a collection designed for the required access pattern.

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
ConcurrentMap<String, Integer> visits = new ConcurrentHashMap<>();
visits.merge("/java", 1, Integer::sum);

Useful choices include:

UtilityGood use case
ConcurrentHashMapShared lookup or cache with frequent reads and updates.
BlockingQueueProducer-consumer work queues.
CountDownLatchWait until a fixed number of tasks complete.
SemaphoreLimit simultaneous access to a resource.

Choose the Right Limit

  • For CPU-intensive work, use a bounded pool roughly aligned with available processors.
  • For many I/O-bound tasks, consider virtual threads rather than increasing a platform-thread pool without limit.
  • Keep locks small and never hold one while making a slow remote call.
  • Make shutdown part of the design so background tasks do not keep an application alive unexpectedly.

Next Steps ➡️

Review Virtual Threads for a lightweight way to run large numbers of blocking I/O tasks.