skip to content

Explain the two @Bulkhead implementations in Resilience4j (semaphore vs thread-pool) and how @Bulkhead relates to @TimeLimiter.

level: seniorimportance: should knowfreq 48%

answer

  1. Semaphore = caller thread, permit count
  2. Thread-pool = separate pool + queue, returns CompletableFuture
  3. BulkheadFullException when full
  4. TimeLimiter needs a Future to cancel
  5. Thread-pool breaks ThreadLocal/SecurityContext

basics

~20 s

A bulkhead caps concurrent calls to protect resources. SEMAPHORE bulkhead limits concurrency on the caller's own thread; THREADPOOL bulkhead runs calls on a bounded, separate thread pool with a queue and returns a CompletableFuture. TimeLimiter caps how long an async (thread-pool) call may run.

solid answer

~40 s

@Bulkhead isolates a dependency so a surge to it can't exhaust all your threads. Two types: **SEMAPHORE** (default) uses a counting semaphore — it caps the number of concurrent executions on the calling thread and rejects excess with BulkheadFullException; it's cheap and non-blocking-thread-changing. **THREADPOOL** (type = Bulkhead.Type.THREADPOOL, config under resilience4j.thread-pool-bulkhead) offloads the call to a dedicated bounded ThreadPoolExecutor with a wait queue, so the method must return a CompletableFuture and gains true thread isolation. @TimeLimiter only works with such async results: it wraps a Future/CompletableFuture and cancels it after timeout-duration, throwing TimeLimiterException. So @TimeLimiter is pairs naturally with a thread-pool bulkhead (or any CompletableFuture-returning method), whereas a semaphore bulkhead runs synchronously and can't be time-limited that way.

code

java · 23 lines
java
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import java.util.concurrent.CompletableFuture;
import org.springframework.stereotype.Service;

@Service
public class PricingClient {

    // Thread-pool bulkhead -> must return CompletableFuture; TimeLimiter can cancel it.
    @TimeLimiter(name = "pricing")
    @Bulkhead(name = "pricing", type = Bulkhead.Type.THREADPOOL)
    @CircuitBreaker(name = "pricing", fallbackMethod = "fallback")
    public CompletableFuture<Double> priceAsync(String sku) {
        return CompletableFuture.supplyAsync(() -> callSlowPricingService(sku));
    }

    private CompletableFuture<Double> fallback(String sku, Throwable t) {
        return CompletableFuture.completedFuture(-1.0); // e.g. BulkheadFullException or timeout
    }

    private double callSlowPricingService(String sku) { /* remote call */ return 9.99; }
}

go deeper

for a junior

Know a bulkhead limits concurrent calls; two flavors exist.

for a middle

Contrast semaphore vs thread-pool and name BulkheadFullException and the CompletableFuture requirement.

for a senior

Explain latency vs concurrency isolation, ThreadLocal propagation loss, and how TimeLimiter composes with async results.

for a principal

Design bulkhead sizing against downstream capacity, reason about aspect ordering when stacking annotations, and weigh async-signature and context-propagation costs across the codebase.

**Why a bulkhead.** The name comes from ship bulkheads: sealed compartments so one flooded section doesn't sink the ship. In software, a bulkhead caps concurrent calls to a dependency so a slow/overloaded dependency can only consume a bounded slice of your resources, leaving the rest of the app functional. **Two implementations:** 1. **SemaphoreBulkhead (default, `Bulkhead.Type.SEMAPHORE`).** Backed by a counting semaphore. Config root `resilience4j.bulkhead`: ```yaml resilience4j.bulkhead.instances.inventory: max-concurrent-calls: 25 max-wait-duration: 0 # fail fast if no permit ``` `max-concurrent-calls` is the permit count. A call tries to acquire a permit; if none is available it waits up to `max-wait-duration`, then throws **`BulkheadFullException`**. The protected method runs on the **caller's own thread** — there is no thread hand-off. Lightweight, low overhead, works with plain synchronous methods. 2. **ThreadPoolBulkhead (`Bulkhead.Type.THREADPOOL`).** Config root `resilience4j.thread-pool-bulkhead`: ```yaml resilience4j.thread-pool-bulkhead.instances.inventory: core-thread-pool-size: 10 max-thread-pool-size: 20 queue-capacity: 50 ``` The call is submitted to a dedicated bounded `ThreadPoolExecutor`. Work runs on a **separate thread pool**, giving true isolation (the caller thread is freed). Because it's asynchronous, the **annotated method must return a `CompletableFuture`** (Resilience4j wraps the supplier and returns the future). If the pool and its queue are full, it rejects with `BulkheadFullException`. **Choosing between them.** - Semaphore: simpler, no context-switch, no thread-pool tuning; but the slow call still occupies the caller's thread, so it doesn't isolate you from *latency*, only from *unbounded concurrency*. - Thread-pool: real thread isolation and a queue, but adds context switching, loses ThreadLocal/SecurityContext propagation unless you handle it, and forces an async (`CompletableFuture`) signature. **Relationship with @TimeLimiter.** `@TimeLimiter` (config `resilience4j.timelimiter`) enforces a maximum duration on an **asynchronous** computation. It operates on a `Future`/`CompletableFuture`: it schedules a timeout and, on expiry, throws `TimeLimiterException` and (by default, `cancel-running-future: true`) cancels the future. Because it needs a Future, it composes with a **thread-pool bulkhead** or any method already returning `CompletableFuture`. Key config: ```yaml resilience4j.timelimiter.instances.inventory: timeout-duration: 2s cancel-running-future: true ``` **Combining annotations.** A common resilient stack is `@TimeLimiter` + `@Bulkhead(type = THREADPOOL)` + `@CircuitBreaker` on a `CompletableFuture`-returning method. Resilience4j's Spring aspects have a **defined ordering** (via configurable aspect order; defaults roughly: Retry > CircuitBreaker > RateLimiter > TimeLimiter > Bulkhead, outer-to-inner). A subtle gotcha: TimeLimiter needs an async result, so if you also use a *semaphore* bulkhead (synchronous), the TimeLimiter can't do its job the same way — you'd return a plain value and there's no Future to cancel. **Gotchas.** - Thread-pool bulkhead breaks `ThreadLocal`-based context (transactions, `SecurityContextHolder`, MDC) unless explicitly propagated. - `max-wait-duration: 0` on a semaphore bulkhead means fail-fast; a non-zero value makes callers block, which can itself queue up threads. - Both throw `BulkheadFullException`; wire a `fallbackMethod` to degrade gracefully. **When to use.** Bulkhead around any dependency where you must bound concurrency; thread-pool variant when you also need latency isolation and are comfortable with async signatures.

  • Why must a @Bulkhead(type = THREADPOOL) method return a CompletableFuture?
    Because the thread-pool bulkhead executes the work on a separate bounded executor asynchronously; Resilience4j submits the supplier to that pool and hands back a CompletableFuture representing the pending result. A synchronous return type has no way to express that offloaded, cancellable computation.
  • What context-propagation problem does the thread-pool bulkhead introduce?
    Work runs on a different thread, so ThreadLocal-based state — Spring's SecurityContextHolder, transaction binding, MDC logging context — is not automatically available. You must propagate it explicitly (e.g., ContextPropagation utilities or DelegatingSecurityContextExecutor).

saying these in an interview costs you the question

  • Saying both bulkhead types use a separate thread pool
  • Claiming @TimeLimiter can time out a synchronous semaphore-bulkhead call the same way
  • Thinking a semaphore bulkhead isolates you from a slow dependency's latency (it only bounds concurrency)
  • Forgetting that thread-pool bulkhead requires a CompletableFuture return type

context