Bulkhead
@Bulkhead limits how many calls run at the same time, so one slow dependency cannot take every thread, connection or slot your service has.
| Annotation | Position | Return types | Defaults |
|---|---|---|---|
@Bulkhead |
7 of 7, innermost, around the method | sync and CompletionStage |
25 concurrent calls, no waiting |
Use it when a dependency has limited capacity (a connection pool, a licence, a CPU-heavy renderer), or when you want one dependency's slowness to stay contained instead of using up everything.
Don't use it when the limit you need is a rate (calls per second): that is a rate limiter. A bulkhead counts calls in progress, not calls per period.
Minimal example
import dev.resilience4cata.annotation.Bulkhead;
public interface ReportRenderer {
@Bulkhead(maxConcurrent = 10)
String render(String reportId);
}
public final class ReportRenderer$Resilient implements ReportRenderer {
// …
public ReportRenderer$Resilient(ReportRenderer delegate, dev.resilience4cata.ResilienceRegistry registry) {
// …
this.bulkhead0 = registry.bulkheadPolicy("dev.resilience4cata.examples.docs.bulkhead.ReportRenderer#render(java.lang.String)", "", "", r4c$b -> {
r4c$b.maxConcurrent(10);
});
}
@Override
public java.lang.String render(java.lang.String p0) {
final dev.resilience4cata.generated.BulkheadPolicy r4c$bulkhead = this.bulkhead0;
if (!r4c$bulkhead.tryEnter()) {
throw r4c$bulkhead.rejection();
}
try {
return this.delegate.render(p0);
} finally {
r4c$bulkhead.exit();
}
}
}
Abridged: the real file uses fully qualified names.
How it behaves
flowchart TD
call["Call arrives"] --> slot{"Free slot?<br/>(fewer than maxConcurrent running)"}
slot -- yes --> run["Take the slot, run the method,<br/>give the slot back"]
slot -- no --> mode{"maxWait > 0?"}
mode -- no --> reject["BulkheadFullException<br/>(method not called)"]
mode -- yes --> wait["Wait for a slot"]
wait -- "a slot is released in time" --> run
wait -- "maxWait passes, or the thread is interrupted" --> reject
- Slots. A call takes a slot before it runs and gives it back when it returns or throws. At most
maxConcurrentcalls hold a slot at the same time. - Rejection. With
maxWait = "0"(the default), a call that finds the bulkhead full fails at once withdev.resilience4cata.BulkheadFullException(aRuntimeExceptionwithinstanceId(); one per bulkhead, no stack trace). The method is not called. - Waiting, sync. With
maxWait> 0, the calling thread blocks until a slot is free ormaxWaitpasses. On a virtual thread, blocking is cheap and does not pin a carrier. If the waiting thread is interrupted, the call is rejected and the interrupt flag is set again. Waiting is not first-come, first-served: a new caller can take a freed slot ahead of a waiting one. - This is a semaphore bulkhead: the call runs on the caller's thread. There is no thread-pool bulkhead; with virtual threads you rarely need one.
- Disabled (
enabled = "false"): every call passes.
Attributes
| Attribute | Type | Default | Config key | Meaning |
|---|---|---|---|---|
maxConcurrent |
int |
25 |
defaults.bulkhead.maxConcurrent |
Calls allowed to run at the same time. At least 1. |
maxWait |
duration | 0 (reject at once) |
defaults.bulkhead.maxWait |
How long a call may wait for a free slot. |
name |
String |
one bulkhead per method | annotation only | Instance id; methods with the same name share one bulkhead and its slots. |
config |
String |
default |
annotation only | Named configuration to start from. |
locked |
boolean |
false |
annotation only | Keeps the values set in the annotation: instances.<id>.* keys cannot change them. See Locking an instance. |
enabled |
String |
"true" |
defaults.bulkhead.enabled |
"" (inherit), "true" or "false". |
The defaults are Resilience4j's. Keys exist under configs.<name>. and instances.<id>. too; see Configuration.
Callers that may wait a little for a slot:
@Bulkhead(name = "thumbnails", maxConcurrent = 4, maxWait = "250ms")
byte[] thumbnail(String imageId);
Sharing and instance ids
Without name, every method has its own bulkhead, with the id <interface>#<method>(<parameter types>). To protect one resource used by several methods (one connection pool behind a whole client), give them the same name: they then share the same maxConcurrent slots. They must resolve to the same configuration, or wrapping fails with R4C-REG-008.
Handle
registry.bulkhead(id) returns a BulkheadHandle:
BulkheadHandle bulkhead = registry.bulkhead("thumbnails");
int running = bulkhead.concurrentCalls(); // calls inside right now
int waiting = bulkhead.waitingCalls(); // calls waiting for a slot (0 when maxWait = 0)
BulkheadSnapshot snapshot = bulkhead.snapshot(); // instance, concurrentCalls, waitingCalls, maxConcurrent
| Method | What it does |
|---|---|
id() |
The name, or the derived id |
config() |
The resolved BulkheadConfig |
concurrentCalls() |
Calls holding a slot right now |
waitingCalls() |
Calls waiting for a slot right now: async calls queued for a released slot plus sync threads blocked on the bulkhead. Always 0 when maxWait is 0. A snapshot for monitoring; it may be stale by the time it is read |
snapshot() |
A BulkheadSnapshot(instance, concurrentCalls, waitingCalls, maxConcurrent), plus availableSlots() |
The bulkhead exists once its interface has been wrapped or its pipeline built; an unknown id throws R4C-REG-009. There is no registry.bulkheads(): list them with registry.snapshot().bulkheads().
With other policies
The bulkhead is the innermost layer, right around your method:
- A slot is held only while the method runs. During a
@Retrydelay, the slot is free for other callers. BulkheadFullExceptionis anException: by default an outer@Retryretries it after its delay (add it toabortOnto fail fast) and an outer@CircuitBreakerrecords it as a failure (add it toignoreOnif a full bulkhead should not open the breaker).- The time spent waiting for a slot counts towards
@Timeout, which is outside: a sync wait interrupted by the timeout ends withCallTimeoutException. - Rejected calls already took a rate-limiter permit, since the rate limiter is outside.
Async
For a method that returns CompletionStage<T> or CompletableFuture<T>, the call holds its slot until its stage completes, not just until the method returns:
@Bulkhead(name = "thumbnails-async", maxConcurrent = 2, maxWait = "1s")
CompletableFuture<byte[]> thumbnailAsync(String imageId);
An async call never blocks a thread while it waits: a full bulkhead with maxWait > 0 queues the call, and the oldest waiter gets the next released slot directly, then starts on a new virtual thread. If maxWait passes first, its future fails with BulkheadFullException. Without maxWait, a full bulkhead returns a failed future at once. Sync and async callers of a shared bulkhead use the same slots. Cancelling the returned future cancels the stage and frees the slot at once (when the stage is a Future); a call still queued leaves the queue and never starts (cancellation). See Async methods.
Same thing with Pipeline
Pipeline<String> render = Pipeline.builder(registry, "renderer", String.class)
.bulkhead(b -> b.maxConcurrent(10))
.build();
String report = render.execute(() -> backend.render("r-1"));
The id "renderer" is the instance id: registry.bulkhead("renderer") returns it. See Pipeline API.
Events and snapshot
| Event | When | Fields |
|---|---|---|
BulkheadEvent.Rejected |
A call was rejected (full with no wait, maxWait passed, or an interrupted wait) |
instance, nanoTime, maxConcurrent |
Admitted calls publish nothing. See Observability.
Testing it with ManualClock
Pending stages hold their slots, which makes an async bulkhead easy to fill in a test:
ThumbnailService thumbnails = Resilient.of(ThumbnailService.class, backend, registry);
BulkheadHandle bulkhead = registry.bulkhead("thumbnails-async");
CompletableFuture<byte[]> a = thumbnails.thumbnailAsync("a"); // pending: holds a slot
CompletableFuture<byte[]> b = thumbnails.thumbnailAsync("b"); // pending: holds the other
CompletableFuture<byte[]> c = thumbnails.thumbnailAsync("c"); // full: waits up to 1 s
assertThat(bulkhead.concurrentCalls()).isEqualTo(2);
assertThat(bulkhead.waitingCalls()).isEqualTo(1);
clock.advance(Duration.ofSeconds(1)); // nothing was released in time
assertThat(c).failsWithin(Duration.ofSeconds(5))
.withThrowableThat().withCauseInstanceOf(BulkheadFullException.class);
On a ManualClock, maxWait is measured on the clock, for async and for sync waits: a blocked sync caller gets a slot as soon as one is released and is rejected when the clock passes maxWait, however long that takes in real time. It waits for as long as the clock does not move.
Small print
- The limit is per JVM and per registry.
waitingCalls()is for monitoring only; don't make decisions with it on the call path.- When sync and async callers wait on the same bulkhead, released slots alternate between the oldest async waiter and the blocked threads, approximately.
- Related errors:
R4C-ATR-001(duration syntax),R4C-ATR-003(maxConcurrentbelow 1),R4C-ATR-006(enabled),R4C-REG-004(unknownconfig),R4C-REG-008(sharedname, different config),R4C-REG-009(unknown id).
What it costs
Measured On AWS Graviton3 (
c7g.xlarge, JDK 21), a call through a bulkhead with a free slot costs 20.5 ns on 1 thread, a tie with Resilience4j 2.4.0 (20.4 ns), and 264 ns on 4 threads sharing it, against 432 ns; neither allocates. A rejected call costs 2.3 ns and 0 B, against 139 ns and 416 B. Benchmarks: MVP completion