13.4 Concurrent Collections, Atomic Variables, Explicit Locks, and `CompletableFuture`
Key Takeaways
Atomic variables in java.util.concurrent.atomic employ hardware-level Compare-And-Swap (CAS) instructions to provide thread-safe, lock-free mutations.
ConcurrentHashMap provides high-concurrency read and write operations via bucket-level locking without throwing ConcurrentModificationException, unlike synchronized map wrappers that lock the entire table.
CopyOnWriteArrayList creates a new internal copy of the backing array on every write, providing immutable snapshot iterators ideal for read-heavy, low-mutation scenarios.
ReentrantLock provides explicit, interruptible, timed, and fair lock acquisition capabilities that exceed intrinsic monitors, requiring mandatory lock() outside and unlock() inside a finally block.
CompletableFuture enables reactive, non-blocking asynchronous pipelines, supporting transformation (thenApply), composition (thenCompose), combination (thenCombine), and robust fallback error handling (exceptionally, handle).
Concurrent Collections, Atomic Variables, Explicit Locks, and CompletableFuture
While intrinsic synchronization (synchronized) provides simple mutual exclusion, high-throughput applications require specialized concurrent primitives to minimize thread contention and maximize multi-core hardware saturation. Java SE 17 provides lock-free atomic variables, highly optimized concurrent collections, explicit lock abstractions, and composable asynchronous completion stages.
Lock-Free Atomic Variables and Compare-And-Swap (CAS)
The java.util.concurrent.atomic package provides classes such as AtomicInteger, AtomicLong, AtomicBoolean, and AtomicReference<V>. These classes support thread-safe operations on single variables without utilizing intrinsic locks.
Compare-And-Swap (CAS) Mechanics
Instead of blocking threads, atomic classes utilize CPU hardware instructions known as Compare-And-Swap (e.g., cmpxchg on x86 architectures). A CAS instruction operates on three operands:
- A memory location ().
- The expected old value ().
- The new value to write ().
The processor atomically updates to if and only if the current value at matches . If the value has changed in the interim, the operation fails, and the calling thread loops to retry the operation (optimistic retry loop):
AtomicInteger counter = new AtomicInteger(0);
// Atomic increment:
int nextVal = counter.incrementAndGet(); // Returns 1
// Conditional CAS update:
boolean updated = counter.compareAndSet(1, 10); // Sets to 10 if current is 1
// Atomic functional transformation:
int transformed = counter.updateAndGet(x -> x * 2); // 10 * 2 = 20
CAS eliminates thread suspension, context-switch overhead, and priority inversion, providing significantly higher throughput under low-to-moderate contention than intrinsic locks.
Concurrent Collections vs. Synchronized Wrappers
Legacy collections can be synchronized using factory methods such as Collections.synchronizedMap() or Collections.synchronizedList().
The Problem with Synchronized Wrappers
- Single Mutex Bottleneck: Synchronized wrappers wrap the underlying collection with a single, coarse-grained mutex lock. Every read and write operation contends for the same lock, serializing all access.
- Unsafe Iteration: Iterators over synchronized wrappers are not thread-safe unless the client code manually synchronizes on the collection wrapper during the entire iteration loop. Failure to do so risks
ConcurrentModificationException.
Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());
// MUST manually synchronize on syncMap during iteration:
synchronized (syncMap) {
for (String key : syncMap.keySet()) { /* ... */ }
}
High-Performance Concurrent Collections
Java provides modern concurrent collections designed from the ground up for multi-threaded workloads:
| Collection | Concurrency Mechanism | Iteration Behavior | Ideal Use Case |
|---|---|---|---|
ConcurrentHashMap | Bucket-level locks and CAS operations | Weakly consistent; never throws ConcurrentModificationException | General-purpose, high-concurrency key-value store |
CopyOnWriteArrayList | Clones backing array on every mutation | Snapshot view; cannot modify during iteration | Read-heavy, rarely modified lists (e.g., event listeners) |
ConcurrentLinkedQueue | Lock-free queue using CAS pointers | Weakly consistent | High-throughput, non-blocking FIFO queue |
ArrayBlockingQueue | Bounded array with explicit locks | Weakly consistent | Fixed-capacity producer-consumer pipelines |
LinkedBlockingQueue | Optionally bounded linked nodes | Weakly consistent | Variable-capacity producer-consumer pipelines |
Atomic Operations in ConcurrentHashMap
ConcurrentHashMap provides atomic compound methods that avoid external synchronization:
putIfAbsent(K, V): Inserts the value only if the key is not currently mapped.computeIfAbsent(K, Function): Computes and inserts a value atomically if the key is absent.computeIfPresent(K, BiFunction): Recomputes a mapping if the key is present.merge(K, V, BiFunction): Merges an existing value with a new value atomically.
CopyOnWriteArrayList Semantics
In a CopyOnWriteArrayList, all mutative operations (add, set, remove) create a fresh copy of the internal backing array. Readers iterate over an immutable snapshot taken when the iterator was constructed. Iterators over a CopyOnWriteArrayList do not support remove(); calling iterator.remove() throws UnsupportedOperationException.
Explicit Locks: ReentrantLock and ReentrantReadWriteLock
The java.util.concurrent.locks package provides explicit locking interfaces that offer advanced capabilities beyond intrinsic monitor blocks.
ReentrantLock Standard Idiom
Unlike synchronized blocks, explicit locks require manual acquisition and release. The call to lock() must occur immediately before the try block, and unlock() must reside in the finally block:
Lock lock = new ReentrantLock();
lock.lock(); // Acquire BEFORE try!
try {
// Critical section
} finally {
lock.unlock(); // Ensure lock is ALWAYS released
}
Capabilities Beyond Intrinsic Locks
- Non-Blocking Lock Acquisition:
lock.tryLock()attempts to acquire the lock immediately without waiting, returningtrueif successful andfalseotherwise. - Timed Lock Acquisition:
lock.tryLock(500, TimeUnit.MILLISECONDS)waits up to the timeout before giving up, breaking deadlock hazards. - Interruptible Acquisition:
lock.lockInterruptibly()acquires the lock unless the current thread is interrupted, responding promptly to cancellation. - Fairness Policies:
new ReentrantLock(true)creates a fair lock that grants access in FIFO order to the longest-waiting thread, eliminating thread starvation at the expense of throughput.
ReentrantReadWriteLock
Optimizes scenarios where read operations substantially outnumber write operations:
- Read Lock (
lock.readLock()): Multiple threads can hold the read lock simultaneously, provided no thread holds the write lock. - Write Lock (
lock.writeLock()): Mutually exclusive. Only one thread can hold the write lock, barring all other readers and writers. - Lock Downgrading vs. Upgrading: Holding the write lock and acquiring the read lock before releasing the write lock (downgrading) is fully supported. However, acquiring the write lock while holding a read lock (upgrading) is not supported and triggers deadlock.
Asynchronous Orchestration with CompletableFuture
Introduced in Java 8 and enhanced in Java 9 through 17, CompletableFuture<T> implements both Future<T> and CompletionStage<T>. It enables non-blocking, declarative, event-driven pipelines.
[supplyAsync] ---> .thenApply() ---> .thenCombine() ---> .exceptionally()
(Fetch Data) (Transform) (Join Another) (Fallback)
1. Initiating Asynchronous Pipelines
CompletableFuture.supplyAsync(Supplier<U>): Executes the supplier asynchronously onForkJoinPool.commonPool()and returnsCompletableFuture<U>.CompletableFuture.runAsync(Runnable): Executes aRunnableasynchronously, returningCompletableFuture<Void>.- Both methods offer overloads accepting a custom
Executor.
2. Chaining Stages (Transformations & Callbacks)
thenApply(Function<T, R>): Synchronously transforms the result of the previous stage into a new typeR.thenAccept(Consumer<T>): Consumes the output of the stage, returningCompletableFuture<Void>.thenRun(Runnable): Runs an action after the prior stage completes without consuming its output.- Async Variants (
thenApplyAsync, etc.): Execute the dependent stage on a different thread usingForkJoinPool.commonPool()or a designatedExecutor.
3. Composing and Combining Futures
thenCompose(Function<T, CompletableFuture<R>>): Flattens nested futures (analogous toflatMap), chaining dependent asynchronous calls.thenCombine(CompletionStage<U>, BiFunction<T, U, V>): Runs two independent stages in parallel and combines their results with aBiFunctionwhen both complete.CompletableFuture.allOf(CompletableFuture<?>... cfs): Returns a future that completes when all input futures complete.CompletableFuture.anyOf(CompletableFuture<?>... cfs): Returns a future that completes when any of the input futures completes.
4. Resilient Exception Handling
exceptionally(Function<Throwable, T>): Catches an unhandled exception thrown anywhere up the pipeline and returns a fallback recovery value.handle(BiFunction<T, Throwable, R>): Always executes upon stage completion, receiving either the computed result or the thrownThrowable.
A developer iterates through a CopyOnWriteArrayList and attempts to remove specific elements:
List<String> list = new CopyOnWriteArrayList<>(List.of("Alpha", "Beta", "Gamma"));
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String item = it.next();
if ("Beta".equals(item)) {
it.remove();
}
}
What is the result when this code executes?
The list successfully removes 'Beta', leaving ['Alpha', 'Gamma'].
A ConcurrentModificationException is thrown at runtime when it.next() is called after removal.
An UnsupportedOperationException is thrown at runtime on it.remove().
The code fails to compile because CopyOnWriteArrayList does not implement Iterable.
Consider a thread that acquires locks using a ReentrantReadWriteLock:
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();
readLock.lock();
try {
// Thread needs to perform an update
writeLock.lock(); // ATTEMPT LOCK UPGRADE
try {
System.out.println("Updated successfully");
} finally {
writeLock.unlock();
}
} finally {
readLock.unlock();
}
What happens when a thread executes this sequence?
The thread deadlocks with itself because ReentrantReadWriteLock does not support lock upgrading.
The read lock is automatically released before acquiring the write lock and re-acquired afterward.
The thread executes normally and prints 'Updated successfully'.
An IllegalMonitorStateException is thrown on writeLock.lock().
Examine the following asynchronous pipeline constructed with CompletableFuture:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("Service Error");
return "Success";
}).exceptionally(ex -> {
return "Recovered";
}).thenApply(String::toUpperCase);
System.out.println(future.join());
What is printed to the console?
A CompletionException wrapping RuntimeException is thrown.
RECOVERED
Success
Service Error
Sections you finish are checked off in the contents.