Java 25

Java’s journey in concurrency has been long and fascinating — evolving from manually managed threads to lightweight virtual threads and structured concurrency in the modern era. In this post, we’ll explore several approaches to running tasks concurrently, showing how each step improves simplicity, readability, and scalability.

You can run these examples with Java 25 directly from .java source files:

java Example.java

[!NOTE] For the scope of this post, we will not cover good old Threads (OS managed threads), but rather we’ll start directly using Virtual Threads (JVM managed threads).

1. Starting with the Basics — The Raw Thread

Here, we just want to run a simple task concurrently using a Thread (a virtual one). It’s the most fundamental building block for concurrency in Java.

void main() throws InterruptedException {
    Thread worker = Thread.startVirtualThread(() -> {
        IO.println("Running in a separate thread: " + Thread.currentThread().getName());
    });

    worker.join();
    IO.println("Main thread finished.");
}

What’s happening:

  • We create and start a new virtual thread to execute a task.
  • join() ensures the main thread waits for it to complete.

2. Thread Pools with ExecutorService

In this example, we want to run multiple tasks efficiently using a pool of worker threads. This avoids creating too many threads and lets us manage resources better.

import java.util.concurrent.*;
import java.util.stream.*;

void main() throws Exception {
    try (var executor = Executors.newFixedThreadPool(4)) {
        var futures = IntStream.range(0, 5)
            .mapToObj(i -> executor.submit(() -> {
                Thread.sleep(200);
                return "Task " + i + " done by " + Thread.currentThread().getName();
            }))
            .toList();

        for (var f : futures) IO.println(f.get());
    }
}

What’s happening:

  • We submit 5 tasks to a pool of 4 threads.
  • Each task sleeps for a short period and then returns a result.
  • The main thread retrieves all results via Future.get().

3. Asynchronous Pipelines with CompletableFuture

Now, let’s chain asynchronous operations to simulate fetching and processing data in a non-blocking way.

import java.util.concurrent.*;

void main() {
    try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
        CompletableFuture.supplyAsync(() -> {
            IO.println("Fetching data...");
            sleep(300);
            return "Data";
        }, pool)
        .thenApply(data -> {
            IO.println("Processing " + data);
            return data.length();
        })
        .thenAccept(len -> IO.println("Result length: " + len))
        .join();
    }
}

void sleep(long ms) {
    try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}

What’s happening:

  • CompletableFuture runs each step asynchronously.
  • Results flow from one stage to the next without blocking.
  • This pattern is powerful for IO-heavy operations.

4. Structured Concurrency — Cleanly Coordinating Subtasks

In this final example, we want to start two subtasks concurrently, wait for both to finish (or cancel the other if one fails), and then combine their results. Structured concurrency provides a safer and clearer way to handle multiple concurrent tasks as part of a single logical operation.

import java.time.Instant;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;

void main() throws Exception {
    try (var scope = StructuredTaskScope.open()) {
        Subtask<String> users = scope.fork(() -> loadData("users"));
        Subtask<String> orders = scope.fork(() -> loadData("orders"));

        // Wait for both subtasks to complete or for one to fail
        scope.join();

        // If join succeeded, both tasks completed successfully
        IO.println("Results: %s | %s"
                  .formatted(users.get(), orders.get()));
    }
}

String loadData(String type) throws InterruptedException {
    IO.println("Loading %s...".formatted(type));
    Thread.sleep(300);
    return "Loaded %s at %s".formatted(type, Instant.now());
}

[!IMPORTANT] ⚠️ Requires --enable-preview flag in Java 25.

What’s happening:

  • StructuredTaskScope.open() creates a scope for concurrent subtasks.
  • Each subtask is typed as Subtask<String> for clarity, using the import.
  • fork(...) schedules a subtask within the scope.
  • scope.join() waits for all tasks to finish or cancels others if one fails.
  • Results are retrieved safely using get().

There more advantages in using Structure Concurrency like scope cancellation, which let’s you handle complex concurrent tasks.

Using Virtual Threads in Spring Boot

Spring Boot

With Java 25’s virtual threads and structured concurrency, we’ve seen how to efficiently manage concurrent tasks in standalone applications. These same principles can be applied in modern frameworks like Spring Boot, where server threads handling HTTP requests can also leverage virtual threads. This enables scalable, non-blocking request processing while keeping code readable and maintainable.

Spring Boot 3.2+ supports using virtual threads for handling HTTP requests and other tasks, leveraging Java 19+ Project Loom features. This can greatly improve scalability for IO-bound workloads.

Configuring Virtual Threads

Enabling virtual threads withing Spring is as simple as including a configuration class like the following:

@EnableAsync
@Configuration
public class ThreadConfig {
    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
    }

    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }
}

Now if you define a new controller like the following:

@RestController
@RequestMapping("/thread")
public class ThreadController {
    @GetMapping("/name")
    public String getThreadName() {
        return Thread.currentThread().toString();
    }
}

and query it:

$ curl -s http://localhost:8080/thread/name
$ VirtualThread[#171]/runnable@ForkJoinPool-1-worker-4

you will be able to validate that indeed the request was handled by an instance of a virtual thread.

You can find a more in depth tutorial about Spring and Virtual Threads here: https://www.baeldung.com/spring-6-virtual-threads

Lessons Learned

Each step improves the developer experience, but also explains what problems the more advanced solution solves:

  • Threads: Simple but error-prone for multiple tasks.
  • Executors: Manage resources efficiently.
  • CompletableFuture: Express pipelines of async work.
  • Structured Concurrency: Provide clear scoping, automatic cancellation, and simple error handling.

Conclusion

Java now offers a rich toolbox for concurrent programming:

  • Threads are best for simple, one-off background tasks.
  • Executors are ideal when controlling the number of active threads.
  • CompletableFuture shines for composing dependent async tasks.
  • Structured Concurrency brings order and safety to complex concurrent workflows, aligning with how humans think about tasks and subtasks.

Each approach builds on the last — culminating in a model where concurrency is not just efficient, but also structured, predictable, and readable. Modern Java has never made concurrency feel so approachable.