DEV Community

Cover image for Java Virtual Threads Aren't Free: 6 Hidden Costs to Check Before Production
Neetu
Neetu

Posted on

Java Virtual Threads Aren't Free: 6 Hidden Costs to Check Before Production

Java virtual threads make it cheap to run a huge number of concurrent tasks, but they don't make your system's limits go away. Memory, database connections, CPU cores, locks and observability all still cost something. Virtual threads were previewed in Java 19 (JEP 425) and became a standard feature in Java 21 (JEP 444). Here are six costs worth checking before you switch a production service over.

What do Java virtual threads actually change?

A virtual thread is a lightweight thread managed by the JVM rather than the operating system. When it blocks on I/O, the JVM unmounts it from its carrier (an ordinary platform thread), and that carrier picks up other work. That is why simple thread-per-request code can scale again without callbacks or reactive chains.

What doesn't change is everything the thread talks to.

1. Memory still adds up

Virtual threads are cheap, not weightless. Each one keeps its stack on the Java heap, and that stack grows with the depth of the call chain. Whatever a task holds (request objects, buffers, parsed payloads) stays reachable until the task finishes.

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 500_000; i++) {
        executor.submit(() -> processRequest());
    }
} // close() waits for every submitted task to finish
Enter fullscreen mode Exit fullscreen mode

Starting 500,000 tasks like this takes a few lines. If each one holds even a modest payload while it waits, heap usage climbs quickly and the garbage collector has to work harder. A fixed thread pool used to act as an accidental throttle. With virtual threads that throttle is gone unless you add one yourself.

What to do:

  • Bound the incoming work with a queue, a rate limiter or a semaphore around the expensive step, instead of relying on thread count.
  • Keep per-task objects small and drop large buffers as soon as you're done with them.
  • Watch heap and GC behaviour under realistic load, not just a quick local test.

2. Your database pool becomes the real limit

Say 10,000 requests arrive at once and each one needs a query. Virtual threads will happily run all 10,000. Your connection pool might have 50 connections.

The other 9,950 requests wait. Waiting is cheap for a virtual thread, so the app doesn't fall over, but latency grows. If the wait is longer than your pool's timeout (connectionTimeout in HikariCP, for example), requests start failing with connection errors.

Making the pool bigger isn't the fix, because the database has its own ceiling. Size the pool for what the database can actually handle, set sensible timeouts, and fail fast when you're over capacity.

The same problem applies to anything rate-limited that has no pool of its own, such as a third-party API. A Semaphore is a simple way to cap concurrent calls:

private final Semaphore paymentApiPermits = new Semaphore(20);

PaymentResult charge(Order order) throws InterruptedException {
    paymentApiPermits.acquire();
    try {
        return paymentClient.charge(order);
    } finally {
        paymentApiPermits.release();
    }
}
Enter fullscreen mode Exit fullscreen mode

You can have thousands of virtual threads, and still no more than 20 of them talking to the payment provider at any moment.

3. ThreadLocal caching stops paying off

ThreadLocal works fine on virtual threads. The trouble is a common pattern built on top of it: caching an expensive object per thread so it gets reused.

private static final ThreadLocal<SimpleDateFormat> FORMAT =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
Enter fullscreen mode Exit fullscreen mode

With a pool of 200 platform threads, that's 200 formatters reused for the life of the application. With virtual threads you usually get a new thread per task, so you build a new formatter for every request and the "cache" saves nothing. Put something larger in that ThreadLocal, run a few hundred thousand threads, and it shows up in memory.

What to do:

  • Search your code (and check your libraries) for ThreadLocal used as an object cache. Swap in a thread-safe alternative, such as DateTimeFormatter instead of SimpleDateFormat.
  • Don't pool virtual threads just to keep ThreadLocal values alive. That defeats the purpose.
  • For passing request context down a call chain, look at Scoped Values, which became final in Java 25 (JEP 506) after previews in Java 21 to 24.

4. Pinning can stall carrier threads

A virtual thread is pinned when it can't be unmounted from its carrier while it blocks. During that time the carrier can't run anything else. The default scheduler has roughly one carrier per CPU core, so a handful of pinned threads can noticeably cut throughput.

On Java 21 to 23, the classic cause is blocking inside synchronized:

public synchronized void refreshCache() {
    loadFromRemoteService(); // blocking I/O while holding the monitor
}
Enter fullscreen mode Exit fullscreen mode

Java 24 (JEP 491) fixed most of this: virtual threads can now block inside synchronized code and still unmount. Pinning still happens when a thread blocks inside a native method or a foreign function call, plus a few rarer cases such as class initialisers.

What to do:

  • Check which JDK you actually run in production. The advice differs before and after Java 24.
  • On Java 21 to 23, move blocking calls out of synchronized sections or switch those sections to ReentrantLock.
  • Record the jdk.VirtualThreadPinned event with Java Flight Recorder to find real pinning instead of guessing.

synchronized isn't the villain here. Long blocking while pinned is.

5. CPU-bound work won't get faster

Virtual threads help when tasks spend most of their time waiting: database queries, HTTP calls, file and network I/O. They don't add CPU.

The default virtual thread scheduler is a ForkJoinPool sized to the number of available processors. On an 8-core machine, 100,000 virtual threads resizing images still share 8 cores, with extra scheduling overhead on top.

Workload Better fit
Database queries, HTTP calls, external APIs Virtual threads
Heavy computation (image processing, encryption, number crunching) Fixed platform thread pool sized to your cores
Divide-and-conquer data processing ForkJoinPool or parallel streams
Mixed workloads Virtual threads for request handling, with CPU-heavy steps handed to a bounded pool

6. Debugging at scale needs different tools

A thread dump with 200 threads is readable. One with 200,000 is not. The questions also change:

  • Which tasks are waiting for a database connection?
  • Are carrier threads being pinned?
  • Which downstream service is slowing requests down?

Tools that help:

  • jcmd <pid> Thread.dump_to_file -format=json <file>: a thread dump format added in JDK 21 that includes virtual threads.
  • Java Flight Recorder and JDK Mission Control for pinning and latency events.
  • An APM tool or OpenTelemetry tracing, so you follow a request rather than a thread.
  • Structured logs with a request ID on every line.

Are Java virtual threads worth using?

For I/O-heavy services, yes. You get to write plain blocking code that scales well, and that code is far easier to read and debug than callback chains.

They're a poor fit for CPU-bound work, and they tend to move bottlenecks rather than remove them. Before you switch, check four things: your JDK version, your connection pool and downstream limits, your ThreadLocal usage, and your monitoring. Then load test with realistic traffic.

Virtual threads make concurrency easier to write. Good system design is still what makes it scale.


Written by the team at Solitaire Infosystems, a software development and IT training company in Mohali, India.

Have you moved a service to virtual threads yet? Which of these costs showed up first for you: the database pool, ThreadLocal, or pinning? Tell us in the comments.

Top comments (0)