Skip to content
Languages

Java Virtual Threads: Best Practices, Pinning and Spring Boot Setup

Virtual threads became final in JDK 21 through JEP 444, and most questions about them are really about limits: when they raise throughput, what still ties them to an OS thread, and which thread-pool habits to drop. This guide covers the mounting mechanism, the JDK 24 change to synchronized, the Spring Boot switch and a comparison with Go's goroutines. The Java snippets are short sketches built from the patterns in JEP 444 and Oracle's guide; they were not executed for this article.

By Published

8 min readEditorial analysisUpdated
  • Java
  • virtual threads
  • concurrency
  • Spring Boot
  • JDK 21
What to remember

Virtual threads buy throughput for blocking, I/O-heavy work, not speed for CPU-bound work. Create one per task, never pool them, throttle scarce resources with a Semaphore, and on JDK 24 or later stop rewriting synchronized just to avoid pinning.

Platform threads vs virtual threads: mounting on carriers

A platform thread is a thin wrapper around an OS thread, so how many you can run is bounded by what the operating system can afford. A virtual thread is also a java.lang.Thread, but it is not tied to one OS thread. The JDK scheduler mounts it on a platform thread, called its carrier, runs it until it blocks on I/O or a call such as BlockingQueue.take(), then unmounts it so the carrier can run another virtual thread. When the operation can complete, the virtual thread is resubmitted and may resume on a different carrier.

The scheduler is a work-stealing ForkJoinPool in FIFO mode whose parallelism defaults to the number of available processors; the jdk.virtualThreadScheduler.parallelism property tunes it. A virtual thread's stack lives on the garbage-collected heap as stack chunk objects that grow and shrink, which is why an idle virtual thread costs heap memory rather than an OS thread. Virtual threads are always daemon threads and run at the fixed priority Thread.NORM_PRIORITY.

Timeline of two carrier threads: VT-A runs on carrier 1, blocks on a socket read and unmounts, VT-B and VT-C then run on carrier 1 while VT-D runs on carrier 2, and VT-A later resumes on carrier 2.
Blocking unmounts the virtual thread and frees its carrier for other work. On JDK 24 and later, only pinning, such as a blocking callback under a native frame or a blocked class initializer, keeps the carrier occupied while the thread waits. RecallDeck diagram

When virtual threads help, and when they don't

JEP 444 is blunt: virtual threads exist to provide scale, meaning higher throughput, not speed, meaning lower latency. They pay off when an application has many concurrent tasks, more than a few thousand, that spend most of their time waiting: the classic thread-per-request server that calls a database and two downstream HTTP services. Little's law explains why. At a fixed latency, throughput can only grow if more requests are in flight, and virtual threads make an in-flight request cheap.

They do not help CPU-bound work. Ten thousand virtual threads computing hashes still share the same cores, and the scheduler does not time-slice them, so a long computation simply occupies its carrier. Oracle's adoption guide adds a rule of thumb: an application that never has 10,000 or more virtual threads is unlikely to benefit. For parallel computation, keep a sized pool, parallel streams or a ForkJoinPool.

Should this workload use virtual threads?
WorkloadVirtual threads?Why
REST service calling a DB and APIsYesMostly blocked on I/O
50,000 outbound HTTP callsYes, with a SemaphoreCheap waiting; cap the load you send
Image resizing or hashingNoCPU-bound; cores are the limit
A few long-lived workersRarelyFar below 10,000 threads, little gain

Best practices: one virtual thread per task, never a pool

Write plain blocking code and give every concurrent task its own virtual thread. Executors.newVirtualThreadPerTaskExecutor() starts a new virtual thread for each submitted task; Thread.ofVirtual() and Thread.startVirtualThread() do the same without an executor. Do not pool them: JEP 444 says virtual threads are cheap and plentiful and should never be pooled. If a fixed pool was really a throttle for a fragile downstream, replace it with a Semaphore, as in this sketch modeled on the examples in JEP 444 and Oracle's guide. A JDBC connection pool already does that job for the database, so it needs no extra semaphore.

ThreadLocal still works, but its economics change. Each of a million virtual threads gets its own copy, and because virtual threads are never reused, caching an expensive object in a thread-local, the old SimpleDateFormat trick, now builds one per task. Oracle's guide suggests immutable alternatives such as DateTimeFormatter. For request context, scoped values, final since JDK 25 through JEP 506, share read-only data with callees and child threads more cheaply than thread-locals.

import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.stream.IntStream;

public class Fanout {
    // At most 20 calls to a rate-limited partner API at once.
    static final Semaphore API_PERMITS = new Semaphore(20);

    public static void main(String[] args) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 10_000).forEach(i ->
                executor.submit(() -> {
                    API_PERMITS.acquire();
                    try {
                        Thread.sleep(Duration.ofMillis(50)); // stands in for a blocking HTTP call
                        return i;
                    } finally {
                        API_PERMITS.release();
                    }
                }));
        } // close() waits for every submitted task
    }
}

Pinning: what JDK 24 fixed and what remains

A pinned virtual thread cannot unmount while it blocks, so it holds its carrier hostage. In JDK 21 to 23 this happened inside synchronized blocks or methods and inside native methods or foreign functions, and JEP 444 recommended replacing synchronized with ReentrantLock where pinning was frequent and long-lived around I/O. JEP 491, delivered in JDK 24, reimplemented monitors so virtual threads acquire, hold and release them independently of their carriers. Blocking to enter a monitor, or inside Object.wait(), now unmounts the thread.

