What are virtual threads in Java, and how do they differ from platform threads?
A virtual thread is a lightweight thread managed by the JVM rather than by the operating system. It runs on a carrier platform thread — the JVM calls them carrier threads — but many virtual threads can share one carrier. The key property is the cost: creating a virtual thread is a matter of allocating an object on the heap and pushing it onto a scheduler queue, where creating a platform thread involves an operating system call and a substantial stack allocation.
The number that makes this concrete is the stack. A platform thread reserves a stack of typically a megabyte by default, so a thousand threads cost a gigabyte of address space before you run a single line of code. A virtual thread's stack lives on the heap and grows on demand, so a hundred thousand of them are entirely reasonable.
The practical effect is that blocking becomes cheap. When a virtual thread calls a blocking operation such as reading from a socket, the JVM unmounts it from its carrier and lets another virtual thread run on that carrier instead. A platform thread blocked on IO just wastes its carrier for the whole duration. That is the whole point: virtual threads make the thread-per-request model viable for IO-bound services, where the limiting factor is the number of concurrent requests rather than the CPU work per request.
What they do not change is the fundamentals. A virtual thread still executes one task at a time, so CPU-bound work does not speed up — you need real parallelism for that, and the carriers are still limited by the number of processors. And the concurrency problems you have are the same problems: two virtual threads sharing a counter still need an atomic class or a lock.
Here is the same thing as runnable code:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static void main(String[] args) throws InterruptedException {
int workers = 8;
AtomicInteger done = new AtomicInteger();
long start = System.nanoTime();
// A fixed pool of platform threads, each running a chunk of work.
ExecutorService pool = Executors.newFixedThreadPool(workers);
for (int i = 0; i < workers; i++) {
final int id = i;
pool.submit(() -> {
for (int j = 0; j < 1000; j++) {
burn(id * 1000L + j);
}
done.incrementAndGet();
});
}
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("workers finished : " + done.get());
System.out.println("elapsed ms : " + ms);
System.out.println("platform threads : one per worker, so the pool size is the limit");
}
static void burn(long value) {
long acc = 0;
for (int i = 0; i < 2_000; i++) {
acc = acc * 31 + value;
}
if (acc == Long.MIN_VALUE) {
System.out.println("never happens");
}
}
}