Java Virtual Threads: Java 21 Vs Java 25
Part 1: Understanding Virtual Threads
Java Virtual Threads represent one of the most significant innovations in concurrent programming since the introduction of the Java Memory Model. If you’re building high-performance, scalable applications in Java, you need to understand this technology.
Virtual Threads are lightweight threads managed by the JVM that make it easier to write, maintain, and observe high-throughput concurrent applications.
1. What are Virtual Threads?
The Problem with Platform Threads
Traditional Java threads (called platform threads) are OS-level threads with significant drawbacks:
- Heavy memory footprint (~1–2 MB per thread)
- Expensive context switching
- Limited scalability (typically thousands of threads before problems)
- OS scheduler has limited visibility into your application
Here’s an example of traditional thread limitations:
// Traditional approach — Creating many platform threads
ExecutorService executor = Executors.newFixedThreadPool(1000);
for (int i = 0; i < 10000; i++) {
executor.submit(() -> {
try {
Thread.sleep(1000);
System.out.println("Task " + i + " completed");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);The problem with this approach: significant memory pressure and context switching overhead.
Virtual Threads: The Solution
Virtual threads are lightweight abstractions over OS threads that solve these problems.
Architecture Overview:
- Virtual Threads: 100,000+ (User-level)
- JVM Scheduler: Manages Virtual Threads
- Platform Threads: A few hundred (OS-level)
- OS Scheduler: Manages Platform Threads
Key Characteristics Comparison:
2. How to Initialize and Use Virtual Threads
Method 1: Using Thread.ofVirtual()
Thread vThread = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> {
System.out.println("Running in a virtual thread!");
System.out.println("Thread: " + Thread.currentThread().getName());
});
vThread.join();### Method 2: ExecutorService with Virtual Threads
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 10000; i++) {
int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " running on " + Thread.currentThread().getName());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
System.out.println("All tasks completed!");Method 3: Thread Factory
ThreadFactory virtualThreadFactory = Thread.ofVirtual()
.name("worker-", 0)
.factory();
ExecutorService executor = Executors.newThreadPerTaskExecutor(virtualThreadFactory);
executor.submit(() -> System.out.println("Executed by virtual thread!"));
executor.shutdown();### Best Places to Use Virtual Threads
IDEAL FOR:
- Server applications (REST APIs, microservices)
- High I/O workloads (database queries, API calls)
- Applications with many concurrent connections
- Legacy code with blocking I/O
NOT IDEAL FOR:
- CPU-intensive tasks (computation-heavy work)
- Low-latency, real-time systems
- Tasks that hold locks for long periods
3. Scalability & Performance Benefits
### Performance Comparison: Virtual vs Platform Threads
Scenario: 10,000 HTTP Requests with 100ms Latency
public class PerformanceComparison {
public static void platformThreadApproach() throws Exception {
long startTime = System.currentTimeMillis();
ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10000; i++) {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
long duration = System.currentTimeMillis() - startTime;
System.out.println("Platform Threads Time: " + duration + "ms");
}
public static void virtualThreadApproach() throws Exception {
long startTime = System.currentTimeMillis();
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 10000; i++) {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
long duration = System.currentTimeMillis() - startTime;
System.out.println("Virtual Threads Time: " + duration + "ms");
}
public static void main(String[] args) throws Exception {
System.out.println("=== Platform Threads ===");
platformThreadApproach();
System.out.println("\n=== Virtual Threads ===");
virtualThreadApproach();
}
}Expected Results:
- Platform Threads Time: 5000ms
Get Aryash Jain’s stories in your inbox
Join Medium for free to get updates from this writer.
- Virtual Threads Time: 1200ms
### Real-world REST API Example
@RestController
@RequestMapping("/api")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.fetchUserFromDatabase(id);
List<Order> orders = userService.fetchUserOrders(id);
user.setOrders(orders);
return ResponseEntity.ok(user);
}
@GetMapping("/users/batch/{count}")
public ResponseEntity<List<User>> getBatchUsers(@PathVariable int count) {
List<User> users = IntStream.range(1, count + 1)
.parallel()
.mapToObj(userService::fetchUserFromDatabase)
.collect(Collectors.toList());
return ResponseEntity.ok(users);
}
}— -
4. Issues with Virtual Threads in Java 21
Issue #1: Pinning to Platform Threads
Virtual threads can get “pinned” to their underlying platform thread, preventing efficient scheduling.
public class PinningExample {
private static final Object LOCK = new Object();
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
synchronized (LOCK) { // Synchronization causes pinning!
try {
Thread.sleep(1000);
System.out.println("Task completed");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
}
}
Why Its Bad ?
- Virtual thread remains bound to platform thread
- Defeats the purpose of having thousands of virtual threads
- Other virtual threads can’t use that platform thread
- Performance degrades significantly
Issue #2: No Support for ThreadLocal Inheritance
ThreadLocal<String> context = new ThreadLocal<>();
context.set("user-123");
Thread vThread = Thread.ofVirtual().start(() -> {
String value = context.get(); // Returns null in Java 21!
System.out.println("Context: " + value);
});Issue #3: Limited Observability
Java 21 had limited built-in observability:
- No easy way to monitor how many virtual threads are running
- Limited thread utilization metrics
- Difficulty getting detailed stack traces for debugging
Issue #4: WeakHashMap Performance
Virtual threads in Java 21 had issues with WeakHashMap due to frequent garbage collection pauses caused by the large number of thread-related objects.
5. How These Issues Are Resolved in Java 25
Solution #1: Improved Pinning Behavior
Java 25 significantly reduces pinning issues. Use ReentrantLock instead of synchronized:
public class ImprovedPinning {
private static final ReentrantLock LOCK = new ReentrantLock();
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
LOCK.lock();
try {
Thread.sleep(1000);
System.out.println("Task completed");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
LOCK.unlock();
}
});
}
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
}
}Key improvements in Java 25:
- ReentrantLock no longer causes pinning
- ReentrantReadWriteLock fully supports virtual threads
- Reduced unnecessary pinning in core JDK classes
### Solution #2: Enhanced ThreadLocal Support
ThreadLocal<String> context = new ThreadLocal<>();
context.set("user-123");
Thread vThread = Thread.ofVirtual().start(() -> {
String value = context.get(); // Now returns "user-123"!
System.out.println("Context: " + value);
});What changed:
- Virtual threads now properly inherit ThreadLocal values
- Better support for context propagation
- Improved integration with existing code patterns
Solution #3: Better Observability and Monitoring
import java.lang.management.ManagementFactory;
import com.sun.management.ThreadMXBean;
public class VirtualThreadMonitoring {
public static void main(String[] args) {
ThreadMXBean threadBean = (ThreadMXBean) ManagementFactory.getThreadMXBean();
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
System.out.println("Virtual threads: " + threadBean.getVirtualThreadCount());
System.out.println("Platform threads: " + threadBean.getThreadCount());
System.out.println("CPU time: " + threadBean.getCurrentThreadCpuTime());
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
}
}Solution #4: Improved GC Performance
- Better memory management for ThreadLocal and related structures
- Reduced GC pauses with large numbers of virtual threads
- Optimized object pooling and reuse
8. Conclusion
### Key Takeaways
Benchmarking has significantly improved with virtual threads , we are using it in our high QPS services , and they are really helping us in servicing highly concurrent requests without CPU overheads
If this article helped you in diving deep , please give a Clap and also comment what next should be served next , it can be related to Java , Virtual Threads or Any new programming concept.









