← Recipes

Virtual Threads and Structured Concurrency

Use virtual threads for high-throughput blocking I/O, then group related subtasks with structured concurrency.

Flags and launch options

--enable-preview-Djdk.virtualThreadScheduler.parallelism-Djdk.virtualThreadScheduler.maxPoolSize-Djdk.tracePinnedThreads-Djdk.traceVirtualThreadLocals

Virtual threads are standard in modern Java and usually need no JVM flag. Structured concurrency in Java 25 uses StructuredTaskScope, which is still a preview API, so code that uses it must be compiled and run with --enable-preview.

-Djdk.virtualThreadScheduler.parallelism sets the target number of carrier threads used by the virtual-thread scheduler. -Djdk.virtualThreadScheduler.maxPoolSize sets an upper bound for scheduler carrier threads when compensation is needed. -Djdk.tracePinnedThreads is a diagnostic property for finding code that pins a virtual thread to its carrier. -Djdk.traceVirtualThreadLocals helps find code that stores thread-local values in virtual threads.

Option details

--enable-preview
Enables preview APIs at compile time and run time. In Java 25, StructuredTaskScope is preview, so both javac and java need this option for code that imports it.
-Djdk.virtualThreadScheduler.parallelism=4
Sets the target parallelism of the virtual-thread scheduler. This is the normal target number of carrier platform threads used to run virtual threads, which can be useful in small containers.
-Djdk.virtualThreadScheduler.maxPoolSize=8
Sets the maximum number of carrier platform threads the scheduler may create. This is different from parallelism: parallelism is the target, while max pool size is the ceiling used when the scheduler compensates for blocking or pinned carriers.
-Djdk.tracePinnedThreads=short
Prints a short stack trace when a virtual thread is pinned to its carrier while blocking. Use it during investigation to find synchronized or native blocking regions that limit scalability.
-Djdk.traceVirtualThreadLocals=true
Prints a stack trace when code sets a thread-local value from a virtual thread. Use it to find libraries or application code that accidentally keep per-request state in many short-lived virtual threads.

When to use it

Use this for services that handle many concurrent blocking I/O operations, such as HTTP calls, database queries, file operations, queue consumers, or fan-out request handlers. Virtual threads provide scale, not faster CPU execution, so they are best for waiting-heavy workloads rather than CPU-bound loops.

Structured concurrency is useful when one request splits into related subtasks and all of them must finish, fail, or be cancelled as one unit. It improves cancellation, error handling, and thread-dump observability compared with unbounded ad hoc futures.

Avoid pooling virtual threads. Prefer one virtual thread per task and keep blocking code simple and synchronous.

Java version support

Virtual threads were previewed in Java 19 and Java 20, then finalized in Java 21. Structured concurrency incubated in Java 19 and Java 20, then previewed from Java 21 through Java 25. It continues as a sixth preview in Java 26 and a seventh preview targeted to Java 27. In Java 25, StructuredTaskScope is still preview, so both compile and run steps need --enable-preview.

Related JEPs

Examples

Run a virtual-thread-per-task service

java -jar app.jar

No virtual-thread flag is needed when the application itself creates virtual threads.

Run Java 25 code that uses StructuredTaskScope

javac --release 25 --enable-preview Main.java
java --enable-preview Main

The preview option must be present at both compile time and run time.

Cap carrier parallelism for a constrained container

java -Djdk.virtualThreadScheduler.parallelism=4 \
  -Djdk.virtualThreadScheduler.maxPoolSize=8 \
  --enable-preview \
  -jar app.jar

This does not limit the number of virtual threads. It sets a target of 4 carrier threads and a ceiling of 8 carriers for scheduler compensation.

Trace pinned virtual threads during investigation

java -Djdk.tracePinnedThreads=short \
  --enable-preview \
  -jar app.jar

Use this in staging or a focused reproduction because the trace output can be noisy.

Trace virtual-thread-local usage

java -Djdk.traceVirtualThreadLocals=true \
  --enable-preview \
  -jar app.jar

Use this to find thread-local writes that may create avoidable memory pressure with many virtual threads.

Complete code example

This example uses structured concurrency to fetch two pieces of a profile as one scoped operation. If one subtask fails, the scope fails as a unit instead of leaving unrelated work running in the background.

import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;

record Profile(String user, String orders) {}

public class Main {
  public static void main(String[] args) throws Exception {
    System.out.println(loadProfile());
  }

  static Profile loadProfile() throws Exception {
    try (var scope = StructuredTaskScope.open(
        Joiner.allSuccessfulOrThrow(),
        config -> config.withTimeout(Duration.ofSeconds(2)))) {
      var user = scope.fork(Main::fetchUser);
      var orders = scope.fork(Main::fetchOrders);

      scope.join();
      return new Profile(user.get(), orders.get());
    }
  }

  static String fetchUser() throws InterruptedException {
    Thread.sleep(Duration.ofMillis(100));
    return "user-123";
  }

  static String fetchOrders() throws InterruptedException {
    Thread.sleep(Duration.ofMillis(150));
    return "orders=7";
  }
}
Compile and run with --enable-preview because this example imports StructuredTaskScope.

Verify

References

See Oracle's Java 25 guides for virtual threads and structured concurrency.