Explain the two @Bulkhead implementations in Resilience4j (semaphore vs thread-pool) and how @Bulkhead relates to @TimeLimiter.
answer
- Semaphore = caller thread, permit count
- Thread-pool = separate pool + queue, returns CompletableFuture
- BulkheadFullException when full
- TimeLimiter needs a Future to cancel
- Thread-pool breaks ThreadLocal/SecurityContext
basics
~20 sA 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 linesimport 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
Know a bulkhead limits concurrent calls; two flavors exist.
Contrast semaphore vs thread-pool and name BulkheadFullException and the CompletableFuture requirement.
Explain latency vs concurrency isolation, ThreadLocal propagation loss, and how TimeLimiter composes with async results.
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