Pinning remains in narrower cases: when native code, reached through a native method or the Foreign Function and Memory API, calls back into Java that blocks; and when a thread blocks while loading or initializing a class, or waits for another thread to initialize one. JEP 491 removes the jdk.tracePinnedThreads property, so setting it has no effect. Use the JFR event jdk.VirtualThreadPinned, on by default with a 20 ms threshold, and print it with jfr print --events jdk.VirtualThreadPinned recording.jfr. On JDK 21 to 23 the ReentrantLock advice still applies.

Spring Boot: one property and two side effects

Spring Boot 3.2 added the switch. The current reference documentation says to set spring.threads.virtual.enabled to true, requires Java 21 or later and strongly recommends Java 24 or later, the release where synchronized stopped pinning. With it on, Tomcat and Jetty process requests on virtual threads, the auto-configured applicationTaskExecutor becomes a SimpleAsyncTaskExecutor that uses virtual threads, and the scheduler becomes a SimpleAsyncTaskScheduler. Properties that size thread pools stop having an effect.

Two side effects catch teams. Virtual threads are daemon threads, so an application kept alive only by @Scheduled jobs can exit; the documentation recommends spring.main.keep-alive=true. And moving blocking calls onto virtual threads moves the bottleneck: a connection pool sized for 200 platform threads now faces thousands of concurrent callers. Oracle's guide notes that a database connection pool already acts as a semaphore, so size it deliberately and watch connection wait times after the switch.

# application.properties (Spring Boot 3.2 or later, Java 21 or later)
spring.threads.virtual.enabled=true
# Virtual threads are daemon threads: keep the JVM alive explicitly
spring.main.keep-alive=true

Virtual threads vs goroutines

Both are user-mode threads multiplexed onto a small number of OS threads, so a Go developer will find the model familiar. The differences sit in scheduling and in the surrounding language. Goroutines have been asynchronously preemptible since Go 1.14, so a tight loop without function calls no longer stalls the scheduler, while Java's virtual-thread scheduler does not currently time-slice. Go also builds channels into the language; Java code coordinates through java.util.concurrent and, eventually, structured concurrency.

Describe structured concurrency carefully. StructuredTaskScope has been a preview API since JDK 21, and JEP 533 delivered a seventh preview in JDK 27, which still requires --enable-preview. JEP 543, which makes the API final, is integrated into JDK 28, the release in development as of November 2026 and scheduled for general availability on 9 March 2027. In an interview, say exactly that: fine for experiments, not yet a standard API in any generally available JDK.

Java virtual threads compared with Go goroutines
AspectJava virtual threadsGo goroutines
Start oneThread.ofVirtual().start(task)go f()
SchedulerForkJoinPool; parallelism = CPUsGo runtime; GOMAXPROCS caps running threads
PreemptionNo time slicing; yields when it blocksAsynchronous since Go 1.14
StackHeap stack chunks that grow and shrinkStarts small, grows on the heap
Blocked in native codePins the carrierThread in a syscall is outside GOMAXPROCS
Coordinationjava.util.concurrent, scoped valuesChannels, sync, context

Practice: four questions to rehearse

Answer each in under a minute: why virtual threads raise throughput but not single-request latency; why pooling them is wrong and what replaces a throttling pool; what pins a virtual thread on JDK 21 compared with JDK 24 and later; and what you would check after enabling spring.threads.virtual.enabled in a Spring Boot service. A strong answer to the last one mentions the database pool, libraries that cache in ThreadLocal, and the JFR pinning event.

Then add one follow-up of your own for each answer, such as “what happens to a virtual thread that calls a native library which calls back into blocking Java code?”, and answer it from memory a day later. Pair this with the Java preparation plan and the Spring Boot guide. RecallDeck's backend track turns these answers into spaced-repetition cards so the details are still there on interview day.

Quick answers

Frequently asked questions

Are virtual threads faster than platform threads?

No. A virtual thread runs code at the same speed as a platform thread. The gain is throughput: you can keep far more blocking tasks in flight, so a server limited by its thread count can serve more concurrent requests at a similar latency.

Should I still replace synchronized with ReentrantLock?

On JDK 24 and later, not for pinning reasons, because JEP 491 lets virtual threads unmount inside synchronized. On JDK 21 to 23, replace it where a synchronized block runs often and guards long blocking I/O; rarely used blocks and short in-memory critical sections can stay.

Can I use virtual threads with Spring WebFlux?

Spring Boot routes WebFlux's blocking-execution support to the applicationTaskExecutor, which uses virtual threads when they are enabled. For new code, Oracle's guide advises against mixing synchronous blocking code with asynchronous frameworks, so pick one model your team can debug.

How many virtual threads can one JVM run?

There is no fixed cap: JEP 444 says the traditional thread dump is unsuitable once there are thousands or millions of virtual threads and adds a new jcmd dump format. In practice the limit is heap memory for their stacks and the downstream resources they wait on, so cap access to scarce resources with semaphores rather than capping the thread count.

Source notes

References and review policy

Information checked on November 7, 2026. Section links identify sources for factual claims and technical explanations. Interpretations, practice scenarios and preparation recommendations are RecallDeck’s editorial work.

From reading to recall

Practice the full interview loop.

RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.

Start studying

Keep going

Languages132 min

Top 100 Go Interview Questions and Answers

Prepare with 100 Go interview questions and detailed answers on slices, interfaces, concurrency, runtime, testing, distributed systems, and reliability.

100 detailed answers