Concurrency means tasks can make progress during overlapping periods. Parallelism means work executes at the same instant on different processing resources. A service waiting for several responses can benefit from concurrency even when its calculations are small. Adding threads also creates coordination problems, so begin by identifying what truly needs to overlap.
This lesson targets Java 21. Virtual threads are a final feature in that baseline; structured concurrency is outside this course because its Java 21 API requires preview features. The example uses only stable executor, future and atomic APIs.
Threads, tasks and executors
A thread is an execution path. Calling a thread's start() schedules its work independently; calling its run() method directly is an ordinary call on the current thread. Runnable represents work without a returned result, while Callable<T> can return a result and throw a checked exception.
An executor manages task execution. Submitting a callable returns a Future<T>, a handle for waiting, retrieving a result, or requesting cancellation. Executors need an owner responsible for shutting them down.
Platform threads are tied to operating-system threads. Virtual threads are lightweight threads scheduled by the JDK and are useful for many tasks that spend time waiting on blocking I/O. They do not create extra CPU cores or make a single calculation inherently faster. Bound scarce external resources, such as database connections, separately rather than assuming cheap threads make every resource unlimited.
Run and join a small task group
Save as ConcurrentSquares.java, compile with javac --release 21 ConcurrentSquares.java, and run java ConcurrentSquares.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
public class ConcurrentSquares {
public static void main(String[] args)
throws InterruptedException, ExecutionException, TimeoutException {
AtomicInteger increments = new AtomicInteger();
List<Future<Integer>> futures = new ArrayList<>();
try (ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor()) {
try {
for (int number = 1; number <= 4; number++) {
int value = number;
futures.add(executor.submit(() -> {
for (int i = 0; i < 1_000; i++) {
increments.incrementAndGet();
}
Thread.sleep(20);
return value * value;
}));
}
int sum = 0;
for (Future<Integer> future : futures) {
sum += future.get(2, TimeUnit.SECONDS);
}
System.out.println("sum: " + sum);
System.out.println("increments: " + increments.get());
} finally {
for (Future<Integer> future : futures) {
future.cancel(true);
}
}
}
}
}
On successful completion, output is sum: 30 followed by increments: 4000. Task completion order is unspecified. The main thread alone updates sum, so that variable needs no synchronization. The artificial sleep demonstrates waiting; this is not a performance benchmark.
Future.get waits for completion and makes the task's published result visible to the caller. Task exceptions arrive wrapped in ExecutionException. A timeout bounds each individual wait here, not the whole program. The finally block requests cancellation of unfinished tasks. Cancellation with interruption is cooperative: code that ignores interruption may continue running.
In Java 21, closing an executor waits for its tasks to finish. Therefore a timeout followed by cancellation is not a hard shutdown deadline for uncooperative work. This example's sleep responds to interruption. Real I/O also needs suitable connection and operation timeouts.
Shared state and visibility
count++ is a read, calculation and write, not one atomic action. Two threads can both read the same old value and lose an increment. AtomicInteger.incrementAndGet performs that update atomically. It protects this counter, not arbitrary invariants across several fields.
A synchronized block permits one thread at a time to hold a particular object's monitor and establishes visibility between matching unlock and lock operations. All code protecting the same state must follow the same locking rule. Keep critical sections small and use a consistent order when acquiring several locks to avoid deadlock.
volatile provides visibility and ordering for accesses to a field; it does not turn count++ into an atomic update. Ordinary ArrayList and HashMap are not safe for concurrent mutation. Prefer task-local data and immutable results; use concurrent collections or explicit coordination where sharing is necessary.
Interruption, exercise and solution guidance
Propagate InterruptedException when your method can declare it, as this main does. If you must catch it without rethrowing, normally restore the flag with Thread.currentThread().interrupt() and stop the interrupted operation. Do not catch and ignore it while continuing indefinitely. Virtual threads are daemon threads, so launching work and immediately exiting main is not a substitute for joining it.
Change one task to throw IllegalStateException. Confirm that get reports ExecutionException with that cause and the program does not print a successful total. Next, lower a wait timeout and confirm that unfinished work is cancelled.
For solution guidance, inject failure inside the callable, not before submission. Inspect getCause() at a boundary that can report it. Keep cleanup in finally. Do not test correctness by expecting a particular task order or an exact elapsed time; verify results, reported failures and eventual shutdown.
Official references
- OpenJDK JEP 444: Virtual Threads.
- Java 21 ExecutorService API.
- Java 21 Future API.
- Java 21 AtomicInteger API.
Previous: Lambdas and Streams. Next: Testing and Debugging.

