Java Threads: Executors, Virtual Threads and Shared State - Yenra

Run bounded concurrency examples, understand cancellation and result ordering, and reproduce a lost update before fixing it.

Parallel ivory and teal channels carry navy work blocks through an amber coordination gate toward a shared platform.
Conceptual illustration: concurrent work needs clear coordination and a defined lifetime.

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.

Starting points for concurrent work
WorkloadUseful approachLimit to establish
A few independent operationsAn ExecutorService with explicit ownershipTask count, timeout behavior and shutdown.
Many operations spending time waiting for I/OA virtual thread per taskAdmission rate and capacity of downstream services.
CPU-heavy calculationsA bounded number of platform-thread workersAvailable CPU capacity and queued work.
Shared mutable stateAtomic operations, ownership or a suitable lockThe 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 ThreadsDemo

Expect 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: 2

Both 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.

Continue with a related task