Executors and virtual threads
Creating threads by hand is not how concurrent work gets run. You submit tasks to an executor, which owns the threads and hands your work to them.
And in Java 21 the arithmetic behind all of this changed, because virtual threads made "one thread per task" affordable again after twenty years of pretending it was not. That is the most significant thing in Java 21 after pattern matching, and it is why this course targets 21.
Executors
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 1; i <= 8; i++) {
int n = i;
futures.add(pool.submit(() -> { sleep(100); return n * n; }));
}
int total = 0;
for (Future<Integer> f : futures) {
total += f.get();
}
System.out.println(total);
}
sum of squares 1..8 = 204
submit returns a Future, a handle to a result that does not exist yet.
get() blocks until it does.
ExecutorService is AutoCloseable since Java 19, so try-with-resources
closes it — which waits for submitted tasks to finish. Before that you had to
call shutdown() and then awaitTermination(...), and forgetting meant the JVM
never exited, because pool threads are not daemons.
| Factory | Gives |
|---|---|
newFixedThreadPool(n) |
Exactly n threads, an unbounded queue |
newVirtualThreadPerTaskExecutor() |
A new virtual thread per task — the modern default |
newSingleThreadExecutor() |
One thread, tasks in order |
newScheduledThreadPool(n) |
Delayed and repeating tasks |
newCachedThreadPool() |
Grows without limit — avoid |
newCachedThreadPool creates a platform thread per task with no ceiling, so a
burst of load creates thousands and the machine falls over. It is in every old
tutorial. Do not use it.
Future, and the exception you will not see
Future<Integer> f = pool.submit(() -> { throw new IllegalStateException("task failed"); });
f.get();
ExecutionException, cause: java.lang.IllegalStateException: task failed
An exception inside a task does not propagate to the submitter. It is stored, and
rethrown wrapped in ExecutionException when you call get(). Always unwrap
with getCause() — the outer exception's own trace points at your get(), not
at the failure.
And the trap:
pool.submit(() -> { throw new IllegalStateException("nobody ever sees this"); });
submit() with no get(): nothing printed above this line
A task that fails and whose Future is never checked fails silently. No
stack trace, no log, nothing. This is one of the most common ways for work to
quietly stop happening in a production system.
Either call get(), or use execute(...) instead of submit(...) — execute
has no Future and sends uncaught exceptions to the thread's uncaught exception
handler, which at least prints them.
| Method | Does |
|---|---|
submit(callable) |
Returns a Future |
execute(runnable) |
No Future; uncaught exceptions are printed |
invokeAll(tasks) |
Runs all, returns when all are done |
invokeAny(tasks) |
Returns the first successful result |
get() / get(timeout, unit) |
Waits for one result |
close() |
Waits for submitted tasks, then shuts down |
shutdownNow() |
Interrupts running tasks |
List<Callable<Integer>> tasks = List.of(
() -> { sleep(30); return 1; },
() -> { sleep(30); return 2; },
() -> { sleep(30); return 3; });
for (Future<Integer> f : pool.invokeAll(tasks)) total += f.get();
total 6
Always prefer a timeout on get. An indefinite get() on a task that hangs
turns one stuck operation into a stuck application.
Virtual threads
A platform thread is an operating system thread with about a megabyte of stack. A virtual thread is managed by the JVM; when it blocks, the JVM parks it and uses the underlying carrier thread for something else. They cost a few hundred bytes.
2,000 platform threads : 136 ms
100,000 virtual threads: 620 ms
Fifty times as many, under five times the time. Two thousand platform threads is already near the practical limit; a hundred thousand virtual threads is unremarkable.
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
pool.submit(() -> { sleep(100); return "done"; });
}
}
1,000 x 100ms calls in 115 ms
A thousand tasks each waiting a tenth of a second, finished in roughly the time of one. Sequentially that is a hundred seconds; on a fixed pool of eight it is about twelve.
This is what virtual threads are for: work that waits. Database queries, HTTP calls, file reads. Not computation — a hundred thousand virtual threads doing arithmetic still share the same CPU cores and will be no faster than a pool sized to those cores.
Thread.ofVirtual().start(runnable);
Thread.ofVirtual().unstarted(runnable);
isVirtual: true
daemon : true
Note they are always daemon threads: the JVM will not wait for one at shutdown.
If you start a virtual thread from main and do not join it, it may never run
to completion.
What changes, and what does not
What changes. "One thread per request" becomes affordable. Blocking code becomes acceptable again — you no longer need reactive frameworks and callback chains purely to avoid holding a thread while waiting. For most applications that is a large simplification.
What does not change. Virtual threads are still threads. Every race condition from the last lesson happens exactly the same way, and with far more of them running at once, contention on a shared lock is worse rather than better.
Two further cautions:
- Do not pool virtual threads. They are cheap; create one per task. A "virtual thread pool" defeats the point.
synchronizedused to pin a virtual thread to its carrier, which could starve the pool under load. Java 24 removed that limitation, but on Java 21 preferReentrantLockoversynchronizedaround blocking work inside virtual threads.
Choosing
| Situation | Use |
|---|---|
| Many tasks that wait — I/O, HTTP, database | newVirtualThreadPerTaskExecutor() |
| CPU-bound work | newFixedThreadPool(availableProcessors()) |
| Tasks that must run in order | newSingleThreadExecutor() |
| Scheduled or repeating work | newScheduledThreadPool(n) |
| One-off background work in a small program | Thread.ofVirtual().start(...) |
| Anything, before you have measured | A plain loop |
That last row is not a joke. Concurrency adds bugs before it adds speed, and most slow code is waiting for one database query that wants an index. Measure first.
Check your work
What does submit return, and what does get() do? A Future, a handle to
a result that does not exist yet. get() blocks until it does.
What happens to an exception thrown inside a task? It is stored and rethrown
wrapped in ExecutionException from get(). If nobody calls get(), it
disappears entirely.
How do you avoid that silent failure? Call get() and unwrap with
getCause(), or use execute rather than submit so uncaught exceptions reach
the thread's handler.
Why is newCachedThreadPool dangerous? It creates a platform thread per task
with no ceiling, so a burst of load creates thousands.
Why is ExecutorService being AutoCloseable useful? try-with-resources
shuts it down and waits for tasks. Forgetting shutdown() previously meant the
JVM never exited.
What are virtual threads good for, and not good for? Work that waits — I/O, HTTP, database. Not CPU-bound work, which is still limited by cores.
Do virtual threads fix race conditions? No. Every race from the previous lesson behaves identically, with more concurrency to expose it.
Should you pool virtual threads? No. Create one per task; pooling defeats the purpose.
Practice 2, the three timings. A thousand tasks that each wait 100 ms: sequentially about 100 seconds, on a fixed pool of eight about 12.5 seconds, on a virtual-thread executor about 115 ms. The ratio is the argument. Repeat it with CPU-bound work — summing a large array — and the virtual-thread version gives no improvement at all over the fixed pool, which is the other half of the argument.
Practice 4, the vanished exception. submit with no get() prints nothing
whatsoever. Adding get() gives ExecutionException whose getCause() is your
IllegalStateException. Switching to execute prints the trace via the
uncaught exception handler without you asking. In a real service the answer is
usually to keep the Future and check it, because you also want to know which
task failed.
Practice
-
Use a fixed pool. Submit eight tasks returning a number, collect the futures, and sum them. Use try-with-resources, then remove it and see whether your program exits.
-
Time three approaches. A thousand tasks each sleeping 100 ms, run sequentially, on
newFixedThreadPool(8), and onnewVirtualThreadPerTaskExecutor(). Then repeat with CPU-bound work instead of sleeping and compare again. -
Count virtual threads. Start 100,000 of them. Then try 100,000 platform threads and see what happens — expect it to fail, and read the error.
-
Lose an exception. Submit a failing task and do not call
get(). Confirm nothing is printed. Then addget()and unwrap the cause. Then useexecuteinstead. -
Use a timeout. Submit a task that sleeps for ten seconds and call
get(1, TimeUnit.SECONDS). Read theTimeoutException, then cancel the task and confirm it stops. -
Harder — fetch in parallel. Write a method taking a list of file paths and returning a map of path to line count, doing the reads concurrently with a virtual-thread executor. Make it report which files failed rather than throwing on the first. Then run it over a thousand files and compare with the sequential version. Then make one file unreadable and confirm your error handling still names it.
That is module nine. You know that your code already runs in many threads, you
have broken a counter and a HashMap on purpose, you know why wrapping a
collection is not enough, and you can run concurrent work with an executor
without losing exceptions.
Next module: Maven and JUnit — the build and the tests, which is where a program becomes a project.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship