All articles

// Knowledge.log — 技術記事

Reactor and Virtual Threads: When to Parallelize in Spring Boot

Learn when parallel and runOn help in Reactor, why subscribeOn does not split items, and how to measure CPU, queues, and latency in Spring Boot.

A WebFlux API receives several independent items, applies a heavy transformation to each one, and starts falling behind as the CPU approaches its limit. It is tempting to add subscribeOn(Schedulers.parallel()) and consider the work distributed. The code gained a convincing name; the items, however, still pass through a single worker.

The key is to separate three decisions:

  • concurrency: how many operations can be in progress;
  • parallelism: how many operations execute at the same time on different cores;
  • waiting model: whether the work uses CPU, non-blocking I/O, or blocking I/O.

Virtual threads help sustain many blocking operations. Schedulers.parallel() is a fixed pool of platform threads intended for CPU-bound work. One is not a more modern version of the other.

The following examples use Java 21, Spring Boot 3.5.16, and WebFlux. Afterward, we will compare five scenarios from a preserved local benchmark, including a below-limit control and a load that actually saturated all four available CPUs.

Example Versions and Upgrade Path

This example uses Spring Boot 3.5.16 with Java 21. That combination is supported: the Boot 3.5 line accepts Java 17 through 25, according to the official Spring Boot 3.5 requirements.

The current path is to upgrade the framework to Spring Boot 4.1 while keeping Java 21. Boot 4.1 supports Java 17 through 26, so you do not need to change the JDK merely to adopt that version. Because this is a Boot major-version change, perform the migration and run the application suite before considering a later JDK. The current Spring Boot matrix is the reference for this combination.

Nothing about the semantics of parallel(), runOn(), or subscribeOn() depends on migrating the example to Boot 4.1. Keeping Java 21 also preserves an appropriate LTS baseline for the path.

What subscribeOn Actually Changes

Consider this source:

Flux.range(0, items)
    .map(item -> digest(item, iterations))
    .subscribeOn(Schedulers.parallel());

subscribeOn chooses the worker where subscription and the upstream chain's request signals happen. It moves the flow to one scheduler worker, but does not distribute the items among the pool's workers. The map remains serial within that subscription.

This can be useful for moving a synchronous source off the thread that started the chain. It is also the right way to isolate a blocking call when applied directly to a Mono.fromCallable, but in that case the correct scheduler is boundedElastic():

Mono.fromCallable(() -> clienteLegado.buscar(id))
    .subscribeOn(Schedulers.boundedElastic());

For CPU-bound work with independent items, explicit partitioning is different:

int workers = Runtime.getRuntime().availableProcessors();

Flux.range(0, items)
    .parallel(workers)
    .runOn(Schedulers.parallel())
    .map(item -> digest(item, iterations))
    .sequential();

parallel(workers) creates the rails; runOn(...) schedules those rails on workers; sequential() exposes a regular Flux again and merges the results. It does not restore the items' original order.

The number of available processors is a starting point, not a license to manufacture more CPU with a larger integer. If four cores are busy, 32 workers add contention and queues; the processor does not feel pressured to create the other 28 cores.

flatMap, flatMapSequential, and parallel/runOn

These operators solve different problems, although all of them can keep multiple tasks in progress.

flatMap: Concurrency with No Ordering Promise

flatMap eagerly subscribes to inner publishers up to the concurrency limit and emits results as they arrive:

Flux.fromIterable(ids)
    .flatMap(
        id -> clienteWeb.buscar(id),
        32, // concorrência máxima
        8   // prefetch
    );

This form works well for non-blocking I/O when order does not matter. A concurrency of 32 allows up to 32 operations in progress, but that does not mean 32 calculations are executing in parallel. If the inner publishers remain on the same event loop and spend most of their time waiting for the network, you have concurrency without CPU parallelism.

Do not leave concurrency and prefetch at their defaults out of habit. Set limits based on dependency capacity, the connection pool, and acceptable latency.

flatMapSequential: Concurrent Inside, Ordered Outside

flatMapSequential also subscribes to inner publishers in advance, but emits in source order:

Flux.fromIterable(ids)
    .flatMapSequential(id -> clienteWeb.buscar(id), 32, 8);

If item 2 finishes before item 1, item 2's result must wait. This preserves order, but can increase memory retention and latency when an early item is slow. If only one inner publisher may exist at a time, the appropriate operator is concatMap, not concurrency disguised as one.

parallel().runOn(): Rails Executing on Workers

For synchronous, independent CPU-bound transformations, parallel().runOn() distributes items among rails executed by the scheduler:

Flux.fromIterable(comandos)
    .parallel(workers)
    .runOn(cpuScheduler)
    .map(this::calcular)
    .sequential();

There is parallelism here when cores are available. The cost is coordination, queues, and possible loss of the original order. For a small number of items or very short work, that cost can exceed the benefit.

The ParallelFlux documentation shows the distinction between creating rails and executing them; the Flux API details the contracts of flatMap and flatMapSequential.

Virtual Threads Neither Replace nor Change parallel()

In Reactor, Schedulers.parallel() remains a fixed pool of platform threads, normally sized according to the number of available processors. Enabling virtual threads in Spring Boot does not convert this scheduler.

With Java 21 or later, boundedElastic() can use virtual threads when the JVM system property reactor.schedulers.defaultBoundedElasticOnVirtualThreads is set before scheduler initialization. It should not go in application.properties; pass it with -D when starting the JVM:

java -Dreactor.schedulers.defaultBoundedElasticOnVirtualThreads=true -jar aplicacao.jar

Another option is to call System.setProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads", "true") at the beginning of main, before SpringApplication.run(...) and before any scheduler access.

This matters for blocking tasks isolated on boundedElastic(). It does not speed up a digest, compression, or any other CPU-bound workload. The spring.threads.virtual.enabled=true property, in turn, affects Spring Boot's auto-configured task execution infrastructure; it also does not transform Schedulers.parallel().

The practical rule is simple:

  • non-blocking I/O: use the reactive client and control flatMap concurrency;
  • unavoidable blocking I/O: wrap it in fromCallable and isolate it on boundedElastic(), evaluating virtual threads on Java 21;
  • independent CPU-bound work: evaluate parallel(workers).runOn(...), starting with workers equal to the number of available CPUs.

The Reactor scheduler reference describes the pools and the virtual-thread implementation of boundedElastic().

Executable Project Used in the Test

The preserved project contains only the WebFlux starter and pins the versions used in this example:

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.16</version>
    <relativePath/>
  </parent>
  <groupId>academy.devdojo</groupId>
  <artifactId>reactor-cpu-benchmark</artifactId>
  <version>1.0.0</version>
  <properties>
    <java.version>21</java.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

The endpoint receives the mode, creates eight items by default, and repeatedly executes SHA-256 for each item. There is no sleep and no external I/O:

package academy.devdojo;

import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;

@SpringBootApplication
@RestController
public class BenchmarkApplication {
  private final int cores = Runtime.getRuntime().availableProcessors();
  private final Scheduler oversized =
      Schedulers.newParallel("oversized", cores * 8);

  public static void main(String[] args) {
    SpringApplication.run(BenchmarkApplication.class, args);
  }

  @GetMapping("/work")
  Mono<Long> work(@RequestParam(defaultValue = "serial") String mode,
                  @RequestParam(defaultValue = "8") int items,
                  @RequestParam(defaultValue = "12000") int iterations) {
    Flux<Integer> source = Flux.range(0, items);
    Flux<Long> result = switch (mode) {
      case "subscribe" -> source
          .map(i -> digest(i, iterations))
          .subscribeOn(Schedulers.parallel());
      case "parallel" -> source
          .parallel(cores)
          .runOn(Schedulers.parallel())
          .map(i -> digest(i, iterations))
          .sequential();
      case "oversized" -> source
          .parallel(cores * 8)
          .runOn(oversized)
          .map(i -> digest(i, iterations))
          .sequential();
      default -> source.map(i -> digest(i, iterations));
    };
    return result.reduce(0L, Long::sum);
  }

  private static long digest(int seed, int iterations) {
    try {
      MessageDigest md = MessageDigest.getInstance("SHA-256");
      byte[] value = ByteBuffer.allocate(16)
          .putLong(seed)
          .putLong(0x5deece66dL)
          .array();
      for (int i = 0; i < iterations; i++) {
        value = md.digest(value);
      }
      return ByteBuffer.wrap(value).getLong();
    } catch (NoSuchAlgorithmException e) {
      throw new IllegalStateException(e);
    }
  }
}

To run it:

mvn -q clean package
java -jar target/reactor-cpu-benchmark-1.0.0.jar

The subscribe mode moves the subscription without splitting the items. The parallel mode uses four rails. The oversized mode creates 32 rails and 32 workers to show the cost of significantly exceeding the available CPU.

The oversized scheduler was kept this way in the preserved project to reproduce the benchmark. In an application, custom schedulers created with Schedulers.newParallel(...) should receive dispose() on shutdown; a Spring bean with destroyMethod = "dispose", as in the instrumentation example below, handles that lifecycle.

What the Local Benchmark Measured

The test ran on August 17, 2026 in this environment:

  • Linux 7.0.0-27-generic x86_64;
  • OpenJDK 21.0.11;
  • Maven 3.9.12;
  • Spring Boot 3.5.16;
  • 4 logical CPUs;
  • server and load client on the same machine, via localhost;
  • 8 items per request, with each item calculating SHA-256 12.000 times;
  • a 10-second warm-up in parallel mode with concurrency 4;
  • a measured duration of 20 seconds per scenario;
  • concurrency 1 in the below-limit control and 16 in the high-load scenarios.

In the high-load parallel scenario, a repetition monitored over 15 samples recorded average process CPU usage of 366,27% against a logical capacity of 400%. The load therefore crossed the CPU bottleneck that the test was designed to observe.

ScenarioConcurrencyRequestsThroughputErrorsp95p99
serial (event loop), below-limit control12.081104,03 req/s012,63 ms13,76 ms
serial, high load168.590427,94 req/s072,24 ms83,99 ms
subscribeOn(parallel), high load169.299463,75 req/s089,99 ms113,43 ms
4 rails with parallel/runOn, high load169.246461,61 req/s041,42 ms46,64 ms
32 rails/workers, high load169.059452,41 req/s046,16 ms51,85 ms

The repetition used to collect CPU data in four-rail mode achieved 451,62 req/s, zero errors, a p95 of 43,02 ms, and a p99 of 48,63 ms.

The most important result is not a contest over two decimal places of throughput. Under saturation, four rails substantially reduced tail latency compared with the serial and subscribeOn modes, and outperformed the 32-worker scheduler in both throughput and latency. subscribeOn reached 463,75 req/s in the recorded run, but had the worst tail: a p99 of 113,43 ms. It moved the work; it did not parallelize the eight items in each request.

This is a synthetic local CPU test. It does not measure I/O, virtual threads, a real network, containers competing for CPU, or production dependencies. The numbers verify operator semantics and behavior in this environment; they do not estimate another service's capacity.

Diagnosis, Correction, and Production Observability

Every symptom should end with a concrete change and a safe way to confirm its effect. An isolated metric can tell stories too; some just have a more creative script.

DiagnosisConcrete correctionWhat to observe with Micrometer
Serial CPU-bound work merely moved by subscribeOnFor independent items, use parallel(CPUs).runOn(cpuScheduler).map(...).sequential()Process CPU, tasks.active, tasks.pending, throughput, and p95/p99 for http.server.requests
More rails/workers than coresStart at availableProcessors() and reduce if tail latency worsens without a throughput gainCPU near the ceiling, growing pending count, tasks completed per second, and p99
Blocking call on the event loop or parallel schedulerWrap it in Mono.fromCallable and use boundedElastic(); prefer a non-blocking client when availablepending/active for the isolated scheduler, errors, timeouts, and HTTP p95/p99
flatMap opening too much workSet explicit concurrency and prefetch, aligned with the connection pool and dependency limitactive connections, timeouts, errors, JVM memory, latency, and pending count for the associated scheduler
flatMapSequential retaining results because of a slow itemUse flatMap if order is not required, or reduce concurrency; use concatMap if serialization is intentionalp99, JVM memory, dependency time, and the difference between started and completed rates
CPU saturated, queue growing, and throughput stableReduce rails/concurrency or limit admission; scale CPU only after validating the profileCPU, pending count, request rate, errors, and p95/p99 in the same dashboard and time window
boundedElastic saturatedRemove blocking where possible; limit concurrency and the isolation queue capacitytasks pending/active/completed/submitted, rejections, timeouts, and HTTP tail latency

To instrument a dedicated scheduler, add Actuator and the Reactor integration with Micrometer:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
  <groupId>io.projectreactor</groupId>
  <artifactId>reactor-core-micrometer</artifactId>
</dependency>

Then wrap the scheduler that executes the workload:

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tags;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.observability.micrometer.Micrometer;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;

@Configuration
class SchedulerConfiguration {
  @Bean(destroyMethod = "dispose")
  Scheduler cpuScheduler(MeterRegistry registry) {
    int workers = Runtime.getRuntime().availableProcessors();
    Scheduler scheduler = Schedulers.newParallel("cpu-work", workers);
    return Micrometer.timedScheduler(
        scheduler,
        registry,
        "reactor.cpu",
        Tags.of("workload", "digest"));
  }
}

Use this cpuScheduler in runOn. The instrumentation exposes submitted, active, pending, and completed tasks under the chosen prefix. Combine this with Actuator HTTP metrics, process CPU, JVM memory, and dependency metrics. The official Reactor and Micrometer integration provides the instrumentation contract; Actuator documents exporting metrics and supported registries.

Avoid inferring saturation from the global thread count. Besides not representing queues, throughput, or tail latency, that kind of estimate does not cover virtual threads usefully. Production diagnosis must correlate resources, pending work, completion rate, errors, and latency percentiles.

When to Adopt It and When to Back Off

The recommendation is firm but conditional: use parallel().runOn() when the work is demonstrably CPU-bound, the items are independent, there is enough volume to pay for coordination, and the metrics show improved tail latency or throughput. Start with no more than one worker per CPU available to this workload.

Do not use the technique to accelerate I/O, to “enable” virtual threads, or to compensate for a blocking dependency. In those cases, control concurrency with flatMap, preserve non-blocking I/O, or isolate legacy code on boundedElastic().

If the metrics show saturation without a capacity gain, back off or reduce concurrency instead of adding workers.

Next Step

Reproduce all four modes with a CPU-bound transformation from your service, instrument a dedicated scheduler with Micrometer, and compare throughput, errors, p95, and p99 starting at one worker per CPU. Only then adjust the rails—one number at a time.

javaspring-bootreactorwebfluxperformance

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles