advanced~2h

Concurrency Patterns & Best Practices

Recognize the recurring, named shapes -- worker pools, pipelines, bulkheads, circuit breakers -- that real systems assemble from concurrency primitives, and know which one a given problem actually needs.

Learning objectives

  • Recognize the thread-per-request model and why virtual threads revive it at scale
  • Design a bounded worker pool with an appropriate rejection policy for backpressure
  • Build a multi-stage pipeline connected by BlockingQueues with correct backpressure
  • Isolate a failing dependency's blast radius using the Bulkhead pattern
  • Explain the three-state Circuit Breaker machine and why it's paired with Bulkhead, not used alone
  • Explain why immutability eliminates the need for synchronization entirely

◆ Story

A lock, a queue, and a thread pool are like a hammer, a saw, and a level — genuinely useful on their own, but nobody builds a house by reasoning about each tool from first principles on every job. Experienced builders reach for recognizable, named shapes instead: a load-bearing wall goes here because that's simply how load-bearing walls work, a drainage slope goes there because that's the recognized solution to water pooling. The tools matter, but recognizing which named shape a given problem actually calls for is what separates someone who can follow a blueprint from someone who can design one.

Every concurrency primitive covered elsewhere in this course — a lock, a queue, an executor, a future — is exactly that kind of tool. Real systems combine several of them into recurring, named shapes that solve specific, recognizable problems: one thread handling one request end to end, a bounded pool of workers with a deliberate policy for what happens when they're all busy, a multi-stage pipeline, a wall that stops one failing dependency from taking down everything else. Knowing the primitives lets you write correct code. Recognizing these patterns is what lets you look at a requirement and know, immediately, which primitives it actually needs.

The simplest possible server model: one thread handles one request from start to finish — blocking calls and all — reading like ordinary sequential code with no callbacks anywhere. It fell out of favor once platform-thread limits made it unscalable past a few hundred to a couple thousand concurrent requests, which is exactly what pushed a generation of Java services toward reactive frameworks instead. Virtual threads make this model fashionable again: same conceptual shape, one thread per request, but each request's thread is now cheap enough that the old ceiling simply doesn't apply. The JVM unmounts a blocked request's virtual thread from its carrier and remounts it on completion, so the code stays simple and blocking while the underlying throughput scales the way a reactive rewrite used to be needed for. Spring Boot 3.2+ makes this an application-wide switch via spring.threads.virtual.enabled=true, with no change to how request-handling code is written.

💻 Code example

