DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Newsletter
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Going All In On the JVM 25 Virtual Threads
  • I Built a Java Version Manager by Fixing Other Tools' Open Bugs
  • A Spring Boot App With Half the Startup Time
  • Alternative Structured Concurrency

Trending

  • Kubernetes Operations Playbook: The Essentials for Keeping Scale, Complexity, and Drift Under Control
  • Beyond @Transactional: Solving the Dual-Write Problem in Distributed Microservices
  • Event-Driven AI Systems With Kafka and Autonomous Agents
  • From Microservices to Agent Services: The Next Architectural Shift
  1. DZone
  2. Coding
  3. Java
  4. Java Virtual Threads Under Load: Three Production Limits

Java Virtual Threads Under Load: Three Production Limits

Learn how admission limits, lock scope, and task cancellation affect Java virtual threads under load, with runnable examples for each production concern.

By 
A N M Bazlur Rahman user avatar
A N M Bazlur Rahman
DZone Core CORE ·
Oct. 20, 26 · Tutorial
Likes (0)
Comment
Save
Tweet
Share
91 Views

Join the DZone community and get the full member experience.

Join For Free

Virtual threads let us keep ordinary blocking code while many more requests wait concurrently. Under load, however, that brings three critical questions into focus:

  • How much work do we admit? Cheap threads can still build an expensive queue.
  • What belongs inside a lock? A sleeping lock holder still makes everyone else wait.
  • Who stops abandoned work? A caller's timeout does not cancel its task.

The examples below isolate these questions with separate before-and-after programs. Each program is complete: save its code block under the filename shown and run the accompanying command, without arguments. All you need is JDK 25. They use its compact source-file syntax, so no class declaration, build tool, external library, or preview flag is required.

First: More Overlapping Waits

Imagine 1,000 tasks that each wait for 100 ms, with no dependency limiting their concurrency:

Execution model Simultaneous waits Ideal elapsed time
Pool of 50 platform threads 50 2,000 ms
One virtual thread per task, all waits overlapping 1,000 100 ms
Virtual threads limited to 50 concurrent waits 50 2,000 ms


These are arithmetic estimates, ignoring startup and scheduling overhead. With 50 workers, the waits take twenty batches. Allow all 1,000 waits to overlap, and they take one batch. Cap virtual-thread concurrency at 50 and the twenty-batch result returns.

Virtual threads let more independent waits overlap. They do not execute CPU instructions faster.

A virtual thread runs on a platform thread called a carrier. Many blocking JDK operations, including socket I/O and sleep, let it unmount while waiting. Its stack and referenced objects remain on the heap, and the carrier can run something else.

The M:N scheduling model(The M:N scheduling model: Many virtual threads map to a smaller pool of OS carrier threads)


The scheduler's default target parallelism is the number of processors available to the JVM. Some blocking operations still occupy a carrier; native and foreign-function calls can pin virtual threads even on JDK 25. Waiting uses fewer OS threads, but it still costs memory and resources.

1. Bound Admission: Eight Slots Cannot Serve Everybody at Once

Imagine a dependency with eight connections. Each call takes 80 ms. Its ideal capacity is 8 / 0.08 = 100 calls per second. Virtual threads can wait cheaply for a connection, but they cannot create another one.

Send a burst of 100 requests. First, accept everything. Then, repeat with an admission limit of 32: up to eight using the dependency and another 24 waiting for it.

First, accept every request. Save as UnboundedAdmission.java:

Java
 
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.concurrent.*;

void main() throws Exception {
    var connections = new Semaphore(8);
    var results = new ArrayList<Future<?>>();
    Instant now = Instant.now();

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 100; i++) {
            results.add(executor.submit(() -> {
                connections.acquire();
                try {
                    Thread.sleep(Duration.ofMillis(80));     // the dependency call
                } finally {
                    connections.release();
                }
                return null;
            }));
        }
    }   // wait for all work to finish

    for (var result : results) result.get();
    IO.println("completed=%d rejected=0 elapsed_ms=%d".formatted(
            results.size(), Duration.between(now, Instant.now()).toMillis()));
}


