← Back to list

Virtual Threads vs Platform Threads: Modern Approach to Concurrency in Java

In software development, concurrency and parallelism have always been critical factors for performance. Executing tasks simultaneously…

Fuad Ahmadov · 2025-11-01 20:18 · 5 claps · 4.4 min read
#java-thread #java-virtual-threads #parallelstream #executorservice
Open on Medium ↗
Wiki topics: 💻 · Programming

Virtual Threads vs Platform Threads: Modern Approach to Concurrency in Java

In software development, concurrency and parallelism have always been critical factors for performance. Executing tasks simultaneously, especially in high-load systems, directly impacts an application’s overall speed. Virtual Threads, introduced with Java 21, have created a revolutionary change in this area, particularly for I/O operations.

We will analyze differences between traditional (platform) threads and virtual threads, determine which to use in which situation, and explore the technical reasons behind these choices.

Core Question: CPU-bound or I/O-bound?

To choose an effective concurrency model, you must first identify the nature of your task:

CPU-bound (Computation-Heavy): These tasks require processor (CPU) to be actively performing calculations. There is almost no waiting.

  • Example: Complex mathematical calculations, logic loops, cryptography, JSON parsing, video/image rendering, data compression.

I/O-bound (Input/Output-Heavy): These tasks spend most of their time waiting for a response from an external system (API, database, file system). During this time, thread is blocked and does not use CPU resources.

  • Example: HTTP API calls, database (DB) queries, file read/write operations, sending/receiving messages with Kafka, socket operations.

Traditional Approach: Platform Threads and CPU-bound Work

Thread class we traditionally know is now called Platform Thread. Each platform thread is directly mapped to Operating System (OS) thread.

Problem: OS threads are “expensive” resources.

  • Memory: Each one typically reserves 1MB-2MB of memory from the OS for its stack.
  • Limit: The operating system cannot efficiently manage thousands of threads simultaneously.

Solution for CPU-bound Tasks

If your work is CPU-bound, you don’t need waiting; you need true parallelism. If you have 8-core CPU, you can only perform 8 tasks in parallel anyway. Creating 100 threads will actually decrease performance due to overhead of context switching between threads.

Best Practice: Use FixedThreadPool with a size equal to the number of CPU cores.

int cores = Runtime.getRuntime().availableProcessors();
// We create a thread pool equal to number of CPU cores (or sometimes cores + 1)
ExecutorService cpuPool = Executors.newFixedThreadPool(cores);
cpuPool.submit(() -> {
    // Heavy computation work.For example, parsing a large JSON file
});
cpuPool.shutdown();

New Approach: Virtual Threads and I/O-bound Work

Problem: What if our work is I/O-bound? A thread waiting for response from API gets blocked. During this time, that expensive OS thread, which occupies 2MB of memory, sits idle, doing no work. It’s impossible to create thousands of platform threads to handle thousands of concurrent requests.

Solution: Virtual Threads

Virtual threads are lightweight threads that are not directly tied to OS threads.

How Do Virtual Threads Work?

  • Carrier Thread: JVM uses a small pool of platform threads (OS threads) to execute virtual threads. This is called the “carrier thread” pool (by default, this is a ForkJoinPool with a size equal to the number of CPU cores).
  • Mount / Unmount:
  • When virtual thread starts, JVM “mounts” it onto one of carrier platform threads, and code executes.
  • When virtual thread needs to block due to an I/O operation (Thread.sleep(), API call, DB query), JVM "unmounts" it from carrier thread.
  • Carrier platform thread is immediately freed up and continues to execute work for another virtual thread.
  • When theI/O operation completes , virtual thread becomes ready to run again and waits to be “mounted” onto an available carrier thread.

Key Advantage: Memory

Main reason virtual threads are “cheap” is their memory usage:

  • Platform Thread: Its stack is managed by OS (1–2 MB).
  • Virtual Thread: Its stack is stored as a Java object in JVM Heap. Its initial size is only a few hundred bytes and grows dynamically as needed.

This means it is possible to create millions of virtual threads with just 1–2GB of RAM.

Best Practice: I/O-bound

