A virtual thread is a Java thread scheduled by the runtime over carrier threads so many blocking tasks can be represented without one dedicated operating-system thread per task.
Java 21 virtual threads: blocking tasks still need limits
Java 21+. Use a JDK that supports this release.
Concurrency is not admission control
The per-task executor can create a virtual thread for every submitted task. That does not give the downstream database unlimited connections or the service unlimited memory. Bound task admission and downstream access separately.
The sample uses three tasks and a semaphore with two permits to describe a downstream budget. A semaphore acquired inside every submitted task bounds active work but does not bound the number of waiting tasks. A large producer still needs an admission cap before submitting.
Each task returns a receipt label. Futures are read in submission order so output is deterministic even though execution order is not. This sample contains no remote calls and makes no throughput claim.
Blocking and CPU work have different constraints
Virtual threads can help when tasks spend time waiting. They do not add CPU cores. A CPU-bound calculation still consumes compute time, and submitting thousands of such tasks can increase contention.
On the Java 21 baseline, blocking while holding certain monitors or during native calls can pin a carrier. Later runtime releases can change that behavior, so do not treat a Java 21 pinning note as a timeless rule.
Per-thread state multiplied by many tasks can become a memory issue. Avoid storing large reusable pools inside every thread-local value. The executor in this program is owned by the method and closed before it returns.
Working program
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
public class ReceiptWorkers {
public static void main(String[] args) throws Exception {
Semaphore downstream = new Semaphore(2);
List<Future<String>> receipts = new ArrayList<>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int index = 1; index <= 3; index++) {
final int receiptId = index;
receipts.add(executor.submit(() -> {
downstream.acquire();
try { return "receipt-" + receiptId; }
finally { downstream.release(); }
}));
}
for (Future<String> receipt : receipts) System.out.println(receipt.get());
}
}
}Output
receipt-1
receipt-2
receipt-3Cost and design choices
Storing n futures requires O(n) retained references plus task state. The semaphore bounds simultaneous access to two permits; it does not reduce retained waiting work to O(1).
Failure and cancellation need a policy. Future.get may report a task failure, and closing an executor waits for owned tasks. A long-lived service needs deadlines and interruption-aware operations rather than relying on a demo’s short tasks.
Common Mistakes
- Do not describe virtual threads as faster CPU execution.
- Do not pool virtual threads to imitate a platform-thread pool.
- Do not mistake a semaphore inside tasks for a bounded submission queue.
Continue the contract
The synchronized-block carrier-pinning warning in this lesson belongs to the Java 21 baseline. JDK 24 changed monitor-related pinning. The Java 25 companion lesson checks the newer boundary without claiming that lock contention disappears.