package concurrency.patterns.threadperrequest; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * The thread-per-request model made viable at massive scale: each submitted * task (standing in for one incoming request) gets its own cheap virtual * thread, rather than competing for a small, bounded platform-thread pool. */ public class ThreadPerRequest { public static void main(String[] args) throws Exception { try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { exec.submit(() -> System.out.println("Handling request on: " + Thread.currentThread())); } // try-with-resources' close() blocks until submitted work finishes. } }

The pattern behind nearly every production thread pool: a fixed number of workers, a bounded queue in front of them, and a deliberate, explicit answer to "what happens when both are full?" A bounded queue matters as much as the worker count — an unbounded one just defers the problem, letting memory grow without limit under sustained overload instead of surfacing backpressure early.

CallerRunsPolicy is a particularly elegant rejection policy because it doesn't lose work or need extra buffering — once both the workers and the queue are full, the submitting thread runs the overflow task itself. That naturally throttles the producer: the thread that would otherwise be submitting more work is now busy executing the task it just tried to submit, so submission rate automatically slows to match the pool's actual processing capacity, with no separate rate-limiting mechanism required.

💻 Code example

package concurrency.patterns.workerpool; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; /** * core=max=2 workers, a bounded queue of 2, and CallerRunsPolicy -- once * both workers and the queue are full, the submitting thread runs the task * itself instead of the work being rejected or silently dropped. */ public class WorkerPoolWithBackpressure { public static void main(String[] args) { ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, 2, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(2), new ThreadPoolExecutor.CallerRunsPolicy() ); // Capacity before rejection kicks in: 2 running + 2 queued = 4. // Tasks 5 and 6 will trigger CallerRunsPolicy. for (int i = 0; i < 6; i++) { final int taskId = i; executor.submit(() -> { // Tasks run via CallerRunsPolicy print "main" here instead // of a pool worker's name -- direct, observable backpressure. System.out.println("Task " + taskId + " on " + Thread.currentThread().getName()); try { Thread.sleep(100); } catch (InterruptedException e) {} }); } executor.shutdown(); } }

Break a larger unit of work into sequential stages, each running concurrently on its own thread, communicating only through queues — never through shared mutable state guarded by a lock. A BlockingQueue supplies every bit of the synchronization this pattern needs: put() blocks if the queue is full, take() blocks if it's empty, and that's the entire hand-off protocol between one stage and the next, with no explicit lock or wait/notify anywhere in sight.

A pipeline's overall throughput is set by its slowest stage, exactly like an assembly line moves at the pace of its slowest station. The usual compensation is running more worker threads on the expensive stage — an expensive "enrichment" stage might run eight threads pulling from its input queue while a cheap "validation" stage in front of it needs only two. Each queue also supplies natural backpressure automatically: a fast upstream stage can never run arbitrarily far ahead of a slow downstream one and exhaust memory, because its put() calls simply start blocking once the queue between them fills up.

💻 Code example

package concurrency.patterns.pipeline; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; /** * A producer and consumer, each on their own thread, connected only by a * shared BlockingQueue -- no explicit lock or wait/notify anywhere. */ public class TwoStagePipeline { public static void main(String[] args) throws Exception { // Bounded to 5: put() blocks if full, take() blocks if empty -- // thread-safe hand-off and backpressure with no explicit locking. BlockingQueue<String> stageOneOutput = new LinkedBlockingQueue<>(5); new Thread(() -> { try { stageOneOutput.put("Raw Data"); } catch (InterruptedException e) {} }).start(); Thread consumer = new Thread(() -> { try { String value = stageOneOutput.take(); System.out.println("Stage 2 processed: " + value + " -> Enriched"); } catch (InterruptedException e) {} }); consumer.start(); consumer.join(); } }

Pipeline connects exactly one producer to exactly one consumer per queue. Publish-Subscribe is the pattern for the opposite shape: one event, broadcast to an arbitrary number of independent listeners, each reacting without knowing or caring that any other listener exists. A thread-safe list of subscribers — appended to rarely (subscribe/unsubscribe) and iterated constantly (publish) — is the core data structure underneath, with CopyOnWriteArrayList a natural fit for exactly that read-heavy, write-rare access pattern.

The simplest form dispatches to each subscriber directly on the publishing thread — straightforward, but a slow subscriber directly slows down the publisher, and one subscriber throwing an exception can prevent later subscribers from ever being notified unless each call is individually wrapped in its own try/catch. Asynchronous dispatch — submitting each subscriber notification as its own task to an executor — decouples publisher speed from subscriber speed entirely, at the cost of losing strict delivery-ordering guarantees.

This in-process pattern is exactly right for decoupling components within a single JVM — publishing domain events like an order being placed or a user registering to several in-process listeners handling logging, metrics, and cache invalidation. Once subscribers need to live in a different process, need guaranteed delivery across a crash, or need to replay events after the fact, that's the signal to reach for an actual message broker like Kafka or RabbitMQ instead — the in-process version trades durability and cross-process reach for near-zero latency and zero operational overhead.

Named after a ship's watertight compartments: if one section floods, the ship doesn't sink, because the flooding is contained. Applied to threads: if every downstream call shares one thread pool, and one downstream service turns slow or unresponsive, every thread in that shared pool can end up blocked waiting on it, starving completely unrelated, perfectly healthy calls of any thread to even run on. Giving each downstream dependency its own fully separate pool means a hung or slow call to one service can never consume threads reserved for another.

With virtual threads, the isolation concern usually shifts from pools to permits, since a bounded pool for the thread count itself becomes largely unnecessary. A Semaphore per downstream service plays the same isolating role a separate ExecutorService used to: acquiring a permit before calling one service, capped independently from the permit budget for a different service, so a struggling dependency can never exhaust the concurrency budget reserved for a healthy one.

💻 Code example

package concurrency.patterns.bulkhead; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; /** * Two fully separate pools, one per downstream service -- a hung/slow order * service can never starve threads reserved for catalog work. */ public class BulkheadIsolation { private static final ExecutorService orderPool = Executors.newFixedThreadPool(2); private static final ExecutorService catalogPool = Executors.newFixedThreadPool(2); public static void main(String[] args) throws Exception { // Isolated: contention on catalogPool can never starve this call. Future<String> orderResult = orderPool.submit(() -> "Order created"); // Isolated the other direction, too. Future<String> catalogResult = catalogPool.submit(() -> "Catalog loaded"); System.out.println(orderResult.get()); System.out.println(catalogResult.get()); orderPool.shutdown(); catalogPool.shutdown(); } }

Bulkhead contains a slow dependency's damage. Circuit Breaker goes a step further: it stops calling a dependency that's clearly failing at all, for a while, instead of continuing to send doomed requests that will only time out anyway and tie up threads or permits waiting on a response that isn't coming.

A circuit breaker is a three-state machine. Closed is normal operation — requests flow through as usual while the breaker counts recent failures; once failures cross a configured threshold (say, half of the last twenty calls), it trips to Open. Open rejects every call immediately, with no attempt made to reach the actual dependency at all, typically returning a fallback value or a fast error instead — this is what actually protects the calling application, since threads or permits are never spent waiting on a dependency already known to be unhealthy. After a configured cooldown, the breaker moves to Half-Open, where a small number of test requests are allowed through to check whether the dependency has recovered: if they succeed, the breaker resets to Closed; if they fail, it trips back to Open and the cooldown restarts.

Bulkhead and Circuit Breaker solve genuinely different problems and are typically layered together rather than treated as alternatives. A bulkhead limits the blast radius of a slow dependency but doesn't stop repeatedly hammering a dependency that's already known to be down. A circuit breaker stops the hammering but says nothing on its own about isolating one dependency's resource usage from another's. Production resilience code typically applies both — a bulkhead per downstream dependency, with a circuit breaker wrapping each call into it — exactly the combination libraries like Resilience4j package as separate, composable decorators around the same underlying call.

Every pattern above manages threads touching shared, changing state. Immutability sidesteps the entire problem at its root: if an object's state can never change after construction, there's nothing left for concurrent threads to race on. No lock, no volatile, no defensive copying required on read — the best synchronization is no synchronization, achieved by removing the thing synchronization exists to protect in the first place.

Four ingredients make a class genuinely immutable: every field private final, assigned exactly once inside the constructor; no setters; the class itself not meaningfully subclassable into a mutable variant; and any field that itself holds a reference type pointing to an immutable type — a mutable field like an array or a plain List would need defensive copying on the way in and out to preserve the guarantee. The final keyword here is more than documentation: the Java Memory Model specifically guarantees that final fields set inside a constructor are visible to any thread that later observes a reference to the fully-constructed object, which is what makes safe publication of an immutable object work with zero additional volatile or locking. Java's record keyword, from Java 16 onward, is the idiomatic shorthand for writing exactly this recipe with far less boilerplate.

💻 Code example

package concurrency.patterns.immutability; /** * final fields, no setters, no synchronization needed -- nothing for * concurrent threads to ever race on. */ public class ImmutableValueObject { public static final class ImmutableUser { // Assigned exactly once, in the constructor, and never reassignable // afterward -- this is what makes the object's state permanently // fixed and safe to read from any thread without synchronization. private final String username; private final String role; public ImmutableUser(String username, String role) { this.username = username; this.role = role; } // Read-only accessors -- no setters exist, so no thread can ever // mutate this object's state after construction. public String getUsername() { return username; } public String getRole() { return role; } } public static void main(String[] args) { // From this line onward, `user` could be safely handed to any // number of threads with no locking whatsoever. ImmutableUser user = new ImmutableUser("admin", "ROOT"); System.out.println("Username: " + user.getUsername()); } }

What determines a pipeline's overall throughput? : Its slowest stage -- exactly like an assembly line moves at the pace of its slowest station. Compensate by running more worker threads on the expensive stage, not by speeding up already-fast stages.

Why is CallerRunsPolicy considered a self-regulating throttle rather than just a rejection policy? : Once workers and the queue are full, it runs the overflow task on the submitting thread itself -- which is now too busy to submit more work, automatically slowing the producer to match the pool's real processing capacity.

How does Bulkhead differ from Circuit Breaker, and why use both? : Bulkhead limits the blast radius of a slow dependency by isolating its resource usage (a separate pool or semaphore) but doesn't stop repeated calls to an already-failing dependency. Circuit Breaker stops the repeated calls but says nothing about resource isolation. Production code layers both together.

What are the three states of a circuit breaker? : Closed (normal operation, counting failures), Open (fail fast, no real call attempted, after the failure threshold trips), and Half-Open (a few probe requests test recovery before resetting to Closed or tripping back to Open).

Why does immutability eliminate the need for synchronization entirely? : If an object's state can never change after construction, there's nothing for concurrent threads to race on -- no lock, volatile, or defensive copying on read is needed, because there's nothing left to protect.

Want a visual for this concept?

Generate a diagram tailored to “Concurrency Patterns & Best Practices” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Deadlocks, Livelocks & Starvation← Back to all Java Concurrency & Multithreading chapters