Tools: Java Virtual Threads — Quick Guide

Tools: Java Virtual Threads — Quick Guide

Source: Dev.to

Java Virtual Threads — Quick Guide ## 01 · What Are Virtual Threads ## Blocking comparison ## 02 · Enable in Spring Boot ## Requirements ## What changes ## What virtual-threads-enabled: true Actually Does in Spring ## The Manual Offload Approach ## 03 Virtual Threads Adoption Strategy in Spring Boot ## Context ## Option 1: Property-Based Virtual Threads (Global Enablement) ## Description ## Benefits ## Risks and Limitations ## Pinning Risk ## ThreadLocal Assumptions Break ## Lack of Control ## Option 2: Manual Offload to Virtual Threads (Selective Adoption) ## Description ## ThreadLocal Considerations ## Benefits ## Trade-offs ## Consequences ## Positive ## Negative ## Final Verdict ## 03 · Pitfalls (Read Before Production) ## synchronized pins carrier threads ## ThreadLocal context loss during manual offload ## ThreadLocal leaks with pooled executors ## Native (JNI) calls pin silently ## MVC + WebFlux together ## CPU-bound work on virtual threads ## Final Takeaway Java 21+ · Spring Boot 3.2+ · Project Loom A concise, production-focused guide to Java Virtual Threads — what they are, how to enable them, when to use them, and the real-world pitfalls that can silently hurt performance. Before Project Loom, there is only one type of threads in Java, which is called platform thread in Project Loom. Platform threads are typically mapped 1:1 to kernel threads scheduled by the operating system. In Project Loom, virtual threads are introduced as a new type of threads. Virtual threads are typically user-mode threads scheduled by the Java runtime rather than the operating system. Virtual threads are mapped M:N to kernel threads. Platform and virtual threads are both represented using java.lang.Thread How to create virtual threads? The first approach to create virtual threads is using the Thread.ofVirtual method. In the code below, a new virtual thread is created and started. The return value is an instance of java.lang.Thread object. The second approach is using Thread.startVirtualThread(Runnable task) method. This is the same as calling Thread.ofVirtual().start(task). The third approach is using ThreadFactory. How to check if a thread is virtual? The new isVirtual() method in java.lang.Thread returns true is this thread is a virtual thread. Can virtual threads be non-daemon threads? No. Virtual threads are always daemon threads. So they cannot prevent JVM from terminating. Calling setDaemon(false) on a virtual thread will throw an IllegalArgumentException exception. Should virtual threads be pooled? No. Virtual threads are light-weight. There is no need to pool them. Can virtual threads support thread-local variables? Yes. Virtual threads support both thread-local variables (ThreadLocal) and inheritable thread-local variables (InheritableThreadLocal). Can virtual threads support thread-local variables? Yes. Virtual threads support both thread-local variables (ThreadLocal) and inheritable thread-local variables (InheritableThreadLocal). Can ExecutorService use virtual threads? An ExecutorService can start a virtual thread for each task. This kind of ExecutorServices can be created using Executors.newVirtualThreadPerTaskExecutor() or Executors.newThreadPerTaskExecutor(ThreadFactory threadFactory) methods. The number of virtual threads created by the Executor is unbounded. In the code below, a new ExecutorService is created to use virtual threads. 10000 tasks are submitted to this ExecutorService. Virtual Thread blocks Why we need virtual threads? One property. No code changes. It replaces Tomcat’s entire servlet thread pool with a virtual-thread-per-request executor. Every incoming HTTP request is immediately assigned a new virtual thread. There is no fixed pool size — Tomcat doesn’t cap anything. The JVM manages it all. This means your entire request lifecycle — from the moment the request hits the DispatcherServlet to the moment the response is written — runs on a virtual thread. Every blocking call inside that chain (RestTemplate, JDBC, Thread.sleep()) is automatically cheap because it’s already on a virtual thread. Tomcat’s default OS thread pool handles accept and dispatch, then the work is explicitly handed off to a virtual thread executor. For example, CompletableFuture.supplyAsync() offloads work to the virtual thread executor. The OS thread that accepted the request is released immediately. Spring MVC knows how to handle a returned CompletableFuture: The key point: Spring MVC does not block the servlet thread waiting for the future. It registers a callback internally and frees the thread immediately. Service and Client Layers Remain Unchanged The service and client layers stay completely unchanged in both approaches. The existing Spring Boot microservice handles incoming HTTP requests using Spring MVC (Servlet stack) and communicates with multiple downstream services using blocking clients such as RestTemplate and JDBC. Key constraints and characteristics: The goal is to improve concurrency and scalability using Java Virtual Threads, without breaking existing behavior or introducing subtle runtime risks. Two approaches are available in Spring Boot: (spring.threads.virtual.enabled=true) replaces Tomcat's servlet thread pool with a virtual-thread-per-request executor. If N concurrent requests enter pinned sections, N carrier threads are required. With only ~CPU-count carriers available, the application can stall. This breaks assumptions made by existing code that was written for pooled OS threads. CompletableFuture.supplyAsync(task, virtualThreadExecutor) Offloading creates a hard thread boundary. is not propagated automatically and must be captured and restored manually. This boundary is explicit and controlled. The property-based virtual thread approach is suitable only for codebases that are already virtual-thread-friendly. For this system, manual offloading is the safest and most effective strategy, delivering the benefits of virtual threads while preserving correctness and operational stability. Fix: use ReentrantLock Fix: capture & restore context 💡 This issue does not exist when using virtual-threads-enabled: true globally. Fix: enforce correct executor Enable pin logging (dev only): Virtual Threads are the best choice for blocking, I/O-heavy Spring Boot services you cannot rewrite. They give you scalability, simplicity, and production safety — without reactive complexity. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK: var thread = Thread.ofVirtual().name("My virtual thread") .start(() -> System.out.println("I'm running")) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: var thread = Thread.ofVirtual().name("My virtual thread") .start(() -> System.out.println("I'm running")) COMMAND_BLOCK: var thread = Thread.ofVirtual().name("My virtual thread") .start(() -> System.out.println("I'm running")) COMMAND_BLOCK: var factory = Thread.ofVirtual().factory(); var thread = factory.newThread(() -> System.out.println("Create in factory")); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: var factory = Thread.ofVirtual().factory(); var thread = factory.newThread(() -> System.out.println("Create in factory")); COMMAND_BLOCK: var factory = Thread.ofVirtual().factory(); var thread = factory.newThread(() -> System.out.println("Create in factory")); COMMAND_BLOCK: try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 10_00).forEach(i -> executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return i; })); } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 10_00).forEach(i -> executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return i; })); } COMMAND_BLOCK: try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 10_00).forEach(i -> executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return i; })); } COMMAND_BLOCK: # application.yml server: servlet: threads: virtual-threads-enabled: true Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # application.yml server: servlet: threads: virtual-threads-enabled: true COMMAND_BLOCK: # application.yml server: servlet: threads: virtual-threads-enabled: true CODE_BLOCK: [Virtual Thread] ↓ synchronized block ← carrier pinned ↓ blocking I/O Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: [Virtual Thread] ↓ synchronized block ← carrier pinned ↓ blocking I/O CODE_BLOCK: [Virtual Thread] ↓ synchronized block ← carrier pinned ↓ blocking I/O CODE_BLOCK: // Pins carrier public synchronized Product fetch(String id) { return restTemplate.getForObject("/p/{id}", Product.class, id); } // Safe private final ReentrantLock lock = new ReentrantLock(); public Product fetch(String id) { lock.lock(); try { return restTemplate.getForObject("/p/{id}", Product.class, id); } finally { lock.unlock(); } } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // Pins carrier public synchronized Product fetch(String id) { return restTemplate.getForObject("/p/{id}", Product.class, id); } // Safe private final ReentrantLock lock = new ReentrantLock(); public Product fetch(String id) { lock.lock(); try { return restTemplate.getForObject("/p/{id}", Product.class, id); } finally { lock.unlock(); } } CODE_BLOCK: // Pins carrier public synchronized Product fetch(String id) { return restTemplate.getForObject("/p/{id}", Product.class, id); } // Safe private final ReentrantLock lock = new ReentrantLock(); public Product fetch(String id) { lock.lock(); try { return restTemplate.getForObject("/p/{id}", Product.class, id); } finally { lock.unlock(); } } COMMAND_BLOCK: Map<String, String> mdc = MDC.getCopyOfContextMap(); SecurityContext sec = SecurityContextHolder.getContext(); return CompletableFuture.supplyAsync(() -> { if (mdc != null) MDC.setContextMap(mdc); SecurityContextHolder.setContext(sec); try { return service.doWork(); } finally { MDC.clear(); SecurityContextHolder.clearContext(); } }, ioExecutor); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: Map<String, String> mdc = MDC.getCopyOfContextMap(); SecurityContext sec = SecurityContextHolder.getContext(); return CompletableFuture.supplyAsync(() -> { if (mdc != null) MDC.setContextMap(mdc); SecurityContextHolder.setContext(sec); try { return service.doWork(); } finally { MDC.clear(); SecurityContextHolder.clearContext(); } }, ioExecutor); COMMAND_BLOCK: Map<String, String> mdc = MDC.getCopyOfContextMap(); SecurityContext sec = SecurityContextHolder.getContext(); return CompletableFuture.supplyAsync(() -> { if (mdc != null) MDC.setContextMap(mdc); SecurityContextHolder.setContext(sec); try { return service.doWork(); } finally { MDC.clear(); SecurityContextHolder.clearContext(); } }, ioExecutor); CODE_BLOCK: @Bean public Executor ioExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: @Bean public Executor ioExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } CODE_BLOCK: @Bean public Executor ioExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } COMMAND_BLOCK: private static final ExecutorService NATIVE_POOL = Executors.newFixedThreadPool(10); public Future<Result> callNative(String input) { return NATIVE_POOL.submit(() -> nativeLib.process(input)); } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: private static final ExecutorService NATIVE_POOL = Executors.newFixedThreadPool(10); public Future<Result> callNative(String input) { return NATIVE_POOL.submit(() -> nativeLib.process(input)); } COMMAND_BLOCK: private static final ExecutorService NATIVE_POOL = Executors.newFixedThreadPool(10); public Future<Result> callNative(String input) { return NATIVE_POOL.submit(() -> nativeLib.process(input)); } CODE_BLOCK: -XX:+PrintVirtualThreadPinning Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: -XX:+PrintVirtualThreadPinning CODE_BLOCK: -XX:+PrintVirtualThreadPinning COMMAND_BLOCK: // I/O work CompletableFuture.supplyAsync( () -> restTemplate.getForObject(...), Executors.newVirtualThreadPerTaskExecutor()); // CPU work CompletableFuture.supplyAsync( () -> heavyComputation(data), ForkJoinPool.commonPool()); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: // I/O work CompletableFuture.supplyAsync( () -> restTemplate.getForObject(...), Executors.newVirtualThreadPerTaskExecutor()); // CPU work CompletableFuture.supplyAsync( () -> heavyComputation(data), ForkJoinPool.commonPool()); COMMAND_BLOCK: // I/O work CompletableFuture.supplyAsync( () -> restTemplate.getForObject(...), Executors.newVirtualThreadPerTaskExecutor()); // CPU work CompletableFuture.supplyAsync( () -> heavyComputation(data), ForkJoinPool.commonPool()); - Extremely lightweight compared to platform (OS) threads. - Millions of virtual threads can be created safely. - Allow developers to write simple, blocking-style code while remaining highly scalable. - RestTemplate blocks an OS thread - Thread is idle during I/O - Under load → thread exhaustion - JVM suspends the virtual thread - Carrier thread is released immediately - Scales safely under high concurrency - Java 21+ (final, not preview) - Spring Boot 3.2+ - Each HTTP request runs on a fresh virtual thread - Controllers, services, RestTemplate → unchanged - It suspends servlet processing - It resumes when the future completes - The current implementation cannot be changed or rewritten. - The codebase contains: synchronized blocks and methods Heavy reliance on ThreadLocal (SecurityContext, MDC, request attributes) - synchronized blocks and methods - Heavy reliance on ThreadLocal (SecurityContext, MDC, request attributes) - The service performs I/O-heavy aggregation across multiple downstream services. - Scalability issues arise due to thread blocking under load. - synchronized blocks and methods - Heavy reliance on ThreadLocal (SecurityContext, MDC, request attributes) - Property-based virtual threads (spring.threads.virtual.enabled=true) - Manual offloading to a virtual-thread executor using CompletableFuture - Is assigned a fresh virtual thread - Runs entirely on that virtual thread (filters → controllers → services → response) - Executes blocking calls cheaply (RestTemplate, JDBC, Thread.sleep) - Zero code changes - Uniform behavior across the entire application - Automatic scalability for blocking I/O - synchronized blocks pin carrier threads - Pinning is invisible and global - Concurrent access can exhaust the small carrier thread pool - Virtual threads are short-lived - No thread reuse across requests - ThreadLocal data does not persist beyond a single request - No isolation boundary - No way to selectively exclude endpoints or code paths - Fixing issues requires rewriting synchronized and ThreadLocal-dependent code - Tomcat continues to use its default OS-thread servlet pool - Existing code runs unchanged on OS threads - I/O-heavy logic is explicitly offloaded using: - Natively supports CompletableFuture return types - Suspends request processing - Releases the servlet thread immediately - Resumes when the future completes - SecurityContext - MDC tracing data - Preserves existing assumptions (synchronized, ThreadLocal) - Avoids carrier-thread pinning in legacy code - Allows targeted use of virtual threads only where beneficial - Enables incremental migration - Clear isolation between OS-thread and virtual-thread execution - Slightly more boilerplate - Requires explicit context propagation - Virtual thread usage must be consciously applied - Improved scalability for I/O-heavy endpoints - No need to refactor existing synchronized code - Predictable runtime behavior - Clear migration path - Additional boilerplate for context propagation - Requires discipline to maintain offload boundaries - Virtual thread becomes glued to the carrier - 9 concurrent requests + 8 carriers → deadlock - SecurityContext - RequestAttributes - Someone replaces the executor with a fixed pool - ThreadLocals leak across requests - Some JDBC drivers - Crypto libraries - If both starters are present, Spring chooses MVC - No warning is shown - Virtual Threads → keep spring-boot-starter-web - Remove starter-webflux - Heavy computation - Image processing - Crypto loops