Managing Concurrent Code Execution
Introduction
This lesson covers the eighth objective of the Oracle Certified Professional Java SE 21 Developer exam (1Z0-830): Managing Concurrent Code Execution. Oracle lists three skills under it: creating platform and virtual threads with Runnable and Callable, managing thread lifecycles with the different ExecutorService implementations and the concurrent APIs, and developing thread-safe code with locking mechanisms while processing collections concurrently with parallel streams. Oracle does not publish a weighting per objective, but concurrency questions are usually code snippets where you must predict the output, decide whether the program terminates, or choose the correct statement among several.
Concurrency is hard to learn from definitions alone, so this lesson builds one running example: an order-processing service for an online shop that receives 1,000 orders, checks stock, computes totals and writes confirmations. We improve it step by step: first with raw threads, then with executors, then with virtual threads, then with locks and atomic classes.
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 is a thin wrapper around an operating-system thread. It is created by new Thread(...) and costs roughly one megabyte of stack plus an OS resource, so an application can run a few thousand at most. A virtual thread (final in Java 21, JEP 444) is a lightweight thread scheduled by the JVM on a small pool of carrier platform threads. Millions can exist. They are meant for tasks that mostly wait (network, database, files).
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 | Non-daemon by default (inherits) | 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 program ends before the output appears unless main calls join().
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 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()runsrun()in a new thread, once. Callingstart()twice throwsIllegalThreadStateException. Callingrun()directly executes in the current thread and starts nothing.join()blocks the caller until the target thread isTERMINATED.Thread.sleepthrows the checkedInterruptedExceptionand does not release any lock it holds.Object.wait()releases the monitor and must be called while owning it, elseIllegalMonitorStateException.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()); // 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 |
Since Java 19, ExecutorService extends AutoCloseable: close() calls shutdown() and waits for submitted tasks to finish. 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 tasks
Important methods:
execute(Runnable)returns nothing.submit(Runnable)returnsFuture<?>whoseget()yieldsnull.submit(Callable<T>)returnsFuture<T>.Future.get()blocks and wraps a task failure inExecutionException(the original isgetCause()).get(timeout, unit)throwsTimeoutException.cancel(true)interrupts.isDone()is true after completion, failure or cancellation.shutdown()stops accepting tasks but lets queued ones finish; latersubmitthrowsRejectedExecutionException.shutdownNow()interrupts running tasks and returns the list of tasks never started.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.- A pool that is never shut down keeps its non-daemon threads alive, so the program does not exit (the virtual-thread executor is the exception, because its threads are daemons).
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 delay
If a periodic task throws, subsequent executions are suppressed silently.
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 final value is unpredictable but never greater than 1,000. 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, AtomicReference<V> use compare-and-set hardware instructions without locking:
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
For counters updated by many threads and rarely read, LongAdder scales better.
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 |
| Try without waiting | 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 putIfAbsent
Individual operations of a thread-safe collection are atomic, but a sequence of two calls is not. Using a plain HashMap with get, then put, in several threads is a classic bug.
7. Parallel streams
list.parallelStream() or stream.parallel() splits the work on the common ForkJoinPool. It is safe when the pipeline is stateless, non-interfering and associative:
long sum = IntStream.rangeClosed(1, 1_000_000).parallel().asLongStream().sum(); // correct
It 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 exception
The 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.reduceneeds an identity and an associative accumulator.reduce(0, (a, b) -> a - b)is not associative, so a parallel result differs from a sequential one.findFirstrespects order;findAnydoes not.- A stream is consumed once; reusing it throws
IllegalStateException. - Parallelism pays off with large data and cheap-to-split sources (
ArrayList, arrays, ranges), not with small lists orLinkedList.
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 printed value is between 1 and 1,000, usually less than 1,000, and may vary between runs.
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?
ExecutorService ex = Executors.newSingleThreadExecutor();
Future<Integer> f = ex.submit(() -> { if (true) throw new IllegalStateException("boom"); return 1; });
try {
System.out.println(f.get());
} catch (ExecutionException e) {
System.out.println(e.getCause().getMessage());
}
Step 1. The lambda has a return statement with a value, so it matches Callable<Integer>.
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. (get() also declares InterruptedException, so the enclosing method must handle or declare it for the code to compile.)
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".
run()instead ofstart()runs in the caller's thread; the output order is then deterministic.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.synchronizedon a local object, or on a string literal shared by other code, protects nothing or too much.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.
- The
HashMapis mutated by several threads while web requests read it, which can corrupt it or lose entries;failures++loses updates. With 16 threads and 200 ms calls the job takes about 5,000 × 0.2 / 16 ≈ 62 seconds, so runs can overlap. - 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. With virtual threads all 5,000 calls overlap, and the job takes a few seconds, limited by the remote API; protect the API with aSemaphoreif it caps concurrency. HandleExecutionExceptionper product so one failure does not abort the others. - Use a
ScheduledExecutorServicewithscheduleWithFixedDelay(job, 0, 1, TimeUnit.MINUTES)so a slow run never overlaps the next one, and catch all exceptions inside the job, since an uncaught exception cancels further runs.
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 stream pipeline executed on several threads of the common
ForkJoinPool.
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)