Run it:

Java
 
java UnboundedAdmission.java


Every request waits until a connection becomes available. Now add an admission semaphore so excess requests are rejected immediately. Save as BoundedAdmission.java:

Java
 
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.concurrent.*;

void main() throws Exception {
    var connections = new Semaphore(8);
    var admission = new Semaphore(32);
    var results = new ArrayList<Future<Boolean>>();
    Instant now = Instant.now();

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 100; i++) {
            results.add(executor.submit(() -> {
                if (!admission.tryAcquire()) return false;
                try {
                    connections.acquire();
                    try {
                        Thread.sleep(Duration.ofMillis(80));     // the dependency call
                    } finally {
                        connections.release();
                    }
                    return true;
                } finally {
                    admission.release();
                }
            }));
        }
    }   // wait for accepted work to finish

    int completed = 0;
    for (var result : results) if (result.get()) completed++;
    IO.println("completed=%d rejected=%d elapsed_ms=%d".formatted(
            completed, 100 - completed, Duration.between(now, Instant.now()).toMillis()));
}


Run it:

Java
 
java BoundedAdmission.java


One run on Oracle GraalVM 25.0.2 produced:

Admission Completed Rejected Elapsed time
Unlimited 100 0 1,062 ms
Limit of 32 32 68 330 ms


There are two different limits here. connections.acquire() waits for one of eight service slots. admission.tryAcquire() admits the request immediately or returns false, recording a rejection. Its permit stays held while the request waits for a connection and makes the call.

Without admission control, all 100 requests complete in thirteen batches, taking roughly a second. With the gate, a quick burst typically admits 32 and rejects 68; four batches take roughly 320 ms. The bounded run finishes sooner because it rejects excess work, preventing memory exhaustion. A 10 KB per-thread cache across 100,000 virtual threads is still 1 GB of heap space.

The takeaway: Reject before adding work to the queue you need to bound.

2. Shorten the Lock: Protect the Update

Now use forty tasks. Each waits 80 ms and increments a shared completion counter. There is no connection limit here: the waits are independent, so the lock is the only bottleneck.

First, put both the wait and the counter update inside the lock. Save as WideLock.java:

Java
 
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.concurrent.*;

final Object lock = new Object();
int completed;

void main() throws Exception {
    Instant now = Instant.now();
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var tasks = new ArrayList<Future<?>>();
        for (int i = 0; i < 40; i++) {
            tasks.add(executor.submit(() -> {
                synchronized (lock) {
                    Thread.sleep(Duration.ofMillis(80));
                    completed++;
                }
                return null;
            }));
        }
        for (var task : tasks) task.get();
    }
    if (completed != 40) throw new AssertionError("Lost an update");
    IO.println("completed=%d elapsed_ms=%d".formatted(
            completed, Duration.between(now, Instant.now()).toMillis()));
}


Run it:

Java
 
java WideLock.java


Now move the wait outside the lock, keeping the counter update protected. Save as NarrowLock.java:

Java
 
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.concurrent.*;

final Object lock = new Object();
int completed;

void main() throws Exception {
    Instant now = Instant.now();
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var tasks = new ArrayList<Future<?>>();
        for (int i = 0; i < 40; i++) {
            tasks.add(executor.submit(() -> {
                Thread.sleep(Duration.ofMillis(80));
                synchronized (lock) {
                    completed++;
                }
                return null;
            }));
        }
        for (var task : tasks) task.get();
    }
    if (completed != 40) throw new AssertionError("Lost an update");
    IO.println("completed=%d elapsed_ms=%d".formatted(
            completed, Duration.between(now, Instant.now()).toMillis()));
}


Run it:

Java
 
java NarrowLock.java


On the same JDK, the two runs produced:

Lock scope Completed Elapsed time
Wait and update inside 40 3,255 ms
Only the update inside 40 87 ms


With the wait inside the lock, forty waits of 80 ms take at least 3,200 ms. Making the other thirty-nine threads cheap to park does not let them pass the lock holder. With the wait outside, those waits overlap.

On JDK 25, synchronized is perfectly suitable for this demonstration, as JDK 24 removed monitor-related virtual-thread pinning. The issue here isn't platform pinning; it's basic thread serialization. The rule is to identify the state transition that needs protection, then keep independent slow work outside of it.

3. Cancel the Work: A Timeout Only Ends the Wait

Use one task that takes 1,800 ms. Its caller is willing to wait only 200 ms. First, let the caller time out without canceling the task. Save as TimeoutOnly.java:

Java
 
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.*;

void main() throws Exception {
    Instant now = Instant.now();
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var task = executor.submit(() -> {
            try {
                Thread.sleep(Duration.ofMillis(1_800));
                IO.println("Work completed");
                return "done";
            } finally {
                IO.println("Cleanup ran");
            }
        });
        try {
            task.get(200, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            IO.println("Caller timed out");
        }
    }   // close() still waits for the task to exit
    IO.println("scope_exited_ms=%d".formatted(
            Duration.between(now, Instant.now()).toMillis()));
}


Run it:

Java
 
java TimeoutOnly.java


Now add task.cancel(true) to the timeout handler. Save as TimeoutWithCancellation.java:

Java
 
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.*;

void main() throws Exception {
    Instant now = Instant.now();
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var task = executor.submit(() -> {
            try {
                Thread.sleep(Duration.ofMillis(1_800));
                IO.println("Work completed");
                return "done";
            } finally {
                IO.println("Cleanup ran");
            }
        });
        try {
            task.get(200, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            IO.println("Caller timed out");
            task.cancel(true); // <--- Explicit cancellation
        }
    }   // close() waits for the task to exit, even after cancellation
    IO.println("scope_exited_ms=%d".formatted(
            Duration.between(now, Instant.now()).toMillis()));
}


Run it:

Java
 
java TimeoutWithCancellation.java


The observed outcomes were:

Example Printed Work completed? Printed Cleanup ran? Scope exited after
Timeout alone Yes Yes 1,805 ms
Timeout plus cancellation No Yes 207 ms


Without cancellation, get() throws after 200 ms, but the task keeps sleeping. It eventually completes its work 1.6 seconds later. If this abandoned work was holding a database connection or an admission permit, that resource would be leaked for the duration of the sleep.

Cancellation is cooperative. Detect the timeout, request cancellation, and wait for the cleanup. All three parts matter.

A Teaser: The Structured Concurrency Solution

In the final example above, you'll notice the parent thread is forced to micromanage the task's lifecycle. It waits, catches the TimeoutException, explicitly calls cancel(true), and crosses its fingers that the cleanup runs.

But what if we could invert this control?

With JDK's preview StructuredTaskScope API, we can tie the lifetime of the threads to the lexical scope itself, making cancellation an automatic architectural guarantee rather than manual boilerplate:

I will be exploring how StructuredTaskScope eliminates resource leaks and abandoned work entirely in my next article. Stay tuned!

Summary

Take these three questions back to your services when migrating to virtual threads:

  1. Where does waiting accumulate? Bound admission before that queue grows.
  2. What serializes the work? Keep independent waits outside state-protecting locks.
  3. Who owns unfinished work? Connect deadlines to cooperative cancellation and cleanup.

The programs here use sleeps so each effect is visible, but the exact same questions apply when your wait is for a database, a socket, or a partner API.

Java Development Kit

Published at DZone with permission of A N M Bazlur Rahman. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • Going All In On the JVM 25 Virtual Threads
  • I Built a Java Version Manager by Fixing Other Tools' Open Bugs
  • A Spring Boot App With Half the Startup Time
  • Alternative Structured Concurrency

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook