ExecutorType.VIRTUAL Doesn't Exist

The Memory Benchmark · Part 14

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:

  • SCHEDULED
  • CACHED
  • FIXED
  • WORK_STEALING
  • THREAD_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):

ScenarioRSSThreads (max)
jvm-unlimited (platform threads)555 MB72
jvm-vthreads-unlimited (virtual threads)465 MB72

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