Why Java 19 Virtual Threads Outperform Traditional Threads and Go Goroutines
This article introduces Java 19's virtual threads, compares their performance and scheduling with traditional platform threads and Go goroutines, provides practical code examples, benchmark results, and migration guidelines, and explains the underlying M:N scheduling mechanisms that make virtual threads more efficient for high‑concurrency, I/O‑bound workloads.
Java Thread Model
Traditional Java threads map one‑to‑one with operating‑system kernel threads, causing significant overhead when many threads are created because each thread consumes system resources and incurs context‑switch costs.
To address this, Java 19 introduces virtual threads (also called lightweight threads). Platform threads remain as before, while a large number of virtual threads are multiplexed onto a smaller pool of platform threads (M:N scheduling), allowing many more concurrent tasks with lower resource consumption.
Creating Java Virtual Threads
New Thread‑related APIs
Thread.ofVirtual()and
Thread.ofPlatform()are the new factory methods for creating virtual and platform threads respectively.
<code>// Output thread ID (includes virtual and platform threads) – Thread.getId() is deprecated in JDK 19
Runnable runnable = () -> System.out.println(Thread.currentThread().threadId());
// Create a virtual thread
Thread vt = Thread.ofVirtual().name("testVT").unstarted(runnable);
vt.start();
// Create a platform thread
Thread pt = Thread.ofPlatform().name("testPT").unstarted(runnable);
pt.start();</code>For quick creation and start, use
Thread.startVirtualThread(Runnable):
<code>Runnable runnable = () -> System.out.println(Thread.currentThread().threadId());
Thread vt = Thread.startVirtualThread(runnable);
</code>Virtual threads can be queried with
Thread.isVirtual(), and they support the usual
joinand
sleepoperations.
<code>Runnable r = () -> System.out.println(Thread.currentThread().isVirtual());
Thread vt = Thread.startVirtualThread(r);
</code>An executor that creates a new virtual thread per task is available via
Executors.newVirtualThreadPerTaskExecutor():
<code>try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> System.out.println("hello"));
}
</code>Because virtual threads are a preview feature in JDK 19, they must be compiled and run with the
--enable-previewflag:
Compile:
javac --release 19 --enable-preview Main.javaRun:
java --enable-preview MainPerformance Comparison: Platform vs. Virtual Threads
A benchmark runs 10,000 one‑second sleep tasks using three approaches: a cached thread pool (unlimited platform threads), a fixed pool of 200 platform threads, and a virtual‑thread executor.
<code>ScheduledExecutorService ses = Executors.newScheduledThreadPool(1);
// ... code to print OS thread count ...
</code>Results:
Cached thread pool: out‑of‑memory after ~3,900 OS threads, execution time > 50 s.
Fixed pool (200 threads): 207 OS threads, execution time ≈ 50 s.
Virtual threads: only 15 OS threads, execution time ≈ 1.6 s.
The data shows virtual threads achieve dramatically higher throughput with far fewer OS threads, especially for I/O‑bound workloads.
Comparison with Go Goroutines
Both Java virtual threads and Go goroutines use M:N scheduling. Example code demonstrates similar logic in both languages, though Go’s
gokeyword is more concise.
<code>// Go example
package main
import ("fmt"; "time")
func say(s string) { for i:=0;i<5;i++ { time.Sleep(100*time.Millisecond); fmt.Println(s) } }
func main() { go say("world"); say("hello") }
</code> <code>// Java example
public final class VirtualThreads {
static void say(String s) throws InterruptedException {
for (int i=0;i<5;i++) { Thread.sleep(Duration.ofMillis(100)); System.out.println(s); }
}
public static void main(String[] args) throws InterruptedException {
var vt = Thread.startVirtualThread(() -> say("world"));
say("hello");
vt.join();
}
}
</code>Go also provides channels for communication, while Java uses blocking queues (e.g.,
ArrayBlockingQueue) to achieve similar patterns.
Scheduling Internals
Go’s G‑M‑P model uses a global run queue (GRQ) and per‑processor local queues (LRQ) with work‑stealing. Java’s virtual‑thread scheduler is a FIFO
ForkJoinPoolthat maps virtual threads onto platform threads; it also employs work‑stealing to avoid thread starvation, mirroring Go’s approach.
When a virtual thread blocks on I/O or a
BlockingQueue.take(), it is unmounted from its platform thread and later remounted, allowing the platform thread to execute other work. Certain operations (e.g., inside
synchronizedblocks or native calls) keep the virtual thread fixed to its platform thread, which can reduce throughput.
Best Practices and Migration
Replace existing thread‑pool‑based executors with
Executors.newVirtualThreadPerTaskExecutor()or use virtual threads directly.
Avoid pooling virtual threads; they are cheap enough to create per task.
Prefer
ReentrantLockover
synchronizedto prevent virtual threads from being fixed.
Be cautious with
ThreadLocalin massive virtual‑thread scenarios due to memory overhead.
Conclusion
The article explains Java’s thread model, how virtual threads work, their performance advantages over platform threads, and how they compare to Go’s goroutines. Virtual threads are a preview feature in JDK 19 and may become standard in future releases, offering a powerful tool for high‑concurrency, I/O‑bound applications.
macrozheng
Dedicated to Java tech sharing and dissecting top open-source projects. Topics include Spring Boot, Spring Cloud, Docker, Kubernetes and more. Author’s GitHub project “mall” has 50K+ stars.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.