Java / Advanced / Lesson 15
Virtual threads and explicit version boundaries
Understand Java 21 virtual threads as a concurrency scalability tool while preserving admission limits, memory-model rules, and Java 17 migration alternatives.
On this page
Lesson goals
- Distinguish virtual threads from faster CPU execution.
- Compile the labeled examples with Java 21.
- Limit scarce downstream resources independently from thread count.
State what the baseline can and cannot compile
Virtual threads became a standard feature in Java 21 after previews in earlier releases. Thread.ofVirtual and Executors.newVirtualThreadPerTaskExecutor are not Java 17 APIs. This lesson labels its complete examples Java 21 and validates them separately; the rest of the course's baseline programs target --release 17. A newer runtime alone does not change what an older compiler accepts.
Do not enable preview options to make an unrelated API appear, and do not describe a Java 21 feature as available on Java 17. An application remaining on 17 can use a deliberately sized platform-thread executor with explicit admission control. Upgrade decisions should include compatibility, support policy, dependencies, and measurements rather than a shorter code sample or a claim about the newest release.
Explain mounting without promising CPU speed
A virtual thread is still a Thread with the same concurrency and memory-model obligations. The runtime schedules many virtual threads over carrier platform threads, allowing supported blocking operations to release the carrier while waiting. This can improve throughput for numerous mostly waiting tasks. It does not make a CPU-bound calculation run faster or create unlimited memory, file descriptors, or database connections.
The example starts one virtual worker, joins it, and reports its result and thread kind. It establishes semantics rather than a benchmark. The worker has no external effects, and joining ensures completion before output. Creating a large number merely to print a demonstration would add noise and resource cost without teaching the scheduling contract more clearly.
public class Main {
public static void main(String[] args) throws Exception {
var result=new java.util.concurrent.atomic.AtomicReference<String>();
Thread worker=Thread.ofVirtual().start(()->result.set("done"));
worker.join();
System.out.println(result.get());
System.out.println(worker.isVirtual());
}
}
done
true
Requires Java 21. The worker is virtual, completes before output, and sets the synthetic result to done.
Limit the scarce resource directly
Virtual-thread-per-task execution does not bound the number of submitted tasks. Limit scarce operations separately, for example with a semaphore around a downstream connection budget, and define how queued requests or deadlines are handled. Do not pool virtual threads simply to imitate a platform-thread pool; they represent tasks rather than a scarce reusable thread resource.
The second Java 21 example acquires one permit, returns a value, and releases the permit in finally. The executor is AutoCloseable in this version, so its try-with-resources lifecycle is valid. After the block, the available permit count is one. This tiny example checks ownership, not real service capacity, fairness, cancellation under load, or a throughput gain.
public class Main {
public static void main(String[] args) throws Exception {
var permits=new java.util.concurrent.Semaphore(1);
try(var tasks=java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
var future=tasks.submit(()->{
permits.acquire();
try { return 42; } finally { permits.release(); }
});
System.out.println(future.get());
}
System.out.println(permits.availablePermits());
}
}
42
1
The permit is released even if the guarded body fails. The successful path returns 42 and restores capacity to one.
Keep performance caveats tied to the release
Java 21 documentation describes pinning when a virtual thread blocks while executing certain synchronized or native operations. Frequent long-lived pinning can reduce scalability by keeping a carrier occupied. This is a release-specific performance concern, not evidence that synchronized is semantically wrong or that every synchronized block must be replaced. Inspect the actual blocking and contention pattern.
Later JDK releases change some implementation behavior, so do not apply a Java 21 pinning rule as timeless advice. Check documentation for the deployed release. ThreadLocal data also deserves review because per-thread storage multiplied across many tasks can become expensive. Keep request context small and cleanup explicit; a cheap thread does not make every object it references cheap.
Measure the service before and after adoption
Compare equivalent workloads with throughput, tail latency, CPU, heap, carrier behavior, and downstream queue limits. A throughput gain that overwhelms a database or raises timeout rates is not a successful migration. Test cancellation, error propagation, shutdown, and thread-local behavior as well as normal requests. Existing synchronization bugs remain bugs under virtual threads.
Observability tools have version-specific virtual-thread support; use the deployed JDK's documented dumps and recording events. This course does not attach to configured processes or claim a measured service speedup. The migration outcome should be a documented capacity and lifecycle contract backed by representative evidence, with a rollback path to the prior executor design if the workload does not benefit.
Common errors and fixes
cannot find symbol: ofVirtual
- Why it happens
- Java 21 source was compiled against a Java 17 API.
- How to fix it
- Use the labeled Java 21 compiler and target, or the baseline executor alternative.
- Prevent it next time
- Record feature version gates in build configuration.
Virtual threads did not speed up a CPU loop
- Why it happens
- Scalability for waiting tasks was confused with CPU acceleration.
- How to fix it
- Measure the bottleneck and choose appropriate CPU parallelism.
- Prevent it next time
- Benchmark representative work, not thread count.
Downstream capacity was exhausted
- Why it happens
- Cheap task creation bypassed admission limits.
- How to fix it
- Bound the scarce resource and define timeout or rejection behavior.
- Prevent it next time
- Test saturation and cancellation with the real budget model.
Check your understanding
1. A service has 20 database connections and thousands of virtual requests. What must remain bounded?
Show worked solution
Database access remains a 20-connection budget, along with queue size, deadlines, memory, and any other scarce resources. Virtual threads simplify waiting style but do not authorize unlimited concurrent database operations.
2. Can a Java 17 program compile newVirtualThreadPerTaskExecutor with --enable-preview?
Show worked solution
No. It is not a Java 17 API, and a preview flag does not backport it. Build and run the labeled example with Java 21, or use a supported Java 17 design with explicit lifecycle and admission limits.
Key takeaways
- Virtual threads are standard in Java 21, beyond the Java 17 baseline.
- They scale waiting tasks and do not accelerate CPU work.
- Admission, cleanup, and memory-model rules still apply.