Concurrency: Threads, Executors and Virtual Threads
Doing several things at once in Java: threads, executors and futures, virtual threads, shared state and synchronization, and the concurrent collections.
Why concurrency matters in Java
Most Java programs that operations teams meet are servers: a customer portal, a billing service, a message broker. They handle many requests at the same time, and each request spends most of its time waiting for a database or another service. Java has supported this from the start with threads: independent paths of execution inside one process that share its memory. The general ideas, such as concurrency versus parallelism, race conditions and deadlock, are explained in Programming Concepts. This page shows the Java tools for them.
All examples on this page are compact source files (see
The JDK, javac, java and Your First Program), so the classes of java.util.concurrent are
available without imports. In a normal class you would write
import java.util.concurrent.*;. Every example prints
its results in a fixed order after the threads have finished: the
order in which threads run is never guaranteed.
Threads: start and join
A thread runs a piece of code given as a Runnable, an
interface with one method run(), usually written as a
lambda.
The program below adds up two halves of a year's meter readings in
two threads and combines the results:
void main() throws InterruptedException {
int[] readings = {412, 388, 405, 397, 420, 431};
long[] totals = new long[2];
Thread first = new Thread(() -> totals[0] = sum(readings, 0, 3));
Thread second = Thread.ofPlatform().name("second-half").start(() -> totals[1] = sum(readings, 3, 6));
IO.println("first before start: " + first.getState());
first.start();
first.join();
second.join();
IO.println("first after join: " + first.getState());
IO.println("First half: " + totals[0] + " kWh");
IO.println("Second half: " + totals[1] + " kWh");
IO.println("Total: " + (totals[0] + totals[1]) + " kWh");
}
long sum(int[] values, int from, int to) {
long total = 0;
for (int i = from; i < to; i++) total += values[i];
return total;
}
first before start: NEW first after join: TERMINATED First half: 1205 kWh Second half: 1248 kWh Total: 2453 kWh
new Thread(runnable) creates a thread that has not
started yet (state NEW); the builder
Thread.ofPlatform(), added in Java 21, does the same
and also lets you set a name. start() asks the JVM to
run the code in a new thread and returns at once.
join() waits until the thread has finished. Joining is
also what makes the result safe to read: everything a thread wrote
before it ended is visible to the thread that joined it. Without
join(), main could print the totals before
they were computed.
The two threads write to different array elements, so they
never get in each other's way. Shared data that several threads
change is the hard part; see
Shared state and race conditions below.
join() and many other waiting methods throw the checked
InterruptedException, which is why
main declares it (see
Exceptions and Error Handling).
Platform threads
A platform thread is a Java
thread that is a thin wrapper around a thread of the operating
system: it holds that OS thread, with its own stack, for its whole
life. Before Java 21 this was the only kind of thread. Platform
threads are fully parallel, but each one costs memory and the
operating system schedules them, so a server can afford hundreds or
a few thousand of them, not millions. They appear as threads of the
java process in tools such as top -H (see
Running Java in Production).
A thread passes through a few states, which you will see again in thread dumps:
| State | Meaning |
|---|---|
NEW |
created, start() not called yet |
RUNNABLE |
running, or ready to run (also while it waits for network input inside the operating system) |
BLOCKED |
waiting to enter a synchronized block |
WAITING |
waiting without a time limit, for example in
join()
|
TIMED_WAITING |
waiting with a time limit, for example in
Thread.sleep()
|
TERMINATED |
finished |
start() versus run()
Calling run() yourself is a plain method call: the code
runs in the current thread, and no new thread is created. The name
of the current thread shows the difference:
void main() throws InterruptedException {
Thread poller = Thread.ofPlatform().name("meter-poller")
.unstarted(() -> IO.println("running in " + Thread.currentThread().getName()));
poller.run(); // a normal method call: runs in the current thread
poller.start(); // starts a new thread, which calls run()
poller.join();
}
running in main running in meter-poller
Executors and futures
Creating a thread per task by hand quickly becomes messy: who waits
for them, where do results and exceptions go, and how many run at
once? The package java.util.concurrent separates
what to do from how it runs. An
ExecutorService is a Java
interface for an object that accepts tasks and runs them on threads
it manages, often a fixed-size pool of reusable threads; for each
task it returns a Future through which you collect the
result or the exception later.
void main() throws Exception {
List<String> meters = List.of("E-001", "E-002", "G-001", "E-003");
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
List<Future<Integer>> results = new ArrayList<>();
for (String meter : meters) {
results.add(pool.submit(() -> readMeter(meter)));
}
for (int i = 0; i < meters.size(); i++) {
IO.println(meters.get(i) + ": " + results.get(i).get() + " kWh");
}
} // close() waits until every submitted task has finished
}
int readMeter(String meterId) throws InterruptedException {
Thread.sleep(50); // stands in for a slow network call
return switch (meterId) {
case "E-001" -> 412;
case "E-002" -> 388;
case "G-001" -> 97;
default -> 405;
};
}
E-001: 412 kWh E-002: 388 kWh G-001: 97 kWh E-003: 405 kWh
The pool has two threads, so two meters are read at the same time
and the other two tasks wait in the pool's queue.
submit() accepts a Callable, a task that
returns a value and may throw a checked exception; the lambda
() -> readMeter(meter) is one.
Future.get() blocks until that task's result is
available. Because the program walks through the futures in the
order of the list, the output is always in that order, whichever
task finished first.
Since Java 19 an ExecutorService is
AutoCloseable, so try-with-resources shuts it down and
waits for the remaining tasks. In older code you see the same done
by hand with shutdown() and
awaitTermination(…). A pool that is never shut down
keeps its threads alive, which can stop a program from exiting.
The factory methods of Executors you meet most:
| Factory method | What you get |
|---|---|
newFixedThreadPool(n) |
n platform threads; extra tasks wait in an
unbounded queue
|
newSingleThreadExecutor() |
one thread: tasks run one after another, in order |
newCachedThreadPool() |
creates threads as needed and reuses idle ones; no upper limit |
newScheduledThreadPool(n) |
runs tasks after a delay or at a fixed rate |
newVirtualThreadPerTaskExecutor() |
a new virtual thread for every task (see below) |
Running a batch with invokeAll
invokeAll submits a whole list of tasks and returns
when all of them are done, with the futures in the same order as the
tasks:
void main() throws Exception {
List<Callable<String>> checks = List.of(
() -> "billing-db: up",
() -> "meter-gateway: up",
() -> "portal: degraded");
try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
for (Future<String> f : pool.invokeAll(checks)) {
IO.println(f.get());
}
}
}
billing-db: up meter-gateway: up portal: degraded
Exceptions in tasks
An exception inside a task does not appear where it happened; the
pool catches it and stores it in the future. get() then
throws an ExecutionException whose cause is the
original exception:
void main() throws InterruptedException {
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
Future<Integer> ok = pool.submit(() -> Integer.parseInt("412"));
Future<Integer> broken = pool.submit(() -> Integer.parseInt("41x"));
for (Future<Integer> f : List.of(ok, broken)) {
try {
IO.println("reading: " + f.get());
} catch (ExecutionException e) {
IO.println("task failed: " + e.getCause());
}
}
}
}
reading: 412 task failed: java.lang.NumberFormatException: For input string: "41x"
If nobody handles it, the stack trace starts with the wrapper, and
the real problem is in the message after the colon (and in the
Caused by: section further down):
void main() throws Exception {
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
Future<Integer> broken = pool.submit(() -> Integer.parseInt("41x"));
IO.println("reading: " + broken.get());
}
}
Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.NumberFormatException: For input string: "41x"
Chaining work with CompletableFuture
A plain Future can only be waited for. A
CompletableFuture is a Java
future that you can also chain: you describe what should happen with
the result when it arrives (transform it, combine it with another
result, recover from an error), and every step runs as soon as its
input is ready. It plays the role of a JavaScript
promise or
a C#
Task.
void main() {
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
CompletableFuture<BigDecimal> usage =
CompletableFuture.supplyAsync(() -> new BigDecimal("412"), pool); // kWh
CompletableFuture<BigDecimal> tariff =
CompletableFuture.supplyAsync(() -> new BigDecimal("0.27"), pool); // euro per kWh
CompletableFuture<String> bill = usage
.thenCombine(tariff, BigDecimal::multiply) // when both are done
.thenApply(amount -> amount.setScale(2, RoundingMode.HALF_UP))
.thenApply(amount -> "C-10042 owes EUR " + amount);
IO.println(bill.join());
}
}
C-10042 owes EUR 111.24
supplyAsync starts a task on the given executor;
without one it uses a shared pool, the
ForkJoinPool.commonPool(), which is meant for short
computations, not for slow calls. thenApply transforms
a result, thenCombine joins two independent results,
and join() waits for the end of the chain, like
get() but without checked exceptions.
allOf(…) waits for a whole group and
anyOf(…) for the first one.
Errors travel down the chain and skip the steps that need a value.
exceptionally turns an error into a fallback value. The
error arrives wrapped in a CompletionException, which
is also what join() throws when nobody recovered:
void main() {
CompletableFuture<Integer> reading = CompletableFuture
.supplyAsync(() -> Integer.parseInt("41x"))
.exceptionally(error -> {
IO.println("fallback because of " + error.getCause().getClass().getSimpleName());
return 0;
});
IO.println("reading: " + reading.join());
CompletableFuture<Integer> failed = CompletableFuture.supplyAsync(() -> Integer.parseInt("41x"));
failed.join();
}
fallback because of NumberFormatException reading: 0 Exception in thread "main" java.util.concurrent.CompletionException: java.lang.NumberFormatException: For input string: "41x"
Long chains of thenApply, thenCompose and
exceptionally are hard to read and to debug: a stack
trace shows pool threads, not the code that started the chain. With
virtual threads, the next section, plain sequential code that simply
waits is often the clearer choice.
Virtual threads
A virtual thread is a lightweight Java thread, final since Java 21, that the JVM schedules itself: it runs on an ordinary platform thread, its carrier, only while it is doing work, and when it waits for input or output the JVM parks it and the carrier runs another virtual thread. A waiting virtual thread costs a little heap memory and no operating system thread, so a program can have hundreds of thousands of them.
The easiest way to use them is an executor that starts a new virtual thread for every task. Here 10,000 simulated meter polls each wait 200 milliseconds for an answer:
void main() throws Exception {
List<Future<Integer>> polls = new ArrayList<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int meter = 1; meter <= 10_000; meter++) {
int id = meter;
polls.add(executor.submit(() -> pollMeter(id)));
}
} // waits for all 10,000 virtual threads
long total = 0;
for (Future<Integer> poll : polls) total += poll.get();
IO.println("Meters polled: " + polls.size());
IO.println("Total usage: " + total + " kWh");
}
int pollMeter(int id) throws InterruptedException {
Thread.sleep(Duration.ofMillis(200)); // waiting for the meter's reply
return 300 + id % 50;
}
Meters polled: 10000 Total usage: 3245000 kWh
Because all 10,000 waits overlap, the program finishes in roughly
the time of one poll plus overhead (on the small test machine for
this site, the whole run took under two seconds). With a fixed pool
of 100 platform threads the same work would take at least 10,000 /
100 × 0.2 s = 20 seconds. The code inside the task is ordinary
blocking code: Thread.sleep here, in practice a JDBC
query or an HttpClient.send. The JDK's blocking
operations recognize that they run in a virtual thread and park it
instead of blocking the carrier.
You can also start a single virtual thread with the builder
Thread.ofVirtual(). Virtual threads are always daemon
threads: they do not keep the JVM alive, so wait for them with
join() or an executor.
void main() throws InterruptedException {
Thread virtual = Thread.ofVirtual().name("reader-1")
.start(() -> IO.println("reader-1 virtual: " + Thread.currentThread().isVirtual()));
virtual.join();
Thread platform = Thread.ofPlatform().name("reader-2")
.start(() -> IO.println("reader-2 virtual: " + Thread.currentThread().isVirtual()));
platform.join();
IO.println("main daemon? " + Thread.currentThread().isDaemon() + ", virtual daemon? " + virtual.isDaemon());
}
reader-1 virtual: true reader-2 virtual: false main daemon? false, virtual daemon? true
When virtual threads help
- They help with waiting, not with computing. A virtual thread is not faster than a platform thread. It lets many tasks wait at the same time, which is what servers that call databases and other services do. Ten CPU-heavy calculations still run only as fast as the number of cores allows.
- Do not pool them. A pool exists to reuse expensive threads; virtual threads are cheap, so create one per task and let it end.
-
Limit the resource, not the threads. If a
database accepts only 20 connections, 10,000 virtual threads will
queue for them. Use a connection pool or a
Semaphoreto cap how many tasks use a scarce resource at once (example below). -
Pinning. Older material warns that a virtual
thread waiting inside a
synchronizedblock stays pinned to its carrier. That was fixed in JDK 24 (JEP 491); on Java 25synchronizedis fine in virtual threads. Waiting inside native code can still pin.
void main() throws Exception {
Semaphore gatewaySlots = new Semaphore(3); // the meter gateway accepts 3 calls at a time
AtomicInteger busy = new AtomicInteger();
AtomicInteger maxBusy = new AtomicInteger();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
gatewaySlots.acquire();
try {
maxBusy.accumulateAndGet(busy.incrementAndGet(), Math::max);
Thread.sleep(10); // the call to the gateway
} finally {
busy.decrementAndGet();
gatewaySlots.release();
}
return null;
});
}
}
IO.println("Calls made: 100");
IO.println("More than 3 at the same time: " + (maxBusy.get() > 3));
}
Calls made: 100 More than 3 at the same time: false
Shared state and race conditions
Problems start when several threads change the same data. The
statement processed++ looks like one step, but it is
three: read the value, add one, write it back. When two threads read
the same old value, one of the two increments is lost. This program
lets four threads count 100,000 tickets each:
int processed = 0; // shared by all threads
void main() throws InterruptedException {
List<Thread> workers = new ArrayList<>();
for (int w = 0; w < 4; w++) {
workers.add(Thread.ofPlatform().start(() -> {
for (int i = 0; i < 100_000; i++) processed++; // read, add 1, write back
}));
}
for (Thread t : workers) t.join();
IO.println("Expected 400000, got " + processed);
}
Expected 400000, got 365779 Expected 400000, got 400000 Expected 400000, got 314164
This is a
race condition, and the second run shows why it is so dangerous: sometimes the
result is right. A test can pass a hundred times and the bug still
appears in production under load. (This example is not run by the
site's checker, because its output differs on every run.) There is a
second, subtler problem: without synchronization, Java does not even
promise that one thread ever sees a value another thread
wrote. The Java Memory Model only guarantees visibility through
synchronization actions such as locks, volatile
fields, starting and joining threads, and the concurrent classes
below.
synchronized
synchronized is the Java keyword that makes a method or block a critical section: a thread must hold the object's built-in lock (its monitor) to run it, so only one thread at a time runs synchronized code on the same object, and everything written inside is visible to the next thread that takes the lock.
void main() throws InterruptedException {
TicketCounter counter = new TicketCounter();
List<Thread> workers = new ArrayList<>();
for (int w = 0; w < 4; w++) {
workers.add(Thread.ofPlatform().start(() -> {
for (int i = 0; i < 100_000; i++) counter.increment();
}));
}
for (Thread t : workers) t.join();
IO.println("Processed: " + counter.get());
}
class TicketCounter {
private int processed = 0;
synchronized void increment() { processed++; } // one thread at a time per object
synchronized int get() { return processed; }
}
Processed: 400000
A synchronized method locks this; a block
synchronized (lock) { … } locks any object you choose.
Note that get() is synchronized too: reading needs the
lock for visibility, not only writing. Threads that wait for the
lock are in the state BLOCKED. Two threads that each
hold one lock and wait for the other's are in a
deadlock; a thread dump reports it (see
Running Java in Production). The usual prevention is to always take locks in the same order.
The class ReentrantLock in
java.util.concurrent.locks does the same as
synchronized with extra options, such as waiting for a
lock with a time limit.
Atomic variables
For a single counter or flag, a lock is more than you need. The
classes in java.util.concurrent.atomic, such as
AtomicInteger, AtomicLong and
AtomicReference, do read-modify-write operations as one
indivisible step using special processor instructions. The next
example also uses a concurrent map, explained in the next section:
void main() throws InterruptedException {
AtomicInteger processed = new AtomicInteger();
ConcurrentHashMap<String, Integer> perTeam = new ConcurrentHashMap<>();
List<String> teams = List.of("NOC", "Billing", "Service desk", "NOC");
List<Thread> workers = new ArrayList<>();
for (String team : teams) {
workers.add(Thread.ofVirtual().start(() -> {
for (int i = 0; i < 25_000; i++) {
processed.incrementAndGet();
perTeam.merge(team, 1, Integer::sum); // atomic read-modify-write per key
}
}));
}
for (Thread t : workers) t.join();
IO.println("Processed: " + processed.get());
IO.println("Per team: " + new TreeMap<>(perTeam));
}
Processed: 100000
Per team: {Billing=25000, NOC=50000, Service desk=25000}
The map is copied into a TreeMap only to print it in a
fixed, sorted order; a hash map's own order is not defined (see
Arrays and Collections).
Under heavy contention, LongAdder counts faster than
AtomicLong.
Concurrent collections
The ordinary collections, ArrayList and
HashMap, must not be changed by several threads at
once: updates get lost, and in the worst case the internal structure
breaks. java.util.concurrent has thread-safe versions.
A ConcurrentHashMap is a
thread-safe Java hash map that many threads can read and update at
the same time without one global lock; its compound methods, such as
merge, compute and
computeIfAbsent, update one key atomically. Use them
instead of "check, then act" code:
if (!map.containsKey(k)) map.put(k, v) is two separate
steps, and another thread can slip in between them, even on a
concurrent map.
| Class | Use it for |
|---|---|
ConcurrentHashMap |
shared lookup tables, counters per key, caches |
CopyOnWriteArrayList |
lists that are read very often and changed rarely, such as a list of listeners |
ArrayBlockingQueue,
LinkedBlockingQueue
|
handing work from producer threads to consumer threads |
ConcurrentLinkedQueue |
a non-blocking queue shared by many threads |
A BlockingQueue connects threads that produce work with
threads that consume it. put waits while the queue is
full and take waits while it is empty, so a fast
producer cannot run far ahead of a slow consumer:
void main() throws InterruptedException {
BlockingQueue<String> queue = new ArrayBlockingQueue<>(2); // holds at most 2 items
Thread producer = Thread.ofPlatform().start(() -> {
try {
for (String reading : List.of("E-001;412", "E-002;388", "G-001;97", "STOP")) {
queue.put(reading); // waits while the queue is full
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
String item;
while (!(item = queue.take()).equals("STOP")) { // waits while the queue is empty
IO.println("stored " + item);
}
producer.join();
IO.println("done");
}
stored E-001;412 stored E-002;388 stored G-001;97 done
The special value "STOP" tells the consumer that no
more work will come; such a marker is often called a
poison pill. The legacy classes Vector and
Hashtable are synchronized too, but they lock the whole
collection for every call and are not used in new code.
Stopping work: interrupts and timeouts
Java cannot kill a thread from the outside.
Thread.stop() was unsafe; on Java 25 it always throws
UnsupportedOperationException, and JDK 26 removed it.
Instead, you interrupt a thread: a polite request that sets
a flag and wakes the thread from sleep,
wait, join or a blocking queue with an
InterruptedException. The thread decides how to stop
cleanly:
void main() throws Exception {
CountDownLatch started = new CountDownLatch(1);
Thread export = Thread.ofPlatform().name("report-export").start(() -> {
started.countDown();
try {
Thread.sleep(Duration.ofMinutes(10)); // a very slow export
IO.println("export finished");
} catch (InterruptedException e) {
IO.println("export interrupted, cleaning up");
}
});
started.await(); // wait until the export is running
export.interrupt(); // ask it to stop
export.join();
IO.println("state: " + export.getState());
}
export interrupted, cleaning up state: TERMINATED
For a task in an executor, the same works through its future:
get with a time limit throws a
TimeoutException when the result is late, and
cancel(true) interrupts the thread that runs the task.
Anything that talks to another system needs such a time limit,
otherwise one hanging service can tie up all your threads.
void main() throws Exception {
try (ExecutorService pool = Executors.newFixedThreadPool(1)) {
Future<String> report = pool.submit(() -> {
Thread.sleep(Duration.ofMinutes(10));
return "report ready";
});
try {
IO.println(report.get(100, TimeUnit.MILLISECONDS));
} catch (TimeoutException e) {
IO.println("no answer within 100 ms, canceling");
report.cancel(true); // true = interrupt the thread running it
}
IO.println("canceled: " + report.isCancelled());
}
}
no answer within 100 ms, canceling canceled: true
Concurrency in practice
In real applications you mostly use concurrency through a framework:
a web server handles each request on its own thread (in recent
versions of Spring Boot and other frameworks this can be a virtual
thread, switched on in configuration), and a connection pool limits
database access. What you write yourself is usually small: a handful
of parallel calls with an executor, a shared cache in a
ConcurrentHashMap, a scheduled job. A short checklist:
- Prefer executors and futures over raw threads, and virtual threads for tasks that wait.
- Keep shared, changing state small, and protect what remains with one clear mechanism: a lock, an atomic, or a concurrent collection.
- Give every pool and thread a meaningful name; it is what you see in logs and thread dumps.
-
Put a time limit on every call to another system, and handle
InterruptedExceptionproperly. - Make sure exceptions in tasks are collected or logged, never lost in an unread future.
When a concurrent program misbehaves in production (it hangs, slows down or uses all CPUs), the first tool is a thread dump: see Running Java in Production.