Skip to content

Java 21 Concurrency and Virtual Threads

Step 15 of 20

Java 21 Concurrency: Virtual Threads, Executors and Thread Safety

Introduction

Java 21 concurrency combines virtual threads for blocking tasks, executors for lifecycle management, and synchronization for shared state. This 1Z0-830 lesson follows an order-processing service through threads, futures, locks, atomic counters and concurrent collections.

You will separate guaranteed behavior from scheduling accidents, distinguish a cancellation request from completed cancellation, and choose an explicit boundary for waiting, failure and shutdown. Code fragments assume the relevant java.util.concurrent imports and handling or declaration of checked exceptions.

Learning objectives

After this lesson you will be able to:

  • Create platform threads and virtual threads and start them with Runnable and Callable tasks.
  • Describe the states of a thread (NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED) and the effect of start, run, join, sleep, interrupt.
  • Choose and shut down the right ExecutorService (newFixedThreadPool, newCachedThreadPool, newSingleThreadExecutor, newScheduledThreadPool, newVirtualThreadPerTaskExecutor) and use Future.
  • Make code thread-safe with synchronized, ReentrantLock, ReadWriteLock, volatile and atomic classes.
  • Use concurrent collections (ConcurrentHashMap, CopyOnWriteArrayList, blocking queues) correctly.
  • Explain when a parallel stream is safe and when it produces wrong results.

1. Platform threads and virtual threads

A platform thread normally maps to an OS thread and consumes an implementation-dependent stack and OS resources. There is no universal Java limit of a few thousand threads. Virtual threads, final in Java 21, are JVM-scheduled lightweight threads suited to large numbers of mostly waiting tasks. They improve potential throughput rather than making a CPU calculation execute faster.

Java
Runnable task = () -> System.out.println("Hello from " + Thread.currentThread().isVirtual());

Thread t1 = new Thread(task);                       // platform thread, not started
Thread t2 = Thread.ofPlatform().name("worker-1").start(task);
Thread t3 = Thread.ofVirtual().name("v-1").start(task);
Thread t4 = Thread.startVirtualThread(task);
Thread t5 = Thread.ofVirtual().unstarted(task);     // created, not started
t1.start();
t5.start();

Key facts:

Fact Platform thread Virtual thread
Creation new Thread, Thread.ofPlatform() Thread.ofVirtual(), Thread.startVirtualThread, executor
Daemon A new Thread inherits daemon status from its creator; builders can configure it Always daemon; setDaemon(false) throws IllegalArgumentException
Priority 1 to 10 Always NORM_PRIORITY; changes are ignored
Name Thread-N Empty string by default
Pooling Pool them (expensive) Never pool them; create one per task
isVirtual() false true

Because virtual threads are daemon threads, the JVM exits when the last non-daemon thread ends, even if virtual threads are still running. A classic exam trap is a main method that starts a virtual thread with a sleep and returns: the output is not guaranteed unless main waits, for example with join. Scheduling can delay main too, so do not claim that the line can never appear.

Runnable versus Callable

Runnable has void run() and cannot throw checked exceptions. Callable<V> has V call() throws Exception and returns a value. A Thread accepts only a Runnable; to run a Callable you need an ExecutorService (or a FutureTask).

Java
Callable<Integer> stockCheck = () -> 42;        // returns Integer, may throw
Runnable log = () -> System.out.println("log"); // returns nothing

A lambda such as () -> { throw new Exception(); } is compatible with Callable but not with Runnable once it throws a checked exception. For an expression lambda such as () -> counter.incrementAndGet(), both targets are possible, and overload resolution of submit(Runnable) versus submit(Callable) picks Callable because the expression produces a value.

2. Thread lifecycle and basic methods

A thread has one of these states (Thread.State):

State Meaning
NEW Created, start() not called
RUNNABLE Running or ready to run
BLOCKED Waiting to enter a synchronized block
WAITING wait(), join() or LockSupport.park() without timeout
TIMED_WAITING sleep(ms), join(ms), wait(ms)
TERMINATED run finished or threw

Rules the exam likes:

  • start() runs run() in a new thread, once. Calling start() twice throws IllegalThreadStateException. For a platform thread created with a Runnable, calling run directly invokes that task in the caller and starts no new thread. Directly calling run on a virtual Thread does nothing in Java 21.
  • join waits for a started target to terminate; it does not start an unstarted thread. A successful join also establishes visibility of the completed thread’s actions.
  • Thread.sleep throws the checked InterruptedException and does not release any lock it holds. Object.wait releases the target object’s monitor, not every lock the thread holds, and reacquires that monitor before returning. It requires ownership or throws IllegalMonitorStateException. Always recheck a waiting condition in a loop because spurious wakeups are permitted.
  • interrupt() sets the interrupt flag. If the target is in sleep/wait/join, it throws InterruptedException and clears the flag. Good practice is to restore it with Thread.currentThread().interrupt() when you cannot propagate.
  • An uncaught exception in a thread kills only that thread; the other threads continue.
Java
Thread worker = new Thread(() -> {
    try {
        Thread.sleep(5_000);
    } catch (InterruptedException e) {
        System.out.println("interrupted, flag=" + Thread.currentThread().isInterrupted()); // false
    }
});
worker.start();
worker.interrupt();
worker.join();
System.out.println(worker.getState()); // TERMINATED

3. ExecutorService

Managing threads by hand does not scale. The Executors factory methods return an ExecutorService that owns the threads and queues tasks.

Factory Behavior Typical use
newFixedThreadPool(n) n threads, unbounded queue CPU-bound work
newCachedThreadPool() Creates threads on demand, reuses idle ones for 60 s Many short tasks
newSingleThreadExecutor() One thread, tasks run in submission order Serialized access
newScheduledThreadPool(n) Delayed and periodic tasks Timers
newVirtualThreadPerTaskExecutor() A new virtual thread per task, no pooling Blocking I/O
newWorkStealingPool() ForkJoinPool with parallelism = CPU count Divide and conquer

In Java 21, ExecutorService extends AutoCloseable: close initiates orderly shutdown and waits. If the waiting caller is interrupted, close requests shutdownNow, keeps waiting for active tasks to complete, then restores interrupt status. Tasks that ignore cancellation can therefore delay close indefinitely. That makes try-with-resources the idiomatic form:

Java
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Integer>> futures = new ArrayList<>();
    for (int i = 0; i < 1_000; i++) {
        int orderId = i;
        futures.add(executor.submit(() -> priceOf(orderId)));   // Callable<Integer>
    }
    int total = 0;
    for (Future<Integer> f : futures) {
        total += f.get();                // blocks; throws ExecutionException, InterruptedException
    }
    System.out.println(total);
}   // close() waits for tasks

Important methods:

  • execute(Runnable) returns nothing. submit(Runnable) returns Future<?> whose get() yields null. submit(Callable<T>) returns Future<T>.
  • Future.get waits for completion and wraps a task failure in ExecutionException; getCause retrieves the cause. A timed get can throw TimeoutException without cancelling the task. cancel(true) requests cancellation and may interrupt the executing thread; it does not guarantee that user code stops. A cancelled future’s get throws CancellationException. isDone() is true after completion, failure or cancellation.
  • shutdown stops accepting work while allowing submitted tasks to execute. The standard factory executors reject later submissions with RejectedExecutionException. shutdownNow attempts to stop active tasks, commonly by interruption, and returns queued tasks that never started. Neither method guarantees that interruption-unresponsive code terminates. awaitTermination(timeout, unit) waits for termination.
  • invokeAll(Collection<Callable>) waits for all and returns the futures in order. invokeAny returns the result of one successful task and cancels the others.
  • Default fixed, single-thread and scheduled executors can keep non-daemon workers alive after main returns. Cached-pool idle workers can time out; ForkJoinPool workers and virtual threads are daemons. Custom ThreadFactory choices can change platform-worker daemon status. Always manage the executor lifecycle explicitly.

Scheduling:

Java
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.schedule(() -> System.out.println("once"), 5, TimeUnit.SECONDS);
scheduler.scheduleAtFixedRate(() -> poll(), 0, 10, TimeUnit.SECONDS);      // start-to-start period
scheduler.scheduleWithFixedDelay(() -> poll(), 0, 10, TimeUnit.SECONDS);   // end-to-start delay

