Bulkhead
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
A ship is divided into watertight compartments so that a hole in one of them does not sink the ship. A bulkhead does the
same for a service: it limits how much of your capacity one dependency can use, so that a slow or hanging dependency can
exhaust its own share, but not the threads that every other call needs. Resilience4j has two implementations. The
SemaphoreBulkhead counts the callers that are inside a call at the same time. The FixedThreadPoolBulkhead runs the
calls on a thread pool of its own, with a bounded queue in front of it. This page was written against Resilience4j 2.4.0,
Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21 and verified on 4 October 2026. Spring Boot 3.5.x with
resilience4j-spring-boot3 behaves identically: the resilience4j.bulkhead. and resilience4j.thread-pool-bulkhead.
properties, the aspect, the Actuator output and the meter names matched in a run on Boot 3.5.16. Only the Java package of the
auto-configuration (io.github.resilience4j.springboot3 instead of io.github.resilience4j.springboot) and the AOP starter
(spring-boot-starter-aop instead of spring-boot-starter-aspectj) differ. The Spring Framework 7 @ConcurrencyLimit
annotation compared below needs Spring Framework 7, so it is available only with Boot 4.
What a bulkhead isolates
Without a bulkhead, every call to every dependency draws on one shared resource: the request threads of the container, or
the virtual threads you start. When the recommendations service starts answering in seconds instead of milliseconds, each
request that touches it keeps a thread for those seconds. Requests pile up behind it, and soon the cheap, healthy
pricing-service call cannot get a thread either, although nothing is wrong with pricing-service. A bulkhead puts a
ceiling on the concurrent use of one dependency and rejects the excess immediately, which is why it is a pattern against
resource exhaustion and cascading failure rather than against errors. The other patterns in this section protect against
different things: a circuit breaker reacts to the rate of failures, a
rate limiter bounds calls per period of time, and a
time limiter bounds how long one caller waits. The bulkhead bounds
how many calls are in flight at once.
Two implementations
SemaphoreBulkhead |
FixedThreadPoolBulkhead |
|
|---|---|---|
Isolation by |
Counting: a |
Threads: a dedicated |
Where the call runs |
On the caller’s own thread. The bulkhead only admits it or not. |
On a pool thread named |
What a caller does when the bulkhead is full |
Waits up to |
Is queued if the queue has room, otherwise |
Return types |
Synchronous values, |
|
Cost |
A semaphore acquire and a release per call. |
A thread hand-off per call, plus the memory of the threads and the queue. |
Context (MDC, security, tracing) |
Nothing to do: the same thread runs the call. |
Lost unless you configure |
Spring Boot prefix |
|
|
Registry and annotation |
|
|
The two are separate registries with separate configuration trees, and a name may exist in both. The Actuator bulkheads
endpoint lists the names of both kinds in one sorted list.
SemaphoreBulkhead
The three properties
| Builder method | Spring Boot property | Default (2.4.0) | Meaning |
|---|---|---|---|
|
|
|
Number of permits: the most calls inside the bulkhead at the same time. The builder accepts |
|
|
|
How long a caller may block waiting for a permit. |
|
none: not a Spring Boot property |
|
|
|
|
|
When |
fairCallHandlingEnabled is not read by the Spring Boot binder in 2.4.0: the class that binds resilience4j.bulkhead.instances.<name>
has no such field, and an unknown key is ignored silently. A run with fairCallHandlingEnabled: false under an instance gave a
configuration with fairCallHandlingEnabled=true. Set it with a customizer, shown below. The readme.io Bulkhead page lists neither
this property nor writableStackTraceEnabled for the semaphore bulkhead, and its examples use the Vavr Try, which has been optional since
Resilience4j 2.0.
A saturated bulkhead
BulkheadConfig config = BulkheadConfig.custom()
.maxConcurrentCalls(2)
.maxWaitDuration(Duration.ZERO) // reject at once: never park a request thread
.fairCallHandlingStrategyEnabled(true)
.build();
Bulkhead bulkhead = Bulkhead.of("isbn", config);
bulkhead.getEventPublisher()
.onCallPermitted(e -> log.info("{}", e.getEventType()))
.onCallRejected(e -> log.info("{}", e.getEventType()))
.onCallFinished(e -> log.info("{}", e.getEventType()));
// two callers are inside the bulkhead and have not returned yet ...
bulkhead.getMetrics().getAvailableConcurrentCalls(); // 0
bulkhead.getMetrics().getMaxAllowedConcurrentCalls(); // 2
try {
bulkhead.executeSupplier(() -> "third");
} catch (BulkheadFullException e) {
e.getMessage(); // Bulkhead 'isbn' is full and does not permit further calls
e.getBulkheadName(); // isbn
}
// events, in order: CALL_PERMITTED, CALL_PERMITTED, CALL_REJECTED, CALL_FINISHED, CALL_FINISHED
Source: Resilience4j: Bulkhead and the
v2.4.0
SemaphoreBulkhead source. The comments show the output of the run.
Waiting for a permit
With a non-zero maxWaitDuration a caller that finds the bulkhead full is parked until a permit is released or the time is up:
Bulkhead bulkhead = Bulkhead.of("waiter", BulkheadConfig.custom()
.maxConcurrentCalls(1)
.maxWaitDuration(Duration.ofMillis(300))
.build());
// another thread holds the permit for longer than 300 ms ...
long start = System.nanoTime();
try {
bulkhead.executeSupplier(() -> "x");
} catch (BulkheadFullException e) {
// thrown after about 310 ms in the run: the caller was parked the whole time
}
Source: Resilience4j: Bulkhead. The note in the maxWaitDuration Javadoc of
BulkheadConfig
recommends 0 for threads that run on an event loop (Netty, Reactor’s parallel scheduler, an RxJava computation scheduler), because blocking one of
them costs more throughput than rejecting. The same reasoning applies to servlet request threads: a waiting caller holds one.
If the waiting thread is interrupted, acquirePermission() throws AcquirePermissionCancelledException instead of BulkheadFullException, and
the interrupt flag stays set.
Changing the limit at runtime
Bulkhead.changeConfig(BulkheadConfig) replaces the configuration of a live bulkhead: maxConcurrentCalls is raised by releasing permits to the
semaphore, or lowered by acquiring the difference. In a run, raising a bulkhead from 2 to 5 permits showed 5 available and a maximum of 5, and
lowering it to 1 showed 1 and 1 with no call in flight. According to the source, lowering uses acquireUninterruptibly, so when callers are
inside the bulkhead the call waits for them to finish before it returns. A FixedThreadPoolBulkhead has no changeConfig: its pool is built once.
FixedThreadPoolBulkhead
The properties
| Builder method | Spring Boot property (resilience4j.thread-pool-bulkhead.…) |
Default (2.4.0) | Meaning |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
Size of the |
|
|
|
How long a thread above the core size may stay idle before it ends. Core threads stay. |
|
|
|
Same as for the semaphore bulkhead. |
|
|
empty |
Classes (Spring Boot) or instances (builder) that copy thread-locals into the pool thread. See the section on context propagation. |
|
none: not a Spring Boot property |
|
What the pool does when it is full. The default makes the bulkhead throw |
Spring Boot also reads baseConfig and eventConsumerBufferSize under both prefixes, as described below. rejectedExecutionHandler has no key in
Resilience4j 2.4.0: a run with rejectedExecutionHandler: java.util.concurrent.ThreadPoolExecutor$CallerRunsPolicy under an instance still produced
rejectExecutionHandle=AbortPolicy. Use a ThreadPoolBulkheadConfigCustomizer. The readme.io page lists neither contextPropagators nor rejectedExecutionHandler
in its property table.
When the pool grows
The executor is a plain ThreadPoolExecutor, so its sizing rule applies, and it surprises people: the pool grows beyond coreThreadPoolSize only
when the queue is full. With the default queue of 100 and the default core size, nothing above the core threads ever runs until a hundred calls are waiting.
In this run the pool has core 1, maximum 3 and a queue of 1, and every call blocks until released:
ThreadPoolBulkheadConfig config = ThreadPoolBulkheadConfig.custom()
.coreThreadPoolSize(1)
.maxThreadPoolSize(3)
.queueCapacity(1)
.keepAliveDuration(Duration.ofMillis(20))
.build();
ThreadPoolBulkhead bulkhead = ThreadPoolBulkhead.of("recommendations", config);
// call 1 accepted: poolSize=1 active=1 queueDepth=0 remainingQueue=1 runs on the core thread
// call 2 accepted: poolSize=1 active=1 queueDepth=1 remainingQueue=0 waits in the queue
// call 3 accepted: poolSize=2 active=2 queueDepth=1 remainingQueue=0 queue full: a second thread
// call 4 accepted: poolSize=3 active=3 queueDepth=1 remainingQueue=0 queue still full: a third thread
// call 5 rejected: Bulkhead 'recommendations' is full and does not permit further calls
// call 6 rejected: the same message
Source: the output of the example above, the Resilience4j: Bulkhead page and the
ThreadPoolExecutor Javadoc (queuing rules).
The total capacity is therefore maxThreadPoolSize + queueCapacity calls: 4 here, and 6 in the Bookshelf configuration below (four threads and two queue slots). The number of
calls that are running is at most maxThreadPoolSize. The queue is not free: a call that waits in it has already used part of its caller’s time budget. A deep queue
turns saturation into latency instead of rejections, which is usually the opposite of what a bulkhead is for.
With queueCapacity(0) the executor uses a SynchronousQueue, a call is accepted only if a thread is free or can be created, and
getQueueCapacity() and getRemainingQueueCapacity() both return 0. The extra rejected call of that run carried the message
Bulkhead 'noqueue' is full and does not permit further calls.
What the caller gets
executeSupplier, executeCallable and executeRunnable return a CompletionStage; decorateSupplier, decorateCallable and decorateRunnable
return a Supplier<CompletionStage<…>>. A failure inside the task completes the stage exceptionally; get() on the CompletableFuture shows the original exception as the cause
(java.lang.IllegalStateException: boom in the run), while join() wraps it in a CompletionException. When the pool rejects the task, the direct methods throw
BulkheadFullException synchronously, which is not what a CompletionStage user expects. The Decorators builder of resilience4j-all (withThreadPoolBulkhead)
turns the rejection into a failed stage, so that a fallback can handle both outcomes the same way:
CompletionStage<List<String>> stage = Decorators.ofSupplier(() -> client.recommendationsFor(isbn))
.withThreadPoolBulkhead(bulkhead)
.withFallback(BulkheadFullException.class, e -> List.of())
.get();
// bulkhead full: the stage completes with [] ; direct bulkhead.executeSupplier(...) throws instead
Source: Resilience4j: Bulkhead and the
v2.4.0 Decorators source.
The builder and fallback model is explained in
Registries, configuration and decorators.
The executor is created in the constructor and lives until close(), which shuts it down and waits up to five seconds. ThreadPoolBulkheadRegistry implements
AutoCloseable and closes every bulkhead it holds, so a registry that you build by hand should be closed. In Spring Boot the registry is a bean and the container does it.
A custom rejection policy
rejectedExecutionHandler accepts any java.util.concurrent.RejectedExecutionHandler. The one that is almost always wrong for a bulkhead is CallerRunsPolicy: it runs the task on
the calling thread when the pool is full. In a run with core 1, maximum 1 and queue 1, the third and fourth calls ran on main and completed at once, no
BulkheadFullException was thrown, and the bulkhead no longer limited anything: the caller did the dependency’s work itself, which is exactly the thread usage the bulkhead exists
to prevent. Keep AbortPolicy, or write a handler that records a metric and then throws.
Spring Boot configuration
Both trees have the same structure as the other modules: configs.<name> and instances.<name>, an instance rooted at configs.default (when it exists), with baseConfig layered on top and
its own keys winning. The shared structure, precedence and customizer rules are described in
Spring Boot configuration. Only the keys specific to the bulkhead are listed here.
resilience4j:
bulkhead: # SemaphoreBulkhead
configs:
default:
maxConcurrentCalls: 10
maxWaitDuration: 0 # reject at once
instances:
isbn: # the third-party ISBN-metadata API
baseConfig: default
maxConcurrentCalls: 2
maxWaitDuration: 100ms
writableStackTraceEnabled: false
eventConsumerBufferSize: 200
thread-pool-bulkhead: # FixedThreadPoolBulkhead
configs:
default:
coreThreadPoolSize: 2
maxThreadPoolSize: 4
queueCapacity: 2
keepAliveDuration: 20ms
contextPropagators:
- com.example.bookshelf.MdcContextPropagator
instances:
recommendations:
baseConfig: default
management:
endpoints:
web:
exposure:
include: health,bulkheads,bulkheadevents,metrics,prometheus
Source: Resilience4j: Spring Boot 2 & 3 and the v2.4.0 source of
CommonBulkheadConfigurationProperties
and
CommonThreadPoolBulkheadConfigurationProperties.
The Actuator exposure is for illustration: expose only the endpoints you need and put them behind authentication, as
Metrics and observability explains. The same file ran on Boot 4.1.1 and Boot 3.5.16 with the results shown on this page.
| Key | Under | Default | Notes |
|---|---|---|---|
|
|
|
See the table above. |
|
|
see the table above |
A key that is not set keeps the builder default. Setting a core size above the CPU count without setting |
|
both trees |
|
|
|
both trees |
none |
Name of an entry under |
|
both trees |
|
Size of the circular buffer behind the |
|
|
|
Position of the |
|
|
|
Switch the Micrometer binder on or off per kind. The legacy flag selects the older |
|
|
empty |
Registry-wide tags added to the meters. |
Neither tree has registerHealthIndicator: the 2.4.0 starter provides no health indicator for bulkheads. Use the meters below to alert on saturation.
Customizers for what YAML cannot express
A BulkheadConfigCustomizer or ThreadPoolBulkheadConfigCustomizer bean receives the builder after the YAML values have been applied, for the instance of that name, so it can set
what has no key, and it can pass ContextPropagator instances instead of class names:
@Configuration
class BulkheadCustomization {
@Bean
BulkheadConfigCustomizer isbnBulkhead() {
return BulkheadConfigCustomizer.of("isbn",
builder -> builder.fairCallHandlingStrategyEnabled(false));
}
@Bean
ThreadPoolBulkheadConfigCustomizer recommendationsBulkhead(MeterRegistry meters) {
return ThreadPoolBulkheadConfigCustomizer.of("recommendations",
builder -> builder
.contextPropagator(new SecurityContextPropagator()) // an instance: it may use beans
.rejectedExecutionHandler(new CountingAbortPolicy(meters)));
}
}
Source: Resilience4j: Spring Boot 2 & 3 (customizers) and the v2.4.0 source of
BulkheadConfigCustomizer.
CountingAbortPolicy is your own RejectedExecutionHandler that increments a counter and then throws RejectedExecutionException. In a run with these customizers the isbn instance had
fairCallHandlingEnabled=false, while an instance configured only in YAML kept true, and recommendations reported rejectExecutionHandle=CountingAbortPolicy. The propagator classes from YAML and the instances
from the customizer are added together (the run listed MdcContextPropagator and SecurityContextPropagator), so name each propagator in one place only. Beans of type ContextPropagator are not picked up
automatically: the 2.4.0 starters read only the class names from YAML and the instances passed to the builder.
Choosing between them
or does it need to stay
on the caller thread?} B -- yes --> S[SemaphoreBulkhead] B -- no --> C{Can the caller accept
a CompletionStage result?} C -- no --> S C -- yes --> D{Is the dependency slow
or hanging, and must the
caller's thread be freed
or the wait bounded?} D -- no --> S D -- yes --> T[ThreadPoolBulkhead
plus context propagators] S --> E{Runs on virtual threads?} E -- yes --> F[Semaphore: its permit
limit is the concurrency limit] E -- no --> G[Semaphore: size it below the
container thread pool]
Use the semaphore when the call is cheap to attempt, when the caller’s thread is going to wait for the answer anyway, when you want the call to stay on its own thread
(so that MDC, security and tracing context just work), or when the call returns a reactive type. It is the default, the cheaper one and the one that composes with
the other patterns by decoration. Use the thread pool when the dependency may hang and you want the work isolated on threads that you can count, size and monitor, when
the caller can take a CompletionStage (for example an asynchronous controller method, or a call that you combine with a TimeLimiter), and when a thread hand-off is cheap compared with the call itself.
A thread pool costs more and has more knobs: pool sizes, queue, keep-alive, and the propagation of every thread-local the call needs.
A pool bulkhead does not, by itself, free the caller. If the controller calls join() on the stage, the request thread still waits for the slow dependency. What the bulkhead
guarantees in that case is a bound on the work done against the dependency and a fast rejection beyond it; to also bound the wait, combine it with a
time limiter or return the stage to a framework that completes the response asynchronously.
The @Bulkhead annotation
The annotation selects the implementation with type, either Bulkhead.Type.SEMAPHORE (the default) or Bulkhead.Type.THREADPOOL. It also takes name (a SpEL expression is allowed),
configuration (the key of the shared configuration to use when the name is a SpEL expression) and fallbackMethod. The aspect needs the AspectJ starter; without it the annotation is a
silent no-op, as the getting started page shows.
@Component
class IsbnMetadataClient {
@Bulkhead(name = "isbn", fallbackMethod = "unknownTitle")
String title(String isbn) throws InterruptedException {
Thread.sleep(400); // stands for the HTTP call to the ISBN API
return "Title of " + isbn;
}
String unknownTitle(String isbn, BulkheadFullException e) {
return "unknown (" + e.getBulkheadName() + " is full)";
}
}
@Component
class RecommendationsClient {
@Bulkhead(name = "recommendations", type = Bulkhead.Type.THREADPOOL,
fallbackMethod = "noRecommendations")
CompletableFuture<List<String>> forIsbn(String isbn) {
// runs on a pool thread of the "recommendations" bulkhead; see the Bookshelf section for the HTTP call
return CompletableFuture.completedFuture(fetchRecommendations(isbn));
}
CompletableFuture<List<String>> noRecommendations(String isbn, BulkheadFullException e) {
return CompletableFuture.completedFuture(List.of());
}
}
Source: Resilience4j: Spring Boot 2 & 3 (annotations) and the v2.4.0 source of
BulkheadAspect.
The method that is annotated with THREADPOOL runs on the pool thread: the aspect submits the call, then waits on the returned future inside that thread, so the blocking
HTTP call stays inside the bulkhead. The fallback signature rules (same parameters plus a trailing exception) are covered in
Fallbacks and composition.
Four behaviours are worth knowing, and each was checked in a run on Boot 4.1.1:
-
The thread-pool type requires a
CompletionStagereturn type. An annotated method that returns aStringfails at call time withjava.lang.IllegalStateException: ThreadPool bulkhead is only applicable for completable futures. -
A rejection reaches the fallback. For a
CompletionStagemethod, the aspect converts theBulkheadFullExceptioninto a failed future, so the fallback method is invoked. In the semaphore case it is thrown as a normal exception and the fallback catches it. Without a fallback method the caller sees the exception:io.github.resilience4j.bulkhead.BulkheadFullException: Bulkhead 'isbn' is full and does not permit further calls. -
A name that is not in the YAML is created on first use from
configs.default(or, if there is none, the builder defaults of 25 permits or the CPU-based pool), under the registry of its type. -
Only the semaphore type has reactive support. The aspect extensions for Reactor and RxJava handle
Mono,Fluxand the RxJava types with the semaphore bulkhead; there is no thread-pool variant, see Reactive, Kotlin and RxJava.
The order in which the aspects nest is described in Annotations and aspect order. The bulkhead aspect is the innermost
one by default (LOWEST_PRECEDENCE - 1), so retry, circuit breaker, rate limiter and time limiter run on the caller’s thread outside the thread-pool bulkhead.
Virtual threads and @ConcurrencyLimit
Virtual threads (JEP 444, final in Java 21) remove the thread pool as the natural ceiling of a service: each request or task gets a new, cheap thread, so
nothing limits how many of them hit a dependency at the same time. In Spring Boot you enable them with spring.threads.virtual.enabled=true
(see Concurrency alternatives). That makes a concurrency limit more important, not less, and it changes which bulkhead you
reach for.
The semaphore bulkhead is the virtual-thread bulkhead
A semaphore wait parks a virtual thread and releases its carrier thread. Resilience4j 2.3.0 replaced the synchronized blocks in the core modules with ReentrantLock, which removed the thread pinning
that those blocks caused, and the SemaphoreBulkhead of 2.4.0 uses a ReentrantLock for configuration changes and a Semaphore for admission. In a run, 50 virtual threads called a
bulkhead with maxConcurrentCalls(5) and maxWaitDuration(10s), each call blocked for 100 ms, and the observed maximum concurrency was 5. The whole batch took 1.08 s, which is the 50 calls
in ten waves of five. Every task ran on a virtual thread.
Bulkhead bulkhead = Bulkhead.of("isbn", BulkheadConfig.custom()
.maxConcurrentCalls(5)
.maxWaitDuration(Duration.ofSeconds(10)) // virtual threads can afford to wait
.build());
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 50; i++) {
executor.submit(() -> bulkhead.executeCallable(() -> {
Thread.sleep(100); // the blocking call: parks the virtual thread
return Thread.currentThread().isVirtual(); // true
}));
}
}
// peak concurrency inside the bulkhead: 5
Source: the output of the example above, JEP 444: Virtual Threads and the
Resilience4j: Bulkhead page. Here, unlike on an event loop, a non-zero maxWaitDuration is cheap: a parked virtual thread costs almost nothing.
The thread-pool bulkhead is not
The pool of a FixedThreadPoolBulkhead is a ThreadPoolExecutor with platform threads, and 2.4.0 does not let you supply a thread factory or an executor: the configuration has no such
property. In the same run a call made from a virtual thread reported isVirtual() == false inside the bulkhead. The hand-off therefore moves the work from a cheap thread to a scarce one, and the pool
size is a hard cap on the platform threads that the dependency can occupy. That is a legitimate goal (a blocking client library that pins carrier threads is a good candidate), but for ordinary blocking I/O on virtual threads
the semaphore bulkhead gives the same concurrency ceiling without the hand-off, the queue and the context propagation.
Spring Framework 7: @ConcurrencyLimit
Spring Framework 7 added @ConcurrencyLimit and @EnableResilientMethods in the package org.springframework.resilience.annotation. The annotation was designed with virtual threads in mind: its
Javadoc says it is particularly useful where there is no thread pool limit in place. It is a deliberately small feature, next to the @Retryable of the same package. The page
Spring Framework native resilience covers the whole package; the facts that matter for choosing are these, taken from the
Spring Framework 7.0.9 sources and a run:
-
The limit is
valueorlimit(anintthat has no default and must be given:1makes the method a lock, a larger value is pool-like,-1is unbounded), orlimitStringfor a${…}placeholder or SpEL expression. -
policy(since 7.0.3) isThrottlePolicy.BLOCKby default andThrottlePolicy.REJECTon request.BLOCKmakes further callers wait without a timeout.REJECTthrowsorg.springframework.resilience.InvocationRejectedException, which extendsjava.util.concurrent.RejectedExecutionExceptionand carries the target. -
On a method the throttle belongs to that method of that bean instance. On a class, all proxied methods of the instance share one throttle.
-
It is enabled by
@EnableResilientMethodson a configuration class, and it works by Spring proxies: there is no AspectJ weaving and no Resilience4j dependency.
@Configuration
@EnableResilientMethods
class ResilienceConfig { }
@Component
class Limited {
@ConcurrencyLimit(2) // BLOCK: extra callers wait
String blocking() { return slowCall(); }
@ConcurrencyLimit(limit = 2, policy = ConcurrencyLimit.ThrottlePolicy.REJECT)
String rejecting() { return slowCall(); }
}
// six virtual threads calling blocking(): peak concurrency 2, 614 ms for six 200 ms calls
// four calls to rejecting() at once: two succeed, two fail with
// InvocationRejectedException: Concurrency limit reached: 2 - not allowed to enter ...Limited.rejecting
Source: the output of the example above, the Spring Framework reference: Resilience Features and the
@ConcurrencyLimit Javadoc.
Resilience4j @Bulkhead (semaphore) |
Spring Framework 7 @ConcurrencyLimit |
|
|---|---|---|
Rejection |
|
|
Waiting |
|
|
Configuration |
YAML, shared |
Annotation attributes; |
Fallback |
|
None: catch the exception yourself |
Events, meters, Actuator |
Events, |
None: the interceptor of 7.0.9 publishes no events and registers no meters |
Thread-pool variant |
Yes ( |
No |
Use @ConcurrencyLimit when all you need is "at most N at a time" on a bean you own, you are on Spring Framework 7, and you do not need to see the bulkhead from outside. Use the Resilience4j semaphore
bulkhead when you want the numbers (saturation gauges, rejection events), a bounded wait, a fallback, configuration by environment, or the same limit expressed across Boot 3 and Boot 4. They are not
meant to be stacked on one method; if you do, the inner one rejects first and the outer one never sees saturation.
Context propagation
A call on a pool thread runs in a different thread from the one that made it, and every ThreadLocal the caller set is invisible there: the logging MDC, the Spring Security context, the
current tracing observation, a request-scoped value. The semaphore bulkhead does not have this problem. The thread-pool bulkhead does, and its answer is io.github.resilience4j.core.ContextPropagator<T>,
an interface of three methods:
| Method | When it runs and what it does |
|---|---|
|
On the calling thread, at the moment of the submission. Reads the value to carry over. |
|
On the pool thread, just before the task. Installs the value. |
|
On the pool thread, in a |
With several propagators the values are collected in a map; if the same propagator instance appears twice, the last one wins. A list of propagators is configured on the
ThreadPoolBulkheadConfig either as classes or as instances, as shown above.
MDC
public class MdcContextPropagator implements ContextPropagator<Map<String, String>> {
@Override
public Supplier<Optional<Map<String, String>>> retrieve() {
return () -> Optional.ofNullable(MDC.getCopyOfContextMap());
}
@Override
public Consumer<Optional<Map<String, String>>> copy() {
return context -> context.ifPresent(MDC::setContextMap);
}
@Override
public Consumer<Optional<Map<String, String>>> clear() {
return context -> MDC.clear();
}
}
Source: ContextPropagator
at v2.4.0 and the Resilience4j: Bulkhead page. In a run with MDC.put("traceId", "abc123") on the caller, a task submitted to a bulkhead without the
propagator read null, and with it read abc123. In the Spring Boot run, six requests whose controller set traceId to t-<isbn> showed exactly those values inside the pool threads.
A class listed under contextPropagators in YAML is created with its no-argument constructor, so it cannot receive Spring beans: use the customizer for anything that needs injection.
Security context
public class SecurityContextPropagator implements ContextPropagator<Authentication> {
@Override
public Supplier<Optional<Authentication>> retrieve() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication());
}
@Override
public Consumer<Optional<Authentication>> copy() {
return authentication -> authentication.ifPresent(a -> {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(a);
SecurityContextHolder.setContext(context);
});
}
@Override
public Consumer<Optional<Authentication>> clear() {
return authentication -> SecurityContextHolder.clearContext();
}
}
Source: ContextPropagator
at v2.4.0. The run used spring-security-core 7.1.1 and a TestingAuthenticationToken, and the pool thread saw the same principal. The propagator installs a new context that holds the
Authentication, rather than sharing the caller’s mutable SecurityContext object. The task may outlive the request, so carry only what the work needs, and remember that the
authentication stays valid for the duration of that task, not the request.
Tracing and everything else
Micrometer’s context-propagation library can capture every ThreadLocal that has been registered in its ContextRegistry and restore it on another thread, and the Micrometer
Observation current-observation accessor is registered there by micrometer-observation itself, through the service loader. A single propagator can therefore bridge the Observation
(and the tracing built on it), and any accessor of your own:
public class SnapshotContextPropagator implements ContextPropagator<ContextSnapshot> {
private static final ContextSnapshotFactory FACTORY = ContextSnapshotFactory.builder().build();
private static final ThreadLocal<ContextSnapshot.Scope> SCOPE = new ThreadLocal<>();
@Override
public Supplier<Optional<ContextSnapshot>> retrieve() {
return () -> Optional.of(FACTORY.captureAll());
}
@Override
public Consumer<Optional<ContextSnapshot>> copy() {
return snapshot -> snapshot.ifPresent(s -> SCOPE.set(s.setThreadLocals()));
}
@Override
public Consumer<Optional<ContextSnapshot>> clear() {
return snapshot -> {
ContextSnapshot.Scope scope = SCOPE.get();
if (scope != null) {
scope.close();
SCOPE.remove();
}
};
}
}
Source: Micrometer Context Propagation and the
Micrometer Observation reference. In the run, an Observation named bookshelf.request was open on the calling thread: inside a
bulkhead configured with this propagator ObservationRegistry.getCurrentObservation() returned it, and inside a bulkhead without the propagator it returned null. A ThreadLocal registered with
ContextRegistry.getInstance().registerThreadLocalAccessor("request-id", REQUEST_ID) was carried the same way. This run used the Observation API directly and did not start a tracer, so it
shows that the observation is carried across the thread boundary. The Micrometer Tracing span is bound to the current observation in Spring Boot’s tracing support, but check your trace
output when you add this in a real service. The tracing setup itself is covered in
Observability.
BulkheadFullException
io.github.resilience4j.bulkhead.BulkheadFullException extends RuntimeException. It has a private constructor and two static factories, one for a Bulkhead and one for a ThreadPoolBulkhead. The message names the
bulkhead:
Bulkhead 'isbn' is full and does not permit further calls
Bulkhead 'recommendations' is full and does not permit further calls
Source: the output of the examples above, and the
v2.4.0 BulkheadFullException source.
getBulkheadName() returns the name, so a fallback or an exception handler can tell which dependency is saturated without parsing the message. The bulkhead name was added to the exception in 2.4.0 (pull request 2349 in the release notes). The exception has no cause, and its stack trace is empty when writableStackTraceEnabled is false, which is the
setting to use for a bulkhead that rejects often. A second message, Bulkhead '%s' is full and thread was interrupted during permission wait, exists in the source for the semaphore case in which the waiting thread has the interrupt flag set.
What to do when you catch it: return a degraded response (see the Bookshelf example below), or let it propagate and map it to a status code. Do not retry it blindly: by default a Retry retries every exception, and a
CircuitBreaker records every exception as a failure, so a full bulkhead gets retried and counted against the breaker. The run below put a full semaphore bulkhead inside a circuit breaker inside a retry of three attempts:
Decorators.ofSupplier(() -> "x")
.withBulkhead(bulkhead) // full
.withCircuitBreaker(breaker)
.withRetry(retry) // 3 attempts
.decorate().get();
// default configs: 3 failed calls recorded by the circuit breaker, the retry made all 3 attempts
// ignoreExceptions(BulkheadFullException.class) on both: 0 failures recorded, 0 retries
Source: the output of the example above and the Resilience4j: Retry and Resilience4j: CircuitBreaker
pages. List io.github.resilience4j.bulkhead.BulkheadFullException under ignoreExceptions of the retry and the circuit breaker that wrap a bulkhead, or do not retry what a bulkhead has just rejected
(see Retry and Circuit breaker).
Events, metrics and endpoints
Events
Each bulkhead has an EventPublisher with three event types, defined in BulkheadEvent.Type:
| Event type | Published when |
|---|---|
|
A call got a permit (semaphore) or started running on a pool thread (thread pool). A call waiting in the queue has not produced the event yet. |
|
The semaphore wait ended without a permit, or the pool rejected the task. |
|
The call completed, successfully or not: success and failure are not distinguished. |
The publisher has onCallPermitted, onCallRejected, onCallFinished and the generic onEvent. In the thread-pool run above (core 1, maximum 3, queue 1) the events were three PERMITTED, two
REJECTED, three FINISHED, then one more PERMITTED and its FINISHED once the queued task started. Event objects print as, for example,
Bulkhead 'isbn' rejected a call. with the creation time in front.
The Boot starter keeps the last eventConsumerBufferSize events (default 100) of every bulkhead, with no switch to turn it on, and exposes them through the bulkheadevents endpoint. For a thread-pool
bulkhead the buffer is registered under the name ThreadPoolBulkhead-<name>, and the endpoint resolves either form.
curl -s http://localhost:8080/actuator/bulkheads
curl -s http://localhost:8080/actuator/bulkheadevents/recommendations
curl -s http://localhost:8080/actuator/bulkheadevents/isbn/call_rejected
{"bulkheads":["isbn","recommendations"]}
{"bulkheadEvents":[
{"bulkheadName":"recommendations","creationTime":"2026-10-04T18:14:59.634024+02:00[Europe/Madrid]","type":"CALL_REJECTED"},
{"bulkheadName":"recommendations","creationTime":"2026-10-04T18:14:59.635448+02:00[Europe/Madrid]","type":"CALL_PERMITTED"},
{"bulkheadName":"recommendations","creationTime":"2026-10-04T18:15:00.610765+02:00[Europe/Madrid]","type":"CALL_FINISHED"}]}
Source: the output of the example above (most events were removed for brevity), and the Resilience4j: Spring Boot 2 & 3 endpoint section.
The endpoint ids are bulkheads and bulkheadevents; both need to be listed in management.endpoints.web.exposure.include. The path accepts /{name} and /{name}/{type} with the event type in lower
case. The 2.4.0 release notes also list snapshot reads for the actuator event endpoints on Boot 3 and Boot 4 (issue 2189).
Metrics
The Micrometer binder registers gauges, tagged with name and any registry or instance tags. A semaphore bulkhead has two of them; a thread-pool bulkhead has seven. Both kinds share the prefix
resilience4j.bulkhead:
| Meter | Prometheus name | Kind | Meaning |
|---|---|---|---|
|
|
semaphore |
Permits that are free now. |
|
|
semaphore |
The configured |
|
|
thread pool |
Tasks waiting in the queue. |
|
|
thread pool |
The configured |
|
|
thread pool |
Threads in the pool now. |
|
|
thread pool |
The configured core size. |
|
|
thread pool |
The configured maximum size. |
|
|
thread pool |
Threads that are running a task now. |
|
|
thread pool |
Maximum pool size minus the active threads. |
These are gauges, not counters: Resilience4j does not count permitted or rejected calls as meters. To graph the rejection rate, count CALL_REJECTED events yourself, or count
your own fallback invocations. The saturation signals to alert on are available.concurrent.calls near 0 for the semaphore, and queue.depth near queue.capacity together with
active.thread.count near max.thread.pool.size for the pool.
curl -s 'http://localhost:8080/actuator/metrics/resilience4j.bulkhead.queue.depth?tag=name:recommendations'
{"availableTags":[],"description":"The queue depth","measurements":[{"statistic":"VALUE","value":2.0}],"name":"resilience4j.bulkhead.queue.depth"}
Source: the output of the example above (taken while eight requests were in flight), and the Resilience4j: Micrometer page. On the same run /actuator/prometheus
contained all seven thread-pool gauges for recommendations and both semaphore gauges for isbn, with the names in the table. The metric names are the constants of
ThreadPoolBulkheadMetricNames
and BulkheadMetricNames. The meters across all patterns, the
Prometheus and Grafana setup and the alert rules are in Observability.
The Bookshelf recommendations call
The recommendations-service is expensive and optional: the book page is still useful without "readers also liked". It is the textbook case for a thread-pool bulkhead plus a degraded answer.
The service gets four threads and a queue of two, and a seventh concurrent request is answered at once with an empty list instead of waiting.
@Component
class RecommendationsClient {
private final RestClient http;
RecommendationsClient(RestClient.Builder builder,
@Value("${bookshelf.recommendations.url}") String baseUrl) {
this.http = builder.baseUrl(baseUrl).build();
}
@Bulkhead(name = "recommendations", type = Bulkhead.Type.THREADPOOL,
fallbackMethod = "noRecommendations")
CompletableFuture<List<String>> forIsbn(String isbn) {
List<String> titles = http.get().uri("/recommendations/{isbn}", isbn).retrieve()
.body(new ParameterizedTypeReference<List<String>>() { });
return CompletableFuture.completedFuture(titles);
}
CompletableFuture<List<String>> noRecommendations(String isbn, BulkheadFullException e) {
log.warn("recommendations skipped for {}: {}", isbn, e.getMessage());
return CompletableFuture.completedFuture(List.of()); // degrade: an empty list
}
}
@RestController
class BookController {
@GetMapping("/books/{isbn}/recommendations")
List<String> recommendations(@PathVariable String isbn) {
MDC.put("traceId", "t-" + isbn); // propagated to the pool thread
try {
return recommendations.forIsbn(isbn).join();
} finally {
MDC.clear();
}
}
}
Source: Resilience4j: Spring Boot 2 & 3 (annotations) and the
Spring Framework: REST Clients reference. RestClient.Builder is available on Boot 4 with
spring-boot-starter-restclient. The YAML is the one shown above, with MdcContextPropagator registered under contextPropagators, plus the customizer for the rejection handler.
To see the isolation, the run stubbed the recommendations service with WireMock at a fixed delay of 700 ms and sent eight requests at once to a pool with core 2, maximum 4 and queue 2. Virtual threads were enabled for the container. The results:
-
Six requests got their recommendations and two got
[]. Four ran on the pool threads, two waited in the queue, and the two that found neither a thread nor a slot were answered by the fallback. WireMock saw six requests, so the rejected calls never reached the dependency. -
While the pool was saturated,
/books/978-1/priceanswered in 3 ms. The pricing call does not use the recommendations bulkhead, and the rejected requests did not hold threads. -
The metrics at that moment were
active=4,queue=2,pool=4, which is the picture in the figure at the top of the page. -
The MDC value reached the pool threads: the six executed calls saw six different
t-978-<n>values, for examplet-978-0,t-978-5,t-978-7,t-978-6,t-978-1andt-978-4in one run, exactly the six requests that were admitted. -
The
bulkheadevents/recommendationsendpoint listed twoCALL_REJECTED, theCALL_PERMITTEDandCALL_FINISHEDof the executed calls.
The controller still blocks its request thread with join() until the pool answers, so a slow recommendations service still holds up a request that was admitted. Two things bound that wait: the
queue (small on purpose, so that a queued call is not stuck behind a long line), and a time limiter around the stage, which is the next step if the 700 ms becomes
seconds. The REST client timeouts apply inside the pool thread as well.
Pitfalls
| Pitfall | What happens and what to do |
|---|---|
|
Neither is a Spring Boot property in 2.4.0: both are silently not applied (verified: the configuration kept |
|
|
Defaults left in place |
25 permits per instance, and a pool of |
Expecting the pool to grow past the core size |
A |
Setting only |
The default maximum is the CPU count, so |
|
The overflow runs on the caller’s thread and the bulkhead limits nothing (verified). Keep |
Blocking wait on event-loop or request threads |
A non-zero |
|
Retry retries all exceptions and the circuit breaker records them by default (verified: three failures from one call). Add it to |
Lost MDC, security or tracing context in the pool |
Pool threads have none of the caller’s thread-locals. Configure |
One bulkhead for several dependencies |
They compete for the same permits or threads, and a slow one starves the others. One instance per remote backend, as the instance-management guidance in Registries, configuration and decorators says. |
Lowering |
The source acquires the difference with |
Thread-pool bulkhead and reactive types |
Not supported: the reactive aspect extensions and operators use the semaphore bulkhead only. |
Thread-pool bulkhead on virtual threads |
The pool threads are platform threads (verified). For blocking I/O on virtual threads use the semaphore bulkhead. |
What changed recently
-
2.0.x removed Vavr from the core modules and requires Java 17. The readme.io Bulkhead examples still use
Try. -
2.3.0 replaced
synchronizedwithReentrantLockin the core modules, which removes the virtual-thread pinning those blocks caused. -
2.4.0 adds
resilience4j-spring-boot4for Spring Boot 4, Spring Framework 7 and Spring Cloud 5. The bulkhead-specific changes are the bulkhead name inBulkheadFullException, thebulkheadAspectOrdersetter (the key now binds from YAML),withThreadPoolBulkheadon moreDecoratorsbuilders, and snapshot reads for the event endpoints. TheBulkheadandThreadPoolBulkheadAPIs and the property names are unchanged from 2.3.x. The release notes for 2.4.0 are only on the GitHub release page. -
Spring Framework 7.0 introduced
@ConcurrencyLimit;policyandInvocationRejectedExceptionarrived in 7.0.3. -
The readme.io Bulkhead page predates these changes. It does not document
fairCallHandlingEnabled,writableStackTraceEnabledfor the semaphore bulkhead,contextPropagatorsorrejectedExecutionHandler, and itsregistry.bulkhead("name2", custom)example uses a variable that the page never defines. Treat the v2.4.0 source as the reference for names and defaults.
References
-
resilience4j-bulkheadsource at v2.4.0 (SemaphoreBulkhead,FixedThreadPoolBulkhead,BulkheadConfig,ThreadPoolBulkheadConfig,BulkheadFullException) -
resilience4j-spring6bulkhead aspect and configuration at v2.4.0 -
resilience4j-framework-commonbulkhead properties and customizers at v2.4.0 -
Resilience4j release notes up to 2.3.0 and the v2.4.0 release
-
Spring Framework: Resilience Features and the
@ConcurrencyLimitJavadoc