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
RunnableandCallabletasks. - Describe the states of a thread (
NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED) and the effect ofstart,run,join,sleep,interrupt. - Choose and shut down the right
ExecutorService(newFixedThreadPool,newCachedThreadPool,newSingleThreadExecutor,newScheduledThreadPool,newVirtualThreadPerTaskExecutor) and useFuture. - Make code thread-safe with
synchronized,ReentrantLock,ReadWriteLock,volatileand 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.
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).
Callable<Integer> stockCheck = () -> 42; // returns Integer, may throw
Runnable log = () -> System.out.println("log"); // returns nothingA 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()runsrun()in a new thread, once. Callingstart()twice throwsIllegalThreadStateException. 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.sleepthrows the checkedInterruptedExceptionand 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 insleep/wait/join, it throwsInterruptedExceptionand clears the flag. Good practice is to restore it withThread.currentThread().interrupt()when you cannot propagate.- An uncaught exception in a thread kills only that thread; the other threads continue.
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()); // TERMINATED3. 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:
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 tasksImportant methods:
execute(Runnable)returns nothing.submit(Runnable)returnsFuture<?>whoseget()yieldsnull.submit(Callable<T>)returnsFuture<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.invokeAnyreturns 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:
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 delayA 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:
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.
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:
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 10Under 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.
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 |
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 putIfAbsentThread-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:
long sum = IntStream.rangeClosed(1, 1_000_000).parallel().asLongStream().sum(); // correctIt is wrong when lambdas mutate shared state:
List<Integer> result = new ArrayList<>();
IntStream.range(0, 1_000).parallel().forEach(result::add); // race: lost elements or exceptionThe right form is collect(Collectors.toList()) or toList(). Other rules:
forEachon a parallel stream gives no order;forEachOrderedpreserves 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.
findFirstrespects order;findAnydoes 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?
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?
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 throwsIllegalThreadStateException.submit(Runnable).get()returnsnull.- A fixed pool that is not shut down prevents JVM exit. Virtual threads are daemons and do not.
ConcurrentHashMaprejectsnull;HashMapallows onenullkey.volatiledoes not makei++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 infinally; forgetting it leaves the lock held forever.- Do not pool virtual threads; use
newVirtualThreadPerTaskExecutor(). - Parallel streams with side effects,
forEachordering assumptions and non-associativereduceare 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.
- 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.
- Use
ConcurrentHashMap<String, BigDecimal>and anAtomicInteger failures(orLongAdder). Because the work is blocking I/O, useExecutors.newVirtualThreadPerTaskExecutor()inside try-with-resources, submit oneCallableper product and collectFutures. 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. - Use a
ScheduledExecutorServicewithscheduleWithFixedDelay(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
RunnableversusCallableand whatFuture.get()can throw. - I can list the thread states and the effects of
start,join,sleep,interrupt. - I can choose between the
Executorsfactory methods and shut them down correctly. - I know
synchronized,volatile, atomics andReentrantLock, and what each guarantees. - I recognize deadlock and know how to prevent it.
- I know which concurrent collections to use and their
nullrules. - I can tell a safe parallel stream from an unsafe one.
Official sources
-
Oracle exam page 1Z0-830 (objectives): https://education.oracle.com/java-se-21-developer-professional/pexam_1Z0-830 (opens in a new tab)
-
Java SE 21
java.util.concurrentpackage: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/package-summary.html (opens in a new tab) -
Virtual threads (Java SE 21 core libraries guide): https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html (opens in a new tab)
-
ThreadAPI: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.html (opens in a new tab) -
ExecutorServiceAPI: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ExecutorService.html (opens in a new tab) -
JEP 444, Virtual Threads: https://openjdk.org/jeps/444 (opens in a new tab)
-
Future cancellation and result: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/Future.html (opens in a new tab)
-
Periodic scheduling: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ScheduledExecutorService.html (opens in a new tab)
-
AtomicInteger: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/atomic/AtomicInteger.html (opens in a new tab)
-
Java memory model: https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html (opens in a new tab)