A failure escaping a periodic task suppresses later executions; inspect its ScheduledFuture to observe that failure. The same scheduled periodic task does not overlap itself, even at fixed rate: an overlong run delays the next execution. Fixed delay additionally waits after the run completes. The owner must cancel periodic work and shut down the scheduler during application shutdown.

4. Thread safety: the problem

When two threads update shared mutable state, the result depends on timing. In our order service, a shared counter of processed orders is incremented by 1,000 tasks:

Java
class Stats {
    private int processed;
    void inc() { processed++; }        // read, add, write: three steps
    int get() { return processed; }
}

processed++ is not atomic. Two threads can read 41, both write 42, and one update is lost. The correct total of 1,000 is not guaranteed. Sleeping before the read does not establish visibility or repair the lost updates. Three tools fix this: mutual exclusion (synchronized, locks), atomic variables, and immutability or confinement.

synchronized

Every object has a monitor. synchronized acquires it.

Java
class Stats {
    private int processed;
    synchronized void inc() { processed++; }          // locks `this`
    static synchronized void reset() { }              // locks Stats.class
    int get() { synchronized (this) { return processed; } }
}

Rules: the lock is re-entrant (the same thread can acquire it again); an instance synchronized method and a static synchronized method use different locks; a thread that cannot get the monitor is BLOCKED; synchronizing on a different object in each method protects nothing. Reads must use the same lock as writes, otherwise visibility is not guaranteed.

volatile

volatile guarantees visibility (a write is seen by later reads in other threads) and ordering, but not atomicity of compound actions. volatile boolean running is a fine stop flag; volatile int counter; counter++ is still a race.

Atomic classes

AtomicInteger, AtomicLong, AtomicBoolean and AtomicReference provide atomic operations with documented memory effects. They avoid writing a separate external lock for these single-variable updates:

Java
AtomicInteger processed = new AtomicInteger();
processed.incrementAndGet();                 // returns the new value
processed.getAndIncrement();                 // returns the old value
processed.updateAndGet(x -> x * 2);
processed.compareAndSet(10, 11);             // true only if current value is 10

Under heavy contention, LongAdder can improve statistical counter throughput, but sum is not an atomic snapshot during concurrent updates. Atomic update functions may be retried, so keep functions such as x -> x * 2 free of external side effects.

5. Explicit locks

java.util.concurrent.locks offers more control than synchronized.

Java
ReentrantLock lock = new ReentrantLock();
void transfer() {
    lock.lock();
    try {
        // critical section
    } finally {
        lock.unlock();           // always in finally
    }
}
Capability synchronized ReentrantLock
Release Automatic at end of block Manual unlock() in finally
Immediate try / bounded wait No tryLock() / tryLock(time, unit)
Interruptible wait No lockInterruptibly()
Fairness option No new ReentrantLock(true)
Multiple conditions One wait set newCondition()

Calling unlock() from a thread that does not hold the lock throws IllegalMonitorStateException. A lock acquired twice must be released twice (getHoldCount()).

ReentrantReadWriteLock has a read lock shared by many readers and an exclusive write lock. It suits read-heavy data such as a product-price cache. A thread holding the read lock cannot upgrade to the write lock (it would deadlock); downgrading from write to read is allowed.

Deadlock occurs when threads hold locks and wait for each other: thread A holds a and wants b, thread B holds b and wants a. Prevention: acquire locks in a global order, or use tryLock with a timeout. Livelock (threads keep reacting to each other without progress) and starvation (a thread never gets the resource) are the other liveness problems.

A caution on virtual threads: in Java 21, blocking inside a synchronized block pins the virtual thread to its carrier, wasting a carrier thread. Prefer ReentrantLock for long blocking sections in code that runs on virtual threads.

6. Concurrent collections

Ordinary collections (ArrayList, HashMap) are not thread-safe. Options:

Option Behavior
Collections.synchronizedList(list) Every method synchronized; iteration still needs manual locking
ConcurrentHashMap Fine-grained, no null keys or values, atomic putIfAbsent, compute, merge
CopyOnWriteArrayList Every write copies the array; iterators see a snapshot and never throw ConcurrentModificationException; good for few writes, many reads
ConcurrentLinkedQueue, ConcurrentLinkedDeque Non-blocking queues
BlockingQueue (ArrayBlockingQueue, LinkedBlockingQueue) put blocks when full, take blocks when empty; producer-consumer
Java
ConcurrentHashMap<String, Integer> stockBySku = new ConcurrentHashMap<>();
stockBySku.merge("SKU-1", 5, Integer::sum);           // atomic
// Not atomic: if (!map.containsKey(k)) map.put(k, v);  -> use putIfAbsent

Thread-safe collections specify which operations are atomic; do not assume that bulk operations, cross-key observations or a sequence of calls form one atomic action. Using a plain HashMap with get, then put, in several threads is a classic bug.

For CopyOnWriteArrayList, a snapshot iterator does not see later changes and does not support iterator.remove. A ConcurrentHashMap traversal is weakly consistent rather than a frozen snapshot.

Before choosing a concurrent replacement, review Java collection contracts and views.

7. Parallel streams

Parallel streams can partition work for concurrent evaluation; their API does not promise a particular worker pool or number of threads. It is safe when the pipeline is stateless, non-interfering and associative:

Java
long sum = IntStream.rangeClosed(1, 1_000_000).parallel().asLongStream().sum();   // correct

It is wrong when lambdas mutate shared state:

Java
List<Integer> result = new ArrayList<>();
IntStream.range(0, 1_000).parallel().forEach(result::add);   // race: lost elements or exception

The right form is collect(Collectors.toList()) or toList(). Other rules:

  • forEach on a parallel stream gives no order; forEachOrdered preserves the encounter order but reduces the benefit.
  • Reduction overloads with an identity need a neutral identity and associative functions. The overload without identity returns an Optional instead. Subtraction violates associativity, so a matching sequential and parallel result is not guaranteed.
  • findFirst respects order; findAny does not.
  • A stream must be used once. Reuse violates the contract and can be detected with IllegalStateException.
  • Measure parallel performance with realistic work and sources. Large arrays or ranges can split efficiently, but no collection size alone guarantees a speedup. Review valid Java stream reductions and collectors for the reduction contracts.

Worked example: fixing the lost updates

Statement. The shop service runs this code. The author expects 1000. What is printed, and what is the best fix?

Java
int[] count = {0};
try (ExecutorService ex = Executors.newFixedThreadPool(8)) {
    for (int i = 0; i < 1_000; i++) {
        ex.execute(() -> count[0]++);
    }
}
System.out.println(count[0]);

Step 1. close() waits for all tasks, so every increment has finished when println runs. There is no problem of waiting.

Step 2. count[0]++ is a non-atomic read-modify-write on state shared by eight threads, so updates can be lost.

Step 3. The program has a data race, so it does not guarantee the expected total. A run that happens to print 1,000 does not prove correctness.

Solution. Replace the array with an AtomicInteger and call incrementAndGet(). The output is then always 1000.

Why the other choices are worse. Making count volatile is impossible for an array element and would not give atomicity anyway. Switching to newSingleThreadExecutor() is correct but removes the parallelism you wanted. Wrapping println in synchronized fixes nothing because the race is in the increments.

Second worked example: Callable, Future and exceptions

Statement. What happens when this program runs?

Java
public static void main(String[] args) throws InterruptedException {
    ExecutorService ex = Executors.newSingleThreadExecutor();
    Future<Integer> f = ex.submit((Callable<Integer>) () -> {
        throw new IllegalStateException("boom");
    });
    try {
        System.out.println(f.get());
    } catch (ExecutionException e) {
        System.out.println(e.getCause().getMessage());
    }
    // Deliberately missing shutdown, for this lifecycle exercise.
}

Step 1. The explicit Callable<Integer> target selects the value-producing task overload, even though this task always throws.

Step 2. The exception is captured inside the Future; it does not propagate out of submit.

Step 3. f.get() rethrows it wrapped in ExecutionException, and the catch prints the cause message.

Solution. The program prints boom. Because ex.shutdown() is never called, the JVM does not exit: the pool thread is non-daemon and stays alive. This is the more subtle part of the question. The main method declares InterruptedException; production code should also manage interruption and close the executor.

Why the other choices fail. IllegalStateException is never thrown by get() itself, so a catch (IllegalStateException e) would not catch the failure. Nothing is printed from the task thread, since the exception is captured by the Future.

Common pitfalls and exam tips

  • Exam tip: ask first "is state shared, and is it mutated?". If yes, look for the synchronization; if none, the answer is usually "unpredictable".
  • Direct run on a platform thread invokes its task in the caller; it creates no new concurrency, although other existing threads can still interleave.
  • start() twice throws IllegalThreadStateException.
  • submit(Runnable).get() returns null.
  • A fixed pool that is not shut down prevents JVM exit. Virtual threads are daemons and do not.
  • ConcurrentHashMap rejects null; HashMap allows one null key.
  • volatile does not make i++ atomic.
  • A fresh monitor per invocation does not coordinate callers. A shared local reference can be a valid monitor; a public string literal risks interference from unrelated code.
  • unlock() must be in finally; forgetting it leaves the lock held forever.
  • Do not pool virtual threads; use newVirtualThreadPerTaskExecutor().
  • Parallel streams with side effects, forEach ordering assumptions and non-associative reduce are classic wrong answers.

Mini case study: a price-refresh job

Scenario. Every minute a job must refresh the prices of 5,000 products by calling a slow HTTP API (200 ms per call), store them in a shared map read by web requests, and log the number of failures. The first version used a fixed pool of 16 threads, a HashMap and an int failures field.

Questions. (1) Why is the first version unsafe? (2) How would you restructure it for Java 21? (3) How do you schedule it?

Solution.

  1. Concurrent HashMap mutation and unsynchronized reads are unsafe, and failures++ can lose updates. Ignoring overhead, 5,000 × 0.2 / 16 is about 62.5 seconds, already beyond a one-minute target. Whether jobs overlap depends on scheduling and whether the job waits for its submitted tasks. A single scheduled periodic action does not overlap itself.
  2. Use ConcurrentHashMap<String, BigDecimal> and an AtomicInteger failures (or LongAdder). Because the work is blocking I/O, use Executors.newVirtualThreadPerTaskExecutor() inside try-with-resources, submit one Callable per product and collect Futures. Bound calls with a Semaphore based on the API’s permitted concurrency and use request deadlines. At 100 concurrent calls, the idealized service-time floor is 5,000 × 0.2 / 100 = 10 seconds; real latency, rate limits and failures can make it longer. Release each acquired permit in finally. Observe each future and handle failures per product; honor interruption and cancel outstanding work when stopping the job.
  3. Use a ScheduledExecutorService with scheduleWithFixedDelay(job, 0, 1, TimeUnit.MINUTES) so the delay begins after the job, including its awaited tasks, completes. Log and handle recoverable failures at the scheduling boundary so they do not silently stop later runs. Treat interruption as a stop request rather than indiscriminately swallowing every throwable.

Key terms

  • Platform thread: a thread backed one-to-one by an OS thread.
  • Virtual thread: a JVM-scheduled lightweight daemon thread, suited to blocking tasks.
  • Race condition: a result that depends on the timing of unsynchronized access.
  • Monitor: the per-object lock used by synchronized.
  • Atomic: an operation that happens entirely or not at all, as seen by other threads.
  • Deadlock: threads waiting forever for each other's locks.
  • ExecutorService: an abstraction that runs submitted tasks on managed threads.
  • Future: a handle to the result of an asynchronous task.
  • Parallel stream: a pipeline eligible for partitioned concurrent execution; no particular pool or speedup is guaranteed.

Summary checklist

  • I can create platform and virtual threads and know virtual threads are daemons.
  • I know Runnable versus Callable and what Future.get() can throw.
  • I can list the thread states and the effects of start, join, sleep, interrupt.
  • I can choose between the Executors factory methods and shut them down correctly.
  • I know synchronized, volatile, atomics and ReentrantLock, and what each guarantees.
  • I recognize deadlock and know how to prevent it.
  • I know which concurrent collections to use and their null rules.
  • I can tell a safe parallel stream from an unsafe one.

Official sources