ExecutorType.VIRTUAL Doesn't Exist
Contents
Part 14 in the Memory Benchmark series.
The Config Everyone Writes
micronaut:
executors:
io:
type: VIRTUAL
Every tutorial. Every “Micronaut + virtual threads” blog post. Every AI suggestion. This is the obvious config. One property, clean intent.
It crashes at startup:
No enum constant io.micronaut.scheduling.executor.ExecutorType.VIRTUAL
The Enum That Actually Exists
ExecutorType in Micronaut 5.0.0 has five values:
SCHEDULEDCACHEDFIXEDWORK_STEALINGTHREAD_PER_TASK
No VIRTUAL. Never was one.
The Config That Works
micronaut:
executors:
io:
type: THREAD_PER_TASK
virtual: true
Two properties. type selects the executor creation strategy – THREAD_PER_TASK maps to Executors.newThreadPerTaskExecutor(factory). virtual sets that factory to produce virtual threads instead of platform threads.
The separation is real: type and virtual are read by two independent code paths. type picks the executor shape (ExecutorFactory.executorService() switches on it); virtual only swaps the ThreadFactory (ExecutorFactory.getThreadFactory() checks it). They compose. THREAD_PER_TASK is a valid strategy even with platform threads (one platform thread per task – expensive, but valid), and virtual: true layers onto any shape – yes, even FIXED, where you get a fixed pool of virtual threads. Two independent decisions, and the framework never cross-checks them.
But nobody thinks about it from the framework’s perspective. People think: “I want virtual threads.” And type: VIRTUAL is the sentence that maps to that thought.
The Trap That Doesn’t Crash
type: VIRTUAL crashes loudly – you fix it in minutes. The dangerous version drops type entirely:
micronaut:
executors:
io:
virtual: true
No crash. Looks right. It isn’t. UserExecutorConfiguration defaults type to SCHEDULED (core pool size 2 × CPU). And because type and virtual are read by separate code paths, virtual: true with no type builds a ScheduledThreadPoolExecutor of 2 × CPU threads that happen to be virtual – pooled, reused, and bounded.
You’ve pinned your “unbounded virtual threads” to a fixed pool and defeated the point of Loom: virtual threads are meant to be created per task and discarded, not parked in a pool. Nothing logs a warning. Throughput silently caps at 2 × CPU.
Only type: THREAD_PER_TASK routes to a real thread-per-task executor (LoomSupport.newThreadPerTaskExecutor – one virtual thread per task, no pool, no bound). That’s why both properties are mandatory: the crash from type: VIRTUAL is the friendly failure mode. The silent one is virtual: true alone.
And it hides across config layers. A profile override (application-prod.yml) still carrying an old type: scheduled quietly wins the per-key merge over a fixed base – your tests boot the base and pass, production boots the override and caps. Grep every profile, not just application.yml.
When You Actually Need This
This config matters for blocking code. If your stack is imperative – JDBC calls, blocking HTTP clients, Thread.sleep() in your service layer – the IO executor is where that work runs.
micronaut:
server:
thread-selection: BLOCKING
With thread-selection: BLOCKING, Micronaut dispatches requests from the Netty event loop to the IO executor. Make that executor use virtual threads, and your blocking JDBC calls run on virtual threads. No code changes. Just config.
For reactive code (Mono/Flux pipelines, R2DBC), virtual threads on the executor don’t help – nothing gets dispatched there. The reactive equivalent is loom-carrier, which puts virtual threads on the Netty event loop itself. Different mechanism, different config, different article.
The RSS Payoff
Same app from Part 12 — imperative Micronaut, JDBC, blocking. With THREAD_PER_TASK + virtual: true under identical k6 load (ramping to 200 VUs, 500 req/s constant):
| Scenario | RSS | Threads (max) |
|---|---|---|
| jvm-unlimited (platform threads) | 555 MB | 72 |
| jvm-vthreads-unlimited (virtual threads) | 465 MB | 72 |
90 MB less RSS at the same thread count. The arithmetic: 72 platform threads × ~1 MB pre-allocated stack = ~72 MB reserved. Virtual threads allocate stack frames on demand, starting at a few KB and growing only as deep as the actual call stack. Under this workload, most virtual thread stacks never exceed 50 KB. 72 threads × ~950 KB saved ≈ 68 MB. The rest is G1GC region overhead from fewer large allocations.
Why This Confusion Exists
PR #8180 added THREAD_PER_TASK to the enum and virtual as a separate boolean on ExecutorConfiguration. The API design is correct – it separates the scheduling strategy from the thread implementation. But the mental model “I want virtual threads, so the type should be VIRTUAL” is so strong that it’s what everyone writes first.
And then AI models, trained on that intuition (and on each other’s wrong answers), confidently suggest type: VIRTUAL as if they’d seen it work.
They haven’t. It’s never worked. The enum has never contained that value.
The One-Line Summary
Virtual thread executors in Micronaut:
type: THREAD_PER_TASK # how tasks are scheduled
virtual: true # what kind of threads run them
Two decisions. Two properties. Write type: VIRTUAL and it crashes. Write virtual: true alone and it silently caps at 2 × CPU. You need both lines.
Sources
- ExecutorType enum (Micronaut latest) – the five actual values
- micronaut-core PR #8180 – virtual thread support implementation
- Micronaut loom-carrier blog post – the reactive-side alternative