Java · lesson 36 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Virtual Threads in Java
Learn Java 21 virtual threads by running them: creating them, 100,000 blocking tasks, pinning, structured concurrency and scoped values.
By SkillByExample · Updated
Virtual threads (Java 21, Project Loom) are threads managed by the JVM instead of the operating
system. When one blocks — on a sleep, a socket, a lock — the JVM unmounts it from its carrier thread
and runs another, so thread-per-task code scales to hundreds of thousands of concurrent tasks.
Every example is a single file run with the JDK 21 source launcher; the last two use preview APIs
and need --enable-preview --source 21.
Creating virtual threads
import java.util.concurrent.Executors;
public class CreateDemo {
public static void main(String[] args) throws Exception {
Thread vt = Thread.ofVirtual().name("worker-1").start(() ->
System.out.println("in " + Thread.currentThread().getName() + ", virtual=" + Thread.currentThread().isVirtual()));
vt.join();
Thread quick = Thread.startVirtualThread(() -> System.out.println("startVirtualThread ran"));
quick.join();
System.out.println("daemon=" + quick.isDaemon() + ", main is virtual=" + Thread.currentThread().isVirtual());
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var f = executor.submit(() -> Thread.currentThread().isVirtual() ? "task on a virtual thread" : "platform");
System.out.println(f.get());
}
}
}
$ java CreateDemo.java
in worker-1, virtual=true
startVirtualThread ran
daemon=true, main is virtual=false
task on a virtual thread
Virtual threads use the ordinary Thread API: build one with Thread.ofVirtual(), start one with
Thread.startVirtualThread, or — the usual choice for servers — submit tasks to
newVirtualThreadPerTaskExecutor, which creates a fresh virtual thread per task. They are always
daemon threads, so join (or the executor’s close) is what keeps main from exiting early.
100,000 blocking tasks
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class ManyThreadsDemo {
public static void main(String[] args) {
AtomicInteger done = new AtomicInteger();
long start = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // stands in for an I/O wait
return done.incrementAndGet();
});
}
} // close() waits for every task
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("completed: " + done.get());
System.out.println("finished in under 5 seconds: " + (ms < 5_000));
}
}
$ java ManyThreadsDemo.java
completed: 100000
finished in under 5 seconds: true
100,000 tasks each blocked for a full second, yet the whole batch finished in a few seconds, because all of them were sleeping at the same time — each on its own virtual thread, sharing a handful of carriers. With 100,000 platform threads the JVM would need gigabytes of stack or fail to create them. (The program prints a comparison instead of milliseconds so the output does not change between machines.)
Platform pool versus virtual threads
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThroughputDemo {
static long run(ExecutorService executor, int tasks) {
long start = System.nanoTime();
try (executor) {
for (int i = 0; i < tasks; i++)
executor.submit(() -> { Thread.sleep(100); return null; });
}
return (System.nanoTime() - start) / 1_000_000;
}
public static void main(String[] args) {
long platform = run(Executors.newFixedThreadPool(200), 2_000);
long virtual = run(Executors.newVirtualThreadPerTaskExecutor(), 2_000);
System.out.println("platform pool needs about 10 rounds of 100 ms: " + (platform >= 1_000));
System.out.println("virtual threads take about one round: " + (virtual < 500));
System.out.println("virtual at least 3x faster: " + (platform > 3 * virtual));
}
}
$ java ThroughputDemo.java
platform pool needs about 10 rounds of 100 ms: true
virtual threads take about one round: true
virtual at least 3x faster: true
A pool of 200 platform threads can only have 200 sleeps in flight, so 2,000 tasks take ten 100 ms rounds — at least a second. The virtual-thread executor starts all 2,000 at once and finishes in roughly one round. The win comes purely from waiting concurrently; a CPU-bound task would not speed up, because the number of carriers is still the number of cores.
Pinning: synchronized versus ReentrantLock
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;
public class PinningDemo {
static long time(Runnable body) {
long start = System.nanoTime();
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10; i++) ex.submit(body);
}
return (System.nanoTime() - start) / 1_000_000;
}
static void sleep100() {
try { Thread.sleep(100); } catch (InterruptedException e) { throw new RuntimeException(e); }
}
public static void main(String[] args) {
long pinned = time(() -> { Object own = new Object(); synchronized (own) { sleep100(); } });
long free = time(() -> { var own = new ReentrantLock(); own.lock(); try { sleep100(); } finally { own.unlock(); } });
System.out.println("synchronized: 10 sleeps ran one after another: " + (pinned >= 900));
System.out.println("ReentrantLock: 10 sleeps overlapped: " + (free < 500));
}
}
$ java -Djdk.virtualThreadScheduler.parallelism=1 PinningDemo.java
synchronized: 10 sleeps ran one after another: true
ReentrantLock: 10 sleeps overlapped: true
The system property limits the scheduler to one carrier thread to make the effect obvious. Each
task locks its own object, so there is no contention — yet the synchronized version took ten
full sleeps, because on JDK 21 a virtual thread that blocks inside synchronized stays pinned
to its carrier and nothing else can run. With ReentrantLock the sleeping thread unmounts and all
ten sleeps overlap. JDK 24 (JEP 491) removed this limitation for synchronized.
Structured concurrency (preview)
import java.util.concurrent.StructuredTaskScope;
public class StructuredDemo {
record Page(String profile, String orders) {}
static String slowOrders() throws InterruptedException {
try {
Thread.sleep(5_000);
return "orders";
} catch (InterruptedException e) {
System.out.println("orders task cancelled");
throw e;
}
}
static Page load(boolean profileFails) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var profile = scope.fork(() -> {
Thread.sleep(50);
if (profileFails) throw new IllegalStateException("profile service down");
return "Alice";
});
var orders = scope.fork(() -> profileFails ? slowOrders() : "2 orders");
scope.join().throwIfFailed();
return new Page(profile.get(), orders.get());
}
}
public static void main(String[] args) throws Exception {
System.out.println(load(false));
long start = System.nanoTime();
try {
load(true);
} catch (Exception e) {
System.out.println("failed: " + e.getCause().getMessage());
}
System.out.println("did not wait for the 5 s task: " + ((System.nanoTime() - start) / 1_000_000 < 2_000));
}
}
$ java --enable-preview --source 21 StructuredDemo.java
Note: StructuredDemo.java uses preview features of Java SE 21.
Note: Recompile with -Xlint:preview for details.
Page[profile=Alice, orders=2 orders]
orders task cancelled
failed: profile service down
did not wait for the 5 s task: true
fork runs each subtask on its own virtual thread, and join waits for the scope. With
ShutdownOnFailure, the first failure shuts the scope down and interrupts the siblings, so the
five-second orders call is cancelled instead of leaking, and throwIfFailed rethrows the cause
wrapped in an ExecutionException. The Note: lines come from the compiler because the API is a
preview in Java 21.
Scoped values instead of ThreadLocal (preview)
public class ScopedValueDemo {
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
static void handle(String id) {
ScopedValue.where(REQUEST_ID, id).run(() -> {
log("validating");
log("saving");
});
}
static void log(String step) {
System.out.println("[" + REQUEST_ID.get() + "] " + step);
}
public static void main(String[] args) throws Exception {
Thread a = Thread.ofVirtual().start(() -> handle("req-1"));
a.join();
Thread b = Thread.ofVirtual().start(() -> handle("req-2"));
b.join();
System.out.println("bound outside the scope? " + REQUEST_ID.isBound());
}
}
$ java --enable-preview --source 21 ScopedValueDemo.java
Note: ScopedValueDemo.java uses preview features of Java SE 21.
Note: Recompile with -Xlint:preview for details.
[req-1] validating
[req-1] saving
[req-2] validating
[req-2] saving
bound outside the scope? false
A ScopedValue is bound for the duration of run and visible to everything called from it,
without passing a parameter through every method. Unlike a ThreadLocal it is immutable and is
unbound automatically when the scope ends, so there is nothing to clean up — which matters when
millions of short-lived virtual threads would each carry their own ThreadLocal copy. Scoped
values became final in JDK 25.
In Spring Boot 3.2+, setting spring.threads.virtual.enabled=true runs each web request on a
virtual thread, so ordinary blocking code (JDBC, RestClient) gets these benefits unchanged.
Practice
- Start 5 virtual threads in a loop, collect their names into a thread-safe list, join them, and print the sorted list.
Solution
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class FiveThreads {
public static void main(String[] args) throws InterruptedException {
List<String> names = Collections.synchronizedList(new ArrayList<>());
List<Thread> threads = new ArrayList<>();
for (int i = 1; i <= 5; i++)
threads.add(Thread.ofVirtual().name("v-" + i).start(() -> names.add(Thread.currentThread().getName())));
for (Thread t : threads) t.join();
Collections.sort(names);
System.out.println(names);
}
}
$ java FiveThreads.java
[v-1, v-2, v-3, v-4, v-5]
The threads finish in an unpredictable order, so the list is sorted before printing.
- Limit concurrency to 3 simultaneous “database calls” with a
Semaphorewhile still using one virtual thread per task, and print the maximum concurrency observed.
Solution
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
public class SemaphoreLimit {
public static void main(String[] args) {
Semaphore permits = new Semaphore(3);
AtomicInteger inFlight = new AtomicInteger(), max = new AtomicInteger();
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 50; i++) {
ex.submit(() -> {
permits.acquire();
try {
max.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
Thread.sleep(20);
} finally {
inFlight.decrementAndGet();
permits.release();
}
return null;
});
}
}
System.out.println("max concurrent calls: " + max.get());
}
}
$ java SemaphoreLimit.java
max concurrent calls: 3
This is the virtual-thread way to protect a scarce resource: cheap threads for every task, and
a Semaphore around just the part that must be limited.
- Replace the sleep in
ThroughputDemowith a CPU-bound loop and check whether virtual threads still win.
Solution
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CpuBound {
static volatile long sink;
static long run(ExecutorService executor) {
long start = System.nanoTime();
try (executor) {
for (int i = 0; i < 64; i++)
executor.submit(() -> { long s = 0; for (long j = 0; j < 50_000_000L; j++) s += j; sink = s; });
}
return (System.nanoTime() - start) / 1_000_000;
}
public static void main(String[] args) {
int cores = Runtime.getRuntime().availableProcessors();
long platform = run(Executors.newFixedThreadPool(cores));
long virtual = run(Executors.newVirtualThreadPerTaskExecutor());
System.out.println("virtual more than 2x faster? " + (virtual * 2 < platform));
}
}
$ java CpuBound.java
virtual more than 2x faster? false
A busy loop never blocks, so a virtual thread never unmounts; with as many carriers as cores, both versions have the same compute capacity and finish in similar time.