
Concurrency lets work overlap, particularly while tasks wait for I/O. A reliable Java design also answers three questions: who owns the tasks, how long may they run, and which state do they share? This guide uses small executable examples to separate those decisions from the choice of thread implementation.
Choose a task model for the workload
On a narrow screen, scroll the table horizontally. Keyboard users can focus the table and use the arrow keys.
| Workload | Useful approach | Limit to establish |
|---|---|---|
| A few independent operations | An ExecutorService with explicit ownership | Task count, timeout behavior and shutdown. |
| Many operations spending time waiting for I/O | A virtual thread per task | Admission rate and capacity of downstream services. |
| CPU-heavy calculations | A bounded number of platform-thread workers | Available CPU capacity and queued work. |
| Shared mutable state | Atomic operations, ownership or a suitable lock | The complete invariant that must hold. |
Virtual threads became a final feature in Java 21. They make thread-per-task code practical for many blocking workloads, but they do not create extra processors or expand a database connection pool. A small fixed number of CPU workers is often a more direct starting point for sustained computation. JEP 444 explains the purpose and limitations.
This lab uses Java 25 and stable APIs. It submits only three tasks. A production service should also bound admitted work; an executor choice alone does not impose a sensible request limit. A fixed thread pool can still accumulate an unbounded queue if producers submit faster than workers complete.
Submit tasks and collect their results
Download both concurrency exercises and README (ZIP)
import java.util.ArrayList;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadsDemo {
public static void main(String[] args) throws Exception {
AtomicInteger completed = new AtomicInteger();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
var tasks = new ArrayList<Future<Integer>>();
for (int n = 1; n <= 3; n++) {
int value = n;
tasks.add(executor.submit(() -> {
Thread.sleep(100); // Simulated waiting, not a performance benchmark.
completed.incrementAndGet();
return value * value;
}));
}
try {
for (Future<Integer> task : tasks)
System.out.println(task.get(2, TimeUnit.SECONDS));
} finally {
for (Future<Integer> task : tasks)
if (!task.isDone()) task.cancel(true);
}
}
System.out.println("Completed: " + completed.get());
if (completed.get() != 3) throw new AssertionError("Missing work");
}
}javac ThreadsDemo.java RaceDemo.java
java ThreadsDemoExpect 1, 4 and 9 on separate lines, followed by Completed: 3. The tasks simulate waiting for 100 milliseconds, then calculate a square. That sleep makes the scenario understandable; it is not a speed measurement. Results print in submission order because the main thread walks the saved futures in that order. Execution and completion may occur in a different order.
Each task captures its own value. The shared counter uses AtomicInteger.incrementAndGet() so an update is indivisible. Future.get() waits for a result and makes the completed task's actions visible to the caller under the documented ExecutorService memory-consistency contract.
Treat timeout, cancellation and shutdown separately
The example gives each get call two seconds. That is a per-future wait, not a two-second deadline for the entire batch. A batch deadline should be computed once from a monotonic clock and the remaining allowance passed to each wait. A TimeoutException means the caller stopped waiting; it leaves the task running until cancellation or another termination condition takes effect.
The finally block requests cancellation for unfinished tasks. cancel(true) can interrupt a running task. Code that blocks in interruptible methods or checks interruption can cooperate; swallowing InterruptedException and continuing indefinitely defeats that design. If a method cannot propagate interruption, restore the interrupt status with Thread.currentThread().interrupt() and return or unwind according to its contract.
Try-with-resources closes the executor and waits for termination. Consequently, this example is not a hard wall-clock cutoff for arbitrary uncooperative tasks. Give network and database operations their own timeouts, and isolate work that requires an externally enforced limit. The Future API distinguishes cancellation state from normal results and failures.
If a task throws, get reports an ExecutionException; inspect its cause. Decide whether sibling tasks should continue, be cancelled or be retried under an explicit policy. Close the task scope even when a result fails.
Reproduce a lost update
An increment such as counter++ contains a read, calculation and write. Two threads can both read zero and both write one. The download's RaceDemo.java deliberately uses a barrier after the read so this lost update can be observed repeatably. Run java RaceDemo:
Forced lost update: 1
Atomic updates: 2Both jobs completed, but the unsynchronized shared array holds one. The atomic counter holds two. The barrier exists only to construct the example; it does not repair the shared counter. This makes the failure easier to reason about than repeatedly hoping a timing-sensitive test happens to fail.
Use an atomic operation for a single counter, or keep the entire related state transition under a common lock. Two independent atomic counters do not by themselves make a transfer between them atomic. Marking a field volatile improves visibility but does not combine the read and write of an increment into one operation. For multi-field state, define the invariant first, then choose coordination that covers it.
Recognize blocked work and verify the fix
A race produces a wrong result or inconsistent state; a deadlock leaves threads waiting in a cycle. For example, one task holds lock A while waiting for B, and another holds B while waiting for A. A consistent lock acquisition order removes that particular cycle. Keeping slow I/O outside a critical section can also reduce unnecessary blocking.
When progress stalls, capture several thread snapshots a short interval apart and compare stacks, held locks and requested locks. A single waiting thread can be healthy; repeated evidence of a cycle or an unchanged bottleneck is more informative. For virtual threads on HotSpot, include the newer thread-dump format described in the application diagnosis guide.
After a fix, verify the business outcome as well as task completion: expected count, unique identifiers, no duplicate side effects and a defined response when a dependency stalls. Keep the deterministic lost-update exercise as a lesson, and write application tests around real invariants rather than arbitrary sleeps.