From the network we come back home: inside each PideYa service there are dozens of threads handling requests at once, and they all share the same memory. Here the failures are not visible timeouts but race conditions: bugs that show up once in a thousand runs and vanish the moment you add a log line. In 02-02 we presented the Singleton's double-checked locking almost as an incantation; this lesson finally provides the foundation, and with it a catalog of patterns so threads can cooperate without stepping on each other. Distribution across processes was covered in the previous lesson; here everything happens inside one JVM.
Contents
- The problem: shared mutable state
- Double-checked locking, now with the foundation
- Immutability as a pattern
- State confinement
- Producer-Consumer with
BlockingQueue - Thread Pool / Worker with
ExecutorService - Future, Promise, and
CompletableFuture - Read-Write Lock
- Active Object (a mention) and Java 21 virtual threads
- Guide: when to use what
The problem: shared mutable state
Two threads execute orderCounter++ at the same time. That line is three operations (read, add, write); if they interleave, an increment is lost. Worse still: even without interleaving, one thread may not see what another wrote, because each core caches memory and the compiler reorders instructions. The Java Memory Model only guarantees visibility between threads when there is a happens-before relationship: it is created by synchronized, volatile, the java.util.concurrent classes, and thread start/join.
The danger equation is: shared state + mutability + concurrency. Every pattern in this lesson removes at least one factor:
| Pattern | Factor it removes |
|---|---|
| Immutability | The mutability |
| Confinement | The sharing |
| Producer-Consumer, Active Object | The simultaneous access (serializes through queues) |
| Locks (including Read-Write) | The simultaneous access (excludes) |
Double-checked locking, with the foundation
Recall the lazy Singleton for PideYaConfig:
public class PideYaConfig {
private static volatile PideYaConfig instance; // volatile: essential
public static PideYaConfig getInstance() {
if (instance == null) { // 1st check, no lock (fast)
synchronized (PideYaConfig.class) {
if (instance == null) { // 2nd check, holding the lock
instance = new PideYaConfig();
}
}
}
return instance;
}
}Now we can explain why every piece is there. Without volatile, the JVM may reorder: publishing the instance reference before the constructor finishes. Another thread would pass the first check and use a half-built object — the quintessential ghost bug. volatile forbids that reordering and guarantees visibility (happens-before between the write and the reads). The first check avoids paying for the lock in 99.9% of calls; the second prevents double creation among threads that were waiting on the lock. And the moral of 02-02 still stands: the holder idiom or an enum achieve the same without writing any of this.
Immutability as a pattern
The immutable object is the cheapest concurrency pattern there is: what never changes can be shared among a thousand threads without a single lock. Java has been making it easier:
// A record is final, with final fields: immutable by construction
public record OrderLine(String dishId, int quantity, BigDecimal price) { }
public final class Order {
private final List<OrderLine> lines;
private Order(Builder b) {
// List.copyOf: immutable copy — neither the builder nor anyone else can mutate it afterwards
this.lines = List.copyOf(b.lines);
}
public List<OrderLine> getLines() { return lines; } // safe: it is immutable
}That List.copyOf we put in 02-05's Order.Builder as a "good practice" was actually a concurrency pattern: the defensive copy that makes Order shareable. And what if the order "changes"? You don't mutate it: you create another order (as clone() did in Prototype, 02-06) or, better, you record the change as an event — the connection with 06-01's Event Sourcing. The objects traveling through 06-03's topics had to be immutable for this very reason.
State confinement
If a piece of data is touched by a single thread, it needs no synchronization even if it is mutable. Ways to confine:
- Stack confinement: local variables; each thread has its own stack. A local
StringBuilderneeds no locks. - Thread confinement:
ThreadLocal<T>gives each thread its own copy (that is how Spring stores the current transaction). - Confinement by design: a single thread owns the structure and everyone else asks it for things via messages — which is exactly the next pattern.
Producer-Consumer with BlockingQueue
PideYa's kitchen tickets: the web threads confirming orders (producers) must not wait for the kitchen to process; they drop the ticket in a queue and move on.
BlockingQueue<KitchenTicket> kitchenQueue = new LinkedBlockingQueue<>(100); // bounded capacity
// Producer (web thread): if the queue is full, put() BLOCKS → backpressure
public void confirmOrder(Order o) throws InterruptedException {
kitchenQueue.put(new KitchenTicket(o.getId(), o.getLines()));
}
// Consumer (kitchen thread): take() blocks until a ticket is available
Runnable cook = () -> {
while (!Thread.currentThread().isInterrupted()) {
try {
KitchenTicket t = kitchenQueue.take();
prepareTicket(t); // only THIS thread touches the ticket's state
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag and exit
}
}
};The BlockingQueue encapsulates all the synchronization: put and take are safe and block when they should. The bounded capacity (100) is the important design decision: if the kitchen can't keep up, producers slow down (backpressure) instead of exhausting memory. It is the intra-process version of 06-03's message queues: same intent, no network or broker — and none of their durability guarantees: if the process dies, the in-memory queue is gone.
Thread Pool / Worker with ExecutorService
Creating one thread per task is expensive (memory and startup) and dangerous (a traffic spike creates ten thousand threads and takes down the JVM). The Thread Pool pattern reuses a fixed set of workers that consume tasks from an internal queue — it is a packaged Producer-Consumer:
// Sized for order processing:
// I/O-bound tasks (DB, network) → more threads than cores; pure-CPU tasks → ~number of cores
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService orderPool = Executors.newFixedThreadPool(cores * 4);
orderPool.submit(() -> processOrder(order)); // enqueue a task, don't create a threadClassic sizing rule: threads ≈ cores × (1 + waitTime/cpuTime). Processing a PideYa order spends ~75% of its time waiting on the DB and Payments, hence the × 4. A pool for pure CPU (computing delivery routes) would stay at cores. And notice: keeping separate pools for "calls to Payments" and "calls to Catalog" is exactly 06-03's bulkhead, local edition.
Future, Promise, and CompletableFuture
A Future<T> is a claim ticket: "the result will arrive; meanwhile, get on with your life". A promise is the writable side of that ticket. In Java both roles are played by CompletableFuture, which additionally lets you compose asynchronous steps. PideYa's checkout: charging and reserving stock don't depend on each other — in the sequential monolith their latencies added up; in parallel, you only pay for the larger one:
CompletableFuture<ChargeResult> charge =
CompletableFuture.supplyAsync(() -> gateway.charge(order, card), orderPool);
CompletableFuture<Reservation> stock =
CompletableFuture.supplyAsync(() -> warehouse.reserve(order.getLines()), orderPool);
// Combine both results when BOTH complete
CompletableFuture<OrderConfirmation> confirmation =
charge.thenCombine(stock, (r, s) -> confirm(order, r, s))
.orTimeout(3, TimeUnit.SECONDS) // 06-03's timeout, local
.exceptionally(ex -> compensateAndReject(order, ex));Read it as a declarative pipeline: supplyAsync launches each task on the pool, thenCombine waits for both and combines, orTimeout bounds the wait, and exceptionally is the plan B (which here must compensate: if the charge went through but the stock failed, you must refund — 06-02's saga logic in miniature). Nothing blocks the calling thread: callbacks chain the way Decorators chained behavior.
Read-Write Lock
PideYa's menu (the Composite from 03-04) is read on every request from every customer and written a handful of times a day. A plain synchronized serializes the reads against each other too — a waste, because reading in parallel is harmless. The Read-Write Lock makes the distinction:
public class SharedMenu {
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private MenuSection root;
public Menu findMenu(String id) {
lock.readLock().lock(); // N readers can enter AT THE SAME TIME
try { return root.find(id); }
finally { lock.readLock().unlock(); }
}
public void update(MenuSection newRoot) {
lock.writeLock().lock(); // the writer enters ALONE, no readers
try { this.root = newRoot; }
finally { lock.writeLock().unlock(); }
}
}Many simultaneous readers, writers in exclusivity. An even better alternative when writes are that rare: publish an immutable menu and have update swap the reference (volatile or AtomicReference) — copy-on-write, which combines patterns 3 and 8 of this lesson and blocks nobody.
Active Object and virtual threads
Active Object (POSA): an object with its own thread and its own request queue; the public methods don't execute, they enqueue, and they return futures. It is Command + Producer-Consumer + Future in one package: that is how DispatchCenter could be implemented so all its state stays confined to a single thread. We leave it at a mention: you already know every piece of the combination.
Virtual threads (Java 21): extremely cheap threads managed by the JVM (millions, not thousands), which release the platform thread when they block on I/O. What they change: the "one thread per request" style with sequential blocking code becomes viable again — less need to chain CompletableFuture just to scale (it is still worthwhile to parallelize, like charge+stock). What they do not change: nothing about correctness — races, visibility, locks, and immutability remain exactly the same. This lesson's patterns don't expire with them.
Guide: when to use what
| Situation in PideYa | Pattern | Java tool |
|---|---|---|
| Data traveling between threads | Immutability | record, List.copyOf, final |
| Per-request auxiliary state | Confinement | Local variables, ThreadLocal |
| Decoupled work with backpressure (kitchen tickets) | Producer-Consumer | Bounded BlockingQueue |
| Many tasks, controlled threads | Thread Pool | Properly sized ExecutorService |
| Independent steps in parallel (charge + stock) | Future/Promise | CompletableFuture.thenCombine |
| Read a lot, written rarely (menu) | Read-Write Lock / copy-on-write | ReentrantReadWriteLock, AtomicReference |
| Complex state with a single owner | Active Object | Own thread + queue + futures |
| Thousands of blocking I/O requests | Virtual threads | Executors.newVirtualThreadPerTaskExecutor() |
| A lone shared counter or reference | (atomics) | AtomicInteger, LongAdder |
Common Mistakes and Tips
- Synchronizing "just in case" or not synchronizing "because it works": both stem from not reasoning about the memory model. Systematic question: which threads touch this data, and which happens-before relationship orders them? If you can't answer, there is a latent bug.
- Double-checked locking without
volatile: it compiles, passes the tests, and fails in production under load. If you need lazy initialization, prefer the holder idiom. - Unbounded queues:
new LinkedBlockingQueue<>()with no capacity turns a traffic spike into a deferredOutOfMemoryError. Bound it and decide what happens when it fills up (block, reject, drop). - Blocking inside a
CompletableFuturewithjoin()/get()in the middle of the chain: it defeats the asynchrony and can cause pool deadlocks. Compose, don't wait. - Sharing one pool for everything: Payments' slow I/O tasks starve the catalog's fast ones. Separate pools per type of work (local bulkhead).
- Nested locks in different orders: thread A takes lock1→lock2, thread B takes lock2→lock1: deadlock. Establish a global acquisition order or redesign to avoid nesting.
- Swallowing
InterruptedException: an emptycatchprevents shutting the system down cleanly. Restore the flag (Thread.currentThread().interrupt()) and finish.
Exercises
- Hunt the race.
DailyOrderCounterhasprivate int total;and the methodpublic void increment() { total++; }, called from the web threads. Explain the two distinct problems (atomicity and visibility) and give two solutions: one with a lock and one without. - Size the pool. The notifications service sends emails: each send uses ~5 ms of CPU and waits ~195 ms on the provider's network. The machine has 8 cores. Compute a reasonable pool size with the lesson's formula and explain which 06-03 pattern belongs in front of the pool.
- Parallelize the checkout. Confirming an order requires: (a) charging, (b) reserving stock, (c) computing loyalty points — the three independent of each other — and (d) with the three results, issuing the confirmation. Write the composition with
CompletableFutureand add a global 2-second timeout.
Solutions
-
Atomicity:
total++is three steps; two threads can read the same value and lose an increment. Visibility: without happens-before, one thread may never see another's writes (andvolatilewould only fix visibility, not atomicity). With a lock:public synchronized void increment() { total++; }(andsynchronizedon the getter too). Without a lock:private final AtomicInteger total = new AtomicInteger(); public void increment() { total.incrementAndGet(); }— orLongAdderif contention is high. -
threads ≈ 8 × (1 + 195/5) = 8 × 40 = 320threads. With that much I/O waiting the pool comes out huge — a sign that virtual threads would fit even better. In front of the pool: a bounded producer-consumer queue with backpressure, and conceptually the sends were already coming from a broker (06-03), with retries and a DLQ for emails that keep failing. -
The composition looks like this:
var charge = CompletableFuture.supplyAsync(() -> gateway.charge(order, card), pool); var stock = CompletableFuture.supplyAsync(() -> warehouse.reserve(order.getLines()), pool); var points = CompletableFuture.supplyAsync(() -> loyalty.calculatePoints(order), pool); CompletableFuture<OrderConfirmation> conf = charge.thenCombine(stock, ChargeStockPair::new) .thenCombine(points, (pair, pts) -> issueConfirmation(order, pair.charge(), pair.reservation(), pts)) .orTimeout(2, TimeUnit.SECONDS) .exceptionally(ex -> compensateAndReject(order, ex));The cascaded
thenCombinejoins the three results (with an auxiliary recordChargeStockPair);orTimeoutbounds the whole operation andexceptionallycentralizes the compensation (refunding/releasing whatever did get executed).
Conclusion
We have descended to the level where bugs don't show their face: shared mutable state. The answering catalog — immutability, confinement, queues with backpressure, well-sized pools, composed futures, read-write locks, and the virtual threads that make threads cheap without repealing a single rule — reuses intents you already mastered: the Builder that copied lists was preventive concurrency, Producer-Consumer is the message queue without the network, Active Object is Command plus Future. This closes the technical part of the modern catalog. One different question remains, about neither networks nor threads but people and time: how do all these patterns coexist with iterative development, sprints, and "you aren't gonna need it"? We'll see in Design Patterns in Agile Development.
Software Design Patterns Course
Module 1: Introduction to Design Patterns
- What Are Design Patterns?
- History and Origin of Design Patterns
- Design Principles: SOLID and Other Foundations
- Essential UML for Understanding Patterns
- Classification of Design Patterns
- Advantages and Disadvantages of Using Design Patterns
Module 2: Creational Patterns
- Introduction to Creational Patterns
- Singleton
- Factory Method
- Abstract Factory
- Builder
- Prototype
- Comparing and Choosing Creational Patterns
Module 3: Structural Patterns
- Introduction to Structural Patterns
- Adapter
- Bridge
- Composite
- Decorator
- Facade
- Flyweight
- Proxy
- Comparing and Choosing Structural Patterns
Module 4: Behavioral Patterns
- Introduction to Behavioral Patterns
- Chain of Responsibility
- Command
- Interpreter
- Iterator
- Mediator
- Memento
- Observer
- State
- Strategy
- Template Method
- Visitor
- Comparing and Choosing Behavioral Patterns
Module 5: Applying Design Patterns
- How to Select the Right Pattern
- Practical Examples of Pattern Usage
- Design Patterns in Real Projects
- Refactoring with Design Patterns
- Anti-Patterns: When Patterns Become a Problem
Module 6: Advanced Design Patterns
- Design Patterns in Modern Architectures
- Design Patterns in Microservices
- Design Patterns in Distributed Systems
- Concurrency Patterns
- Design Patterns in Agile Development
