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

  1. The problem: shared mutable state
  2. Double-checked locking, now with the foundation
  3. Immutability as a pattern
  4. State confinement
  5. Producer-Consumer with BlockingQueue
  6. Thread Pool / Worker with ExecutorService
  7. Future, Promise, and CompletableFuture
  8. Read-Write Lock
  9. Active Object (a mention) and Java 21 virtual threads
  10. 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 StringBuilder needs 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 thread

Classic 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 deferred OutOfMemoryError. Bound it and decide what happens when it fills up (block, reject, drop).
  • Blocking inside a CompletableFuture with join()/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 empty catch prevents shutting the system down cleanly. Restore the flag (Thread.currentThread().interrupt()) and finish.

Exercises

  1. Hunt the race. DailyOrderCounter has private int total; and the method public 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.
  2. 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.
  3. 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 CompletableFuture and add a global 2-second timeout.

Solutions

  1. 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 (and volatile would only fix visibility, not atomicity). With a lock: public synchronized void increment() { total++; } (and synchronized on the getter too). Without a lock: private final AtomicInteger total = new AtomicInteger(); public void increment() { total.incrementAndGet(); } — or LongAdder if contention is high.

  2. threads ≈ 8 × (1 + 195/5) = 8 × 40 = 320 threads. 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.

  3. 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 thenCombine joins the three results (with an auxiliary record ChargeStockPair); orTimeout bounds the whole operation and exceptionally centralizes 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.

© Copyright 2026. All rights reserved