Skip to main content

Java 21 for Spring Boot developer: Virtual Threads, Pattern Matching, Records and String Templates

Java 21 for Spring Boot: measured virtual threads, switch over sealed types, records and Sequenced Collections, with listings run on JDK 21.0.13.

What do you think about this article?

Java 21 for Spring Boot developer: Virtual Threads, Pattern Matching, Records and String Templates
Clean ArchitecturePhoto:RDNE Stock projectPexels

At a Glance

Java 21 for Spring Boot: measured virtual threads, switch over sealed types, records and Sequenced Collections, with listings run on JDK 21.0.13.

You have a Spring Boot service that spends almost all its time waiting: on a database, on a remote service, on a slow client. With platform threads, every request in flight holds an operating-system thread, and the server's thread ceiling becomes the ceiling on concurrent requests. Java 21 offers another route, virtual threads, and it also finalizes other features that change how you write the domain: pattern matching for switch, record patterns, sealed classes and Sequenced Collections. The short answer is that virtual threads help only if your load is dominated by waiting and only if you switch them on, while the other features are useful right away because they make the domain model shorter and safer. In this article every listing was compiled and run on OpenJDK 21.0.13.

What is final in Java 21 and what is not

Java 21 is an LTS release, meaning it has extended support: the exact dates depend on the vendor of your distribution (Oracle, Temurin, Corretto) and must be read on its site. The status of the individual features, on the other hand, is written in the JEPs, the OpenJDK enhancement proposals, and you can check it. The table lists the ones we use, with the release in which they became final.

FeatureJEPStatus in Java 21
Virtual threads444final (release 21)
Pattern matching for switch441final (release 21)
Record patterns440final (release 21)
Sequenced Collections431final (release 21)
Sealed classes409final since release 17
Records395final since release 16
String Templates430preview only in 21 (--enable-preview), later withdrawn

The reading that matters is the last row: string templates are not a feature on which to build production code. After a second preview in Java 22 (JEP 459), the third (JEP 465) is listed as withdrawn (Withdrawn) on the project page: if you find the STR."..." syntax in an article, you know it is code that will not compile without preview. To format text, use text blocks with formatted(), which we look at below. Spring Boot 3.4 requires at least Java 17, while virtual threads require Java 21 and Spring Boot 3.2 or later.

The move from Java 17 to 21 is usually cheap, but not free. Check the libraries that manipulate bytecode or use reflection (Lombok, for example, added initial JDK 21 support in version 1.18.30, as its changelog reports), the versions of Mockito and ByteBuddy, and agents such as JaCoCo. A build that passes on 17 and breaks on 21 almost always breaks there.

Virtual threads: waiting is cheap

The problem: the thread stays busy while it waits

Tomcat, the server embedded in Spring Boot, by default handles requests with a pool of at most 200 threads (property server.tomcat.threads.max, default value 200 according to the Spring Boot properties appendix, which specifies that it has no effect when virtual threads are enabled). If every request waits 100 ms for a response, the thread stays hooked for that whole time doing nothing, and the two-hundred-and-first client waits in line. A platform thread is an operating-system thread: it costs memory for its stack and time to create, so you cannot have a hundred thousand of them.

A virtual thread is a thread managed by the JVM. When it blocks on an I/O call, the JVM detaches it from the operating-system thread that was hosting it (the carrier) and resumes it when the response is ready; in the meantime the carrier serves another virtual thread. The reference documentation is JEP 444.

A measurement you can repeat

The listing launches 10,000 tasks that sleep for 100 ms, simulating the wait for a response. It first hands them to a fixed pool of 200 threads (Tomcat's default ceiling), then to an executor that creates one virtual thread per task. The try (ex) block relies on the fact that ExecutorService has been AutoCloseable since Java 19: on closing, it waits for all the tasks to finish, so the time measured is the real one. With the fixed pool you can do the sum in your head: 10,000 tasks divided by 200 threads is 50 waves of 100 ms, about 5 seconds, and that is what comes out.


import java.time.Duration;
import java.util.concurrent.*;
import java.util.stream.IntStream;

public class Vt {
    static long run(ExecutorService ex, int tasks) throws Exception {
        long t0 = System.nanoTime();
        try (ex) {
            IntStream.range(0, tasks).forEach(i -> ex.submit(() -> {
                Thread.sleep(Duration.ofMillis(100)); // simulates 100 ms of I/O wait
                return i;
            }));
        } // close() waits for all tasks to finish
        return (System.nanoTime() - t0) / 1_000_000;
    }

    public static void main(String[] a) throws Exception {
        int tasks = 10_000;
        System.out.println("fixed pool of 200 threads:  " + run(Executors.newFixedThreadPool(200), tasks) + " ms");
        System.out.println("one virtual thread per task: " + run(Executors.newVirtualThreadPerTaskExecutor(), tasks) + " ms");
    }
}

// fixed pool of 200 threads:  5050 ms
// one virtual thread per task: 154 ms
// (JDK 21.0.13, 12 cores, five runs: pool 5050-5363 ms, virtual 153-325 ms)

The result says one precise thing and fails to say another. It says that, when the work is waiting, virtual threads remove the ceiling of 200: the time drops from about 5 seconds to about 150 ms, close to the 100 ms of a single wait. It does not say that your service will become 35 times faster: here the load is pure sleep, with no CPU, no database with a limited connection pool and no serialization. The ratio depends on your own ratio between waiting and computing. The times were measured on a 12-core machine, five runs, and they vary from one machine and one run to the next.

How to enable them in Spring Boot

Spring Boot does not use virtual threads by default, not even on Java 21. From version 3.2 you enable them with a property: spring.threads.virtual.enabled=true. With the property on, Tomcat serves every request on a virtual thread, and the same goes for the @Async and scheduling executors. The Spring Boot documentation warns that in some cases speed can drop because of pinned virtual threads, and recommends Java 24 or later for the best experience.

Pinning is the hidden cost of version 21: a virtual thread inside a synchronized block that performs blocking I/O does not free its carrier, and if all the carriers are blocked this way the advantage disappears. JEP 491 removed the limitation in Java 24. On Java 21 you diagnose it with -Djdk.tracePinnedThreads=full and you avoid it by replacing synchronized with ReentrantLock in the sections that do I/O.

  • I/O-dominated load with many concurrent requests: this is the case where virtual threads pay off, without rewriting the code in reactive style.
  • CPU-dominated load: no benefit, because a virtual thread does not compute any faster.
  • Database connection pool: it remains a limit. With 20 connections, even 10,000 virtual threads run at most 20 queries at a time; the wait moves to the pool.
  • The method: a load test with and without the property, on the same code and the same machine, comparing throughput and p99 latency.

The list is in decreasing order of importance for the decision, but the items are not alternatives: they are the four questions to ask before turning the property on. If the first answer is no (the load is not waiting) or the third is "the pool is small and the database is already the bottleneck", virtual threads do not change the outcome. The last item is the only one that produces a number, and that number holds only for your service.

Pattern matching and sealed classes: the compiler checks the cases

Imagine an API that receives three kinds of credentials: a user with email and plan, an API token with a scope, an OAuth token with provider and subject. Before Java 21 the code that derived the access level from them was a chain of instanceof with casts and a final else that threw an exception, and nobody warned you if you added a fourth type and forgot a branch. With a sealed interface, the compiler knows the complete list of permitted types (permits) and can check that the switch covers all of them.

The listing shows two methods. accessLevel uses pattern matching with when guards: the three case User labels are ordered from the most specific to the most general, because the compiler rejects a case that an earlier one would already cover. subject uses record patterns, which destructure the record directly in the case. In Java 21 all the components must be declared (var email, var premium, var admin): the anonymous pattern _ arrives with JEP 456 in Java 22. There is no default, and that is deliberate: a default would make the exhaustiveness check disappear.


public class Cred {
    sealed interface Credential permits User, ApiToken, OAuthToken {}
    record User(String email, boolean isPremium, boolean isAdmin) implements Credential {}
    record ApiToken(String scope, long expiresAt) implements Credential {}
    record OAuthToken(String provider, String subject) implements Credential {}

    static String accessLevel(Credential c) {
        return switch (c) {
            case User u when u.isPremium() -> "premium";
            case User u when u.isAdmin() -> "admin";
            case User u -> "free";
            case ApiToken t -> t.scope();
            case OAuthToken o -> "oauth-" + o.provider();
        };
    }

    static String subject(Credential c) {
        return switch (c) {
            case User(var email, var premium, var admin) -> email;
            case ApiToken(var scope, var expiresAt) -> "token-" + scope;
            case OAuthToken(var provider, var subject) -> subject;
        };
    }

    public static void main(String[] args) {
        Credential[] all = {
            new User("a@example.com", true, false),
            new User("b@example.com", false, true),
            new User("c@example.com", false, false),
            new ApiToken("read:articles", 1_800_000_000L),
            new OAuthToken("github", "octocat")
        };
        for (Credential c : all) System.out.println(accessLevel(c) + " | " + subject(c));
    }
}

// premium | a@example.com
// admin | b@example.com
// free | c@example.com
// read:articles | token-read:articles
// oauth-github | octocat

The real value of this code shows when you break it. If you remove the OAuthToken branch from accessLevel, nothing happens at runtime in some rare and hard-to-test case: the file stops compiling. This is the error that javac 21 emits on that version:


// javac Cred2.java  (Cred.java without the OAuthToken branch in accessLevel)

// $ javac Cred2.java
// Cred2.java:8: error: the switch expression does not cover all possible input values
//         return switch (c) {
//                ^
// 1 error

The difference from a switch over a non-sealed interface is all here: there the compiler has to trust a default, and the fourth type added six months from now slips silently into the default. With sealed types, adding a new type to permits breaks compilation in every switch that needs updating, and that breakage is how the compiler tells you where to look. The references are JEP 441 (switch), JEP 440 (record patterns) and JEP 409 (sealed classes).

Records: value objects without boilerplate

A value object such as Email or Money is a value, not an entity: two emails with the same text are the same email. Writing one by hand means a final field, a constructor, a getter, equals, hashCode and toString, about thirty lines for a single field. A record generates all of this from the header alone (JEP 395), and the compact constructor (without a parameter list) is the place to put validation: it runs before the fields are assigned, so an invalid Email never exists.


import java.math.BigDecimal;
import java.util.*;

public class Rec {
    public record Email(String value) {
        public Email {
            if (!value.contains("@")) throw new IllegalArgumentException("Invalid email: " + value);
        }
    }
    record Money(BigDecimal amount, Currency currency) {}

    public static void main(String[] args) {
        Email e = new Email("user@example.com");
        System.out.println(e);
        System.out.println(e.value());
        System.out.println(e.equals(new Email("user@example.com")));
        try { new Email("no-at-sign"); } catch (IllegalArgumentException ex) { System.out.println(ex.getMessage()); }
        Money m1 = new Money(BigDecimal.TEN, Currency.getInstance("EUR"));
        Money m2 = new Money(BigDecimal.TEN, Currency.getInstance("EUR"));
        System.out.println(m1.equals(m2) + " " + (m1.hashCode() == m2.hashCode()));
        Set<Money> set = new HashSet<>(List.of(m1));
        System.out.println(set.contains(m2));
        // BigDecimal: 10 and 10.00 are not equal according to equals
        System.out.println(new Money(new BigDecimal("10"), Currency.getInstance("EUR")).equals(new Money(new BigDecimal("10.00"), Currency.getInstance("EUR"))));
    }
}

// Email[value=user@example.com]
// user@example.com
// true
// Invalid email: no-at-sign
// true true
// true
// false

The last two lines of the output contain the trap that the typical tutorial skips. Money(BigDecimal.TEN, ...) and another identical one are equal and have the same hash, so they work in a HashSet. But new BigDecimal("10") and new BigDecimal("10.00") are not equal according to equals, because they have a different scale, and the record inherits this behavior: two amounts that are the same value to an accountant turn out to be different. If your domain compares amounts, normalize the scale in the compact constructor (with setScale) or compare with compareTo.

A record is final and immutable by construction, but the immutability is shallow: if a component is a mutable List, the record does not protect it. In the compact constructor, copy it with List.copyOf. A record with more than five or six components is usually a sign that there are two concepts inside, to be separated into nested records. Compared with Lombok @Value, the record is a language feature and not an annotation processor: for new value objects it is the simplest choice, while Lombok remains useful where you need builders or logging already widespread in the code.

Sequenced Collections: first, last, reversed

Before Java 21, getting the last element of a List required list.get(list.size() - 1), and the last of a LinkedHashSet required iterating all the way to the end. JEP 431 introduces the interfaces SequencedCollection, SequencedSet and SequencedMap with a uniform API: getFirst, getLast, addFirst, reversed. The reversed() view does not copy: it is another perspective on the same collection.


import java.util.*;

public class Seq {
    public static void main(String[] args) {
        SequencedCollection<String> articles = new ArrayList<>(List.of("first", "second", "third"));
        System.out.println(articles.getFirst() + " " + articles.getLast());
        System.out.println(articles.reversed());
        articles.addFirst("zero");
        System.out.println(articles);
        SequencedMap<Long, String> timeline = new LinkedHashMap<>();
        timeline.put(1L, "a1"); timeline.put(2L, "a2");
        System.out.println(timeline.firstEntry() + " " + timeline.lastEntry());
        System.out.println(timeline.reversed());
        // a HashSet is not sequenced: it does not compile
        String text = """
                Article ID: %d
                Title: %s
                """.formatted(7, "Title");
        System.out.print(text);
    }
}

// first third
// [third, second, first]
// [zero, first, second, third]
// 1=a1 2=a2
// {2=a2, 1=a1}
// Article ID: 7
// Title: Title

In the output you see that reversed() returns the reverse order and that firstEntry and lastEntry on the map give the extreme pairs. The last part of the listing also shows the correct alternative to string templates: a text block with formatted() (Java 15), which needs no preview. The place where you get hurt is another one: HashSet and HashMap are not sequenced, because they do not guarantee an order, and the compiler rejects them. This is the real error with which you discover you picked the wrong structure for a timeline:


import java.util.*;

public class Bad {
    void f() {
        SequencedCollection<String> s = new HashSet<String>();
    }
}

// Bad.java:5: error: incompatible types: HashSet<String> cannot be converted to SequencedCollection<String>
//         SequencedCollection<String> s = new HashSet<String>();
//                                         ^
// 1 error

For a timeline of articles the right choice is therefore LinkedHashMap or ArrayList, never a HashMap in the hope that insertion order is preserved. If your repository returns a list already ordered by the query (ORDER BY published_at DESC), you do not need to turn it into a LinkedList to get getFirst: a List is already a SequencedCollection in Java 21.

What this site's backend actually does

This site's backend compiles with <release>21</release> (read in the pom.xml) and does not set spring.threads.virtual.enabled: Tomcat runs on platform threads. Virtual threads are used in one place only, the DatasetStreamController controller, which streams the result of a dataset query as server-sent events. It is an example of targeted adoption, with its costs. The listing that follows is a simplified excerpt of the source, not runnable on its own: it depends on Spring MVC and on the project's services.


// DatasetStreamController (simplified excerpt, not runnable on its own)
private static final long SSE_TIMEOUT_MS = 60_000L;
private static final ExecutorService VIRTUAL_EXECUTOR =
        Executors.newVirtualThreadPerTaskExecutor();

@PostMapping(value = "/{slug}/query/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter queryStream(@PathVariable String slug, @RequestBody(required = false) Map<String, Object> body /* ... */) {
    SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
    // ... authentication resolved synchronously, so the 401 is immediate ...

    CompletableFuture.runAsync(() -> {
        // loads the dataset, reserves 1 credit, runs the query,
        // sends the rows one per event, then confirms or refunds the credit
    }, VIRTUAL_EXECUTOR);

    return emitter;        // the servlet thread is immediately free again
}

An SSE session lasts up to 60 seconds (SSE_TIMEOUT_MS) and spends almost all its time sending rows to a client that may be slow. If a servlet thread handled it, that thread would stay busy for the whole duration, and a few slow sessions would saturate the pool. With runAsync on the virtual thread executor, the HTTP thread is returned immediately, and the sending happens on a thread that costs little to leave waiting. The source also documents the discarded alternative: SseEmitter instead of a WebFlux Flux, because the project is on the servlet stack and introducing Reactor would require a migration out of proportion to the current scale.

Virtual threads add no capacity to the database. The connection pool is 10 connections in development and 20 in production (maximum-pool-size in application.yml and application-prod.yml), with a 20-second timeout on requesting a connection, and the service starts with -Xmx512m. With thousands of virtual threads waiting, the wait moves to the pool and to memory. Here the risk is contained by factors unrelated to the executor: every session requires authentication and reserves a credit, and every stream has a default limit of 500 rows (DEFAULT_STREAM_LIMIT).

  • No orderly shutdown: the executor is a static field that nobody closes, so on shutdown the sessions in progress drop along with the process.
  • No cap on concurrent sessions: newVirtualThreadPerTaskExecutor() does not limit threads; the real limit comes from authentication, credits and the pool, not from a semaphore. For a hard cap you need a Semaphore acquired before runAsync.
  • Thread context is not propagated: the log correlation identifier (MDC) is tied to the request thread, and the code in runAsync logs without it. The remedy is to copy the MDC by hand, as explained in Java in the real world.

These are the three prices of targeted adoption, and they should be read as a reminder: none is a defect of the virtual thread itself, all stem from having chosen a static executor instead of the one managed by Spring. Whoever enables the global property gets integration with shutdown, but pays for extending the behavior to every request. Before doing so you need a load test on the real code, a pinning check with -Djdk.tracePinnedThreads=full and a review of the libraries that synchronize around I/O.

Frequently asked questions

Should I migrate to Java 21 today?

If you are on Java 17 with an enterprise application, usually yes: 21 is an LTS and the migration is modest, provided you check the libraries that use reflection or bytecode. Virtual threads are one more reason only if your load is dominated by I/O waits, and they must be enabled and measured. From Java 8 or 11 the jump is bigger and its costs and benefits deserve evaluation.

Do virtual threads work with all database drivers?

A JDBC driver blocks the thread during the query, and with a virtual thread the runtime can detach it from the carrier while it waits. There are, however, two exceptions. In Java 21 a virtual thread inside a synchronized block stays hooked to its carrier (pinning), and a driver or a pool that synchronizes around I/O suffers from it; the problem is solved in Java 24 with JEP 491. In addition, the connection pool remains a limit: 20 connections mean 20 queries at a time.

Can I use Spring 5 with Java 21?

Support for Java 21 and virtual threads arrives with Spring Framework 6.1 and Spring Boot 3.2. Spring Framework 5 is not designed for Java 21 and does not support them: if you are on Spring 5, the migration to Spring Boot 3, with the switch to the jakarta.* namespace, comes before the one to Java 21.

Are string templates usable?

No, not for production code. In Java 21 they were a preview (JEP 430) that requires --enable-preview, and the language never made them final. For formatted text use a text block with formatted(), and for JSON let Jackson serialize the records instead of building it by hand: a title containing quotation marks produces invalid JSON.

What you can do now

You can decide whether to enable virtual threads on your service with a measurement, not an impression, and you know where the cost hides (connection pool, pinning, thread context). You can model credentials or payments with sealed interfaces and records, and leave it to the compiler to flag the forgotten branch. And you can pick the right structure when order matters. The next step, for a Clean Architecture, is to bring these types into the domain layer, and for the concurrency upstream see Concurrency and multithreading and Virtual threads and structured concurrency.

Sources

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects