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

Three horizontal lanes separated by bulkhead walls, fed by the request threads of bookshelf-service. The top lane, pricing, has four threads of which one is busy, and answers in a few milliseconds. The middle lane, recommendations, is a ThreadPoolBulkhead with core size 2, maximum size 4 and queue capacity 2: all four threads are red because they wait for the slow recommendations-service, the two queue slots are full, and a seventh call is rejected with BulkheadFullException and answered by a fallback that returns an empty list. The bottom lane, the ISBN API, is a SemaphoreBulkhead with four permits of which two are taken

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 java.util.concurrent.Semaphore with maxConcurrentCalls permits.

Threads: a dedicated ThreadPoolExecutor plus a bounded queue.

Where the call runs

On the caller’s own thread. The bulkhead only admits it or not.

On a pool thread named bulkhead-<name>-<n>. The caller gets a CompletionStage.

What a caller does when the bulkhead is full

Waits up to maxWaitDuration for a permit (default: does not wait), then BulkheadFullException.

Is queued if the queue has room, otherwise BulkheadFullException at once. There is no wait time to configure.

Return types

Synchronous values, CompletionStage, and the reactive types through resilience4j-reactor and the RxJava modules.

CompletionStage only. The annotation rejects any other return type.

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 ContextPropagators.

Spring Boot prefix

resilience4j.bulkhead

resilience4j.thread-pool-bulkhead

Registry and annotation

BulkheadRegistry, @Bulkhead (the default type)

ThreadPoolBulkheadRegistry, @Bulkhead(type = Bulkhead.Type.THREADPOOL)

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

maxConcurrentCalls(int)

maxConcurrentCalls

25

Number of permits: the most calls inside the bulkhead at the same time. The builder accepts 0 or more, but the Spring Boot binder rejects a value below 1 with IllegalArgumentException.

maxWaitDuration(Duration)

maxWaitDuration

0 (do not wait)

How long a caller may block waiting for a permit. 0 rejects at once. The wait is converted to whole milliseconds, so a value below 1 ms is the same as 0. Must not be negative.

fairCallHandlingStrategyEnabled(boolean)

none: not a Spring Boot property

true

true gives the semaphore a fair mode, so that waiting callers get permits in first-in first-out order. false is cheaper but gives no ordering guarantee. In BulkheadConfig the getter is isFairCallHandlingEnabled().

writableStackTraceEnabled(boolean)

writableStackTraceEnabled

true

When false, BulkheadFullException has an empty stack trace, which saves time and log space when thousands of calls are rejected.

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

Two permits taken, a third call rejected
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:

Waiting up to 300 ms for the only permit
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

coreThreadPoolSize(int)

coreThreadPoolSize

Runtime.availableProcessors() - 1, at least 1 (7 on the 8-CPU machine of the runs)

corePoolSize of the ThreadPoolExecutor. At least 1. In Spring Boot a value of 0 or less means "not set".

maxThreadPoolSize(int)

maxThreadPoolSize

Runtime.availableProcessors() (8 in the runs)

maximumPoolSize. At least 1, and build() fails with IllegalArgumentException if it is smaller than the core size.

queueCapacity(int)

queueCapacity

100

Size of the ArrayBlockingQueue in front of the pool. 0 uses a SynchronousQueue: no waiting at all. In Spring Boot -1 means "not set" and 0 is a real value.

keepAliveDuration(Duration)

keepAliveDuration

20ms

How long a thread above the core size may stay idle before it ends. Core threads stay.

writableStackTraceEnabled(boolean)

writableStackTraceEnabled

true

Same as for the semaphore bulkhead.

contextPropagator(…)

contextPropagators (a list of class names)

empty

Classes (Spring Boot) or instances (builder) that copy thread-locals into the pool thread. See the section on context propagation.

rejectedExecutionHandler(RejectedExecutionHandler)

none: not a Spring Boot property

ThreadPoolExecutor.AbortPolicy

What the pool does when it is full. The default makes the bulkhead throw BulkheadFullException.

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:

The pool grows only when the queue is full
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:

ThreadPoolBulkhead with Decorators and a fallback
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.

application.yml for the Bookshelf bulkheads
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

maxConcurrentCalls, maxWaitDuration

resilience4j.bulkhead.configs.<name> / .instances.<name>

25, 0

See the table above. maxConcurrentCalls must be at least 1 and maxWaitDuration not negative.

coreThreadPoolSize, maxThreadPoolSize, queueCapacity, keepAliveDuration, contextPropagators

resilience4j.thread-pool-bulkhead.configs.<name> / .instances.<name>

see the table above

A key that is not set keeps the builder default. Setting a core size above the CPU count without setting maxThreadPoolSize fails at startup, because the default maximum is the CPU count.

writableStackTraceEnabled

both trees

true

baseConfig

both trees

none

Name of an entry under configs. An unknown name fails with ConfigurationNotFoundException. An instance with no baseConfig still inherits configs.default when that exists: in the run, an instance that set only coreThreadPoolSize: 3 took the maximum, queue size and propagators of default.

eventConsumerBufferSize

both trees

100

Size of the circular buffer behind the bulkheadevents endpoint. At least 1. Unlike the circuit breaker and the retry, there is no subscribeForEvents switch: the buffer exists for every bulkhead.

bulkheadAspectOrder

resilience4j.bulkhead (one level up)

Ordered.LOWEST_PRECEDENCE - 1

Position of the @Bulkhead aspect, shared by both types. The setter was added in 2.4.0; the key binds from YAML. A lower number is the outer aspect.

metrics.enabled, metrics.legacy.enabled

resilience4j.bulkhead and resilience4j.thread-pool-bulkhead

true, false

Switch the Micrometer binder on or off per kind. The legacy flag selects the older TaggedBulkheadMetrics registration instead of the publisher.

tags

resilience4j.bulkhead and resilience4j.thread-pool-bulkhead

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:

Customizers for fairCallHandling, the rejection handler and propagator instances
@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

flowchart TD A[Start: one remote dependency to protect] --> B{Is the call reactive,
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.

A semaphore bulkhead and a thread-pool bulkhead on the Bookshelf clients
@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 CompletionStage return type. An annotated method that returns a String fails at call time with java.lang.IllegalStateException: ThreadPool bulkhead is only applicable for completable futures.

  • A rejection reaches the fallback. For a CompletionStage method, the aspect converts the BulkheadFullException into 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, Flux and 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.

Fifty virtual threads through five permits
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 value or limit (an int that has no default and must be given: 1 makes the method a lock, a larger value is pool-like, -1 is unbounded), or limitString for a ${…​} placeholder or SpEL expression.

  • policy (since 7.0.3) is ThrottlePolicy.BLOCK by default and ThrottlePolicy.REJECT on request. BLOCK makes further callers wait without a timeout. REJECT throws org.springframework.resilience.InvocationRejectedException, which extends java.util.concurrent.RejectedExecutionException and 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 @EnableResilientMethods on a configuration class, and it works by Spring proxies: there is no AspectJ weaving and no Resilience4j dependency.

Spring Framework 7 concurrency limits
@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

BulkheadFullException, with the bulkhead name

InvocationRejectedException (policy REJECT), carrying the target object

Waiting

maxWaitDuration: bounded, default none

BLOCK waits without a timeout; REJECT never waits

Configuration

YAML, shared configs, customizers, SpEL names, runtime changeConfig

Annotation attributes; limitString for placeholders

Fallback

fallbackMethod, the Resilience4j rules

None: catch the exception yourself

Events, meters, Actuator

Events, resilience4j.bulkhead.* gauges, bulkheads and bulkheadevents endpoints

None: the interceptor of 7.0.9 publishes no events and registers no meters

Thread-pool variant

Yes (THREADPOOL)

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

Supplier<Optional<T>> retrieve()

On the calling thread, at the moment of the submission. Reads the value to carry over.

Consumer<Optional<T>> copy()

On the pool thread, just before the task. Installs the value.

Consumer<Optional<T>> clear()

On the pool thread, in a finally after the task. Removes the value, so that the next task of the pool thread does not see it.

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

A ContextPropagator for the logging 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

A ContextPropagator for the Spring Security Authentication
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:

A ContextPropagator that bridges Micrometer’s ContextSnapshot
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:

BulkheadFullException counted and retried unless it is ignored
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

CALL_PERMITTED

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.

CALL_REJECTED

The semaphore wait ended without a permit, or the pool rejected the task.

CALL_FINISHED

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.

The Actuator endpoints
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

resilience4j.bulkhead.available.concurrent.calls

resilience4j_bulkhead_available_concurrent_calls

semaphore

Permits that are free now.

resilience4j.bulkhead.max.allowed.concurrent.calls

resilience4j_bulkhead_max_allowed_concurrent_calls

semaphore

The configured maxConcurrentCalls.

resilience4j.bulkhead.queue.depth

resilience4j_bulkhead_queue_depth

thread pool

Tasks waiting in the queue.

resilience4j.bulkhead.queue.capacity

resilience4j_bulkhead_queue_capacity

thread pool

The configured queueCapacity.

resilience4j.bulkhead.thread.pool.size

resilience4j_bulkhead_thread_pool_size

thread pool

Threads in the pool now.

resilience4j.bulkhead.core.thread.pool.size

resilience4j_bulkhead_core_thread_pool_size

thread pool

The configured core size.

resilience4j.bulkhead.max.thread.pool.size

resilience4j_bulkhead_max_thread_pool_size

thread pool

The configured maximum size.

resilience4j.bulkhead.active.thread.count

resilience4j_bulkhead_active_thread_count

thread pool

Threads that are running a task now.

resilience4j.bulkhead.available.thread.count

resilience4j_bulkhead_available_thread_count

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.

Reading the gauges
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.

The recommendations client and the controller that uses it
@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/price answered 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 example t-978-0, t-978-5, t-978-7, t-978-6, t-978-1 and t-978-4 in one run, exactly the six requests that were admitted.

  • The bulkheadevents/recommendations endpoint listed two CALL_REJECTED, the CALL_PERMITTED and CALL_FINISHED of 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

fairCallHandlingEnabled or rejectedExecutionHandler in YAML

Neither is a Spring Boot property in 2.4.0: both are silently not applied (verified: the configuration kept true and AbortPolicy). Use a BulkheadConfigCustomizer or a ThreadPoolBulkheadConfigCustomizer.

@Bulkhead(type = THREADPOOL) on a method that does not return a CompletionStage

IllegalStateException: ThreadPool bulkhead is only applicable for completable futures. Return a CompletableFuture, or use the semaphore type.

Defaults left in place

25 permits per instance, and a pool of CPU - 1 core threads with a queue of 100 that hides saturation as latency. Size each bulkhead for the dependency it protects, and keep the queue short.

Expecting the pool to grow past the core size

A ThreadPoolExecutor adds threads above coreThreadPoolSize only when the queue is full. With a queue of 100, the extra threads almost never start.

Setting only coreThreadPoolSize above the CPU count

The default maximum is the CPU count, so build() fails with maxThreadPoolSize must be a greater than or equals to coreThreadPoolSize. Set both.

CallerRunsPolicy as the rejection handler

The overflow runs on the caller’s thread and the bulkhead limits nothing (verified). Keep AbortPolicy.

Blocking wait on event-loop or request threads

A non-zero maxWaitDuration parks the thread that called. Use 0 there; a non-zero value is cheap only on virtual threads.

BulkheadFullException retried and counted by the circuit breaker

Retry retries all exceptions and the circuit breaker records them by default (verified: three failures from one call). Add it to ignoreExceptions of both.

Lost MDC, security or tracing context in the pool

Pool threads have none of the caller’s thread-locals. Configure ContextPropagators. Classes in YAML need a no-argument constructor; beans are not picked up automatically.

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 maxConcurrentCalls with changeConfig under load

The source acquires the difference with acquireUninterruptibly, so the call waits until enough callers have left. Plan changes for quiet moments.

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 synchronized with ReentrantLock in the core modules, which removes the virtual-thread pinning those blocks caused.

  • 2.4.0 adds resilience4j-spring-boot4 for Spring Boot 4, Spring Framework 7 and Spring Cloud 5. The bulkhead-specific changes are the bulkhead name in BulkheadFullException, the bulkheadAspectOrder setter (the key now binds from YAML), withThreadPoolBulkhead on more Decorators builders, and snapshot reads for the event endpoints. The Bulkhead and ThreadPoolBulkhead APIs 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; policy and InvocationRejectedException arrived in 7.0.3.

  • The readme.io Bulkhead page predates these changes. It does not document fairCallHandlingEnabled, writableStackTraceEnabled for the semaphore bulkhead, contextPropagators or rejectedExecutionHandler, and its registry.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.