For I/O-heavy work, recommended approach is to use ExecutoerService that dedicates a new virtual thread to each task.

// Executor that creates NEW virtual thread for every submitted task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // We create 10,000 different I/O tasks
    for (int i = 0; i < 10000; i++) {
        executor.submit(() -> {
            // Blocking I/O operation
            callApiRequest(); 
            Thread.sleep(Duration.ofSeconds(1)); // This is also a blocking operation
            return true;
        });
    }
} 
// Try-with-resources block automatically calls shutdown() on the executor

Important Details About Virtual Threads

a) Is There a Limit to Virtual Threads?

Example of 1,000 tasks in notes is not a limit; it’s just the number of tasks submitted. The newVirtualThreadPerTaskExecutor creates a new virtual thread for every submit() call.

  • Limit on Virtual Thread Count: The practical limit is your system’s RAM (Heap memory). You can create millions of virtual threads.
  • Limit on Parallelism: number of virtual threads executing (doing computation) at the same time is limited by number of carrier platform threads (which defaults to number of CPU cores). However, in I/O-bound workloads, threads are mostly in a waiting state (unmounted), so limitation is not a bottleneck.

b) Virtual Thread Lifecycle

  • Platform Thread: Typically kept in a ThreadPool and reused repeatedly.
  • Virtual Thread: Designed for single use. Because creating and destroying virtual threads is so “cheap,” they are not pooled. Virtual thread that finishes its work logically “dies,” and its corresponding objects on the Heap are cleaned up by next Garbage Collector (GC) cycle.

c) ExecutorService and Spring

If you manage ExecutoreService manually, you must call shutdown() when you are finished so that resources are released (especially for platform thread pools).

If you are using Spring, it is sufficient to declare it as @Bean Spring will manage the bean's lifecycle and automatically call shutdown() method when the application closes:

@Bean
public ExecutorService virtualThreadExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}

Complex Case: Hybrid Tasks (I/O + CPU)

Sometimes a task has both an I/O part and a CPU part. For example, fetching data from an API (I/O) and then performing heavy computations on that data (CPU).

In this case, the best approach is to use separate ExecutorService for each type of work and chain them together with CompletableFuture

// 1. Virtual thread executor for I/O operations
ExecutorService ioExecutor = Executors.newVirtualThreadPerTaskExecutor();

// 2. Fixed thread pool for CPU computations
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuExecutor = Executors.newFixedThreadPool(cores);

// 3. Chained execution
CompletableFuture.supplyAsync(() -> {
    // === I/O Part ===
    return callExternalApi(); // Blocking I/O, runs on a virtual thread
}, ioExecutor)
.thenApplyAsync(data -> {
    // === CPU Part ===
    return heavyComputation(data); // Heavy computation, runs on a platform thread
}, cpuExecutor)
.thenAccept(result -> {
    System.out.println("Result: " + result);
});

Golden Rule:

  • If your code spends most of its time computing, use Platform Threads (and newFixedThreadPool).
  • If your code spends most of its time waiting (for API, DB, file), use Virtual Threads (and newVirtualThreadPerTaskExecutor ).

ForkJoinPool is designed for CPU-bound Tasks parallelStream() uses the common ForkJoinPool, which is optimized for short, CPU-bound tasks — not for I/O-bound workloads that block threads. By default, parallelStream() shares the same global thread pool (ForkJoinPool.commonPool()). If blocking I/O operations (like JDBC calls) take too long, the limited thread pool (default ≈ number of CPU cores) can be fully occupied — no free threads remain → deadlock-like behavior.


메타데이터
post_id
2eadbbdb5219
slug
virtual-threads-vs-platform-threads-modern-approach-to-concurrency-in-java-2eadbbdb5219
url
https://medium.com/@fuad-ahmadov/virtual-threads-vs-platform-threads-modern-approach-to-concurrency-in-java-2eadbbdb5219
canonical_url
https://medium.com/@fuad-ahmadov/virtual-threads-vs-platform-threads-modern-approach-to-concurrency-in-java-2eadbbdb5219
author_url
https://medium.com/@fuad-ahmadov
status
ok
fetched_at
2026-08-26 10:52:19