Virtual Threads
Virtual Threads
Virtual threads were introduced as an experimental feature in Lucee 7 and became official in Lucee 8.
What Are Virtual Threads?
Java virtual threads (Project Loom, Java 21+) are lightweight threads managed by the JVM instead of the operating system. A platform thread maps to an OS thread and is relatively expensive to create and hold. A virtual thread is cheap — you can run thousands or millions of them concurrently without exhausting the thread pool.
Lucee exposes virtual threads in two places you already use:
<cfthread>/thread— opt in per thread withvirtual=true- Parallel collection functions — opt in with
parallel="virtual"
Both require Java 21 or newer. On older JVMs Lucee falls back to regular platform threads and logs a warning once.
On Lucee 7, virtual threads are experimental — the API may change. From Lucee 8 onward they are a supported, production-ready feature.
Why and When to Use Them
Virtual threads shine for I/O-bound work: HTTP calls, database queries, file reads, waiting on external APIs. The pattern is “many tasks, each spends most of its time waiting.”
| Use virtual threads | Stick with platform threads (parallel="thread") |
|---|---|
| Fan-out HTTP requests to hundreds of endpoints | CPU-heavy computation (image processing, crypto) |
| Parallel database lookups across many IDs | Work that holds locks for long periods |
| High-concurrency integration/sync jobs | When you need a small, fixed thread pool |
I/O and interruption
This is one of the practical wins for CFML code.
When a virtual thread hits a blocking I/O call (for example cfhttp, queryExecute, or sleep), the JVM unmounts it from its carrier platform thread. That carrier is free to run other virtual threads while the first one waits.
That matters when you need to stop work:
- A platform thread blocked deep in native I/O may not respond to
interruptorterminateuntil the blocking call returns. - A virtual thread waiting on I/O can be interrupted or terminated much more reliably — the JVM can unblock the wait and tear down the task.
<cfthread> supports action="interrupt" and action="terminate" on virtual threads the same way as on platform threads, but you will see the difference most clearly when the thread body is waiting on I/O rather than burning CPU.
<cfthread> / thread
Add virtual=true when creating a daemon thread (action="run", type="daemon" — the default).
// Fire-and-forget on a virtual thread
thread name="worker" virtual=true {
sleep(500);
thread.data = "done";
}
thread action="join" name="worker";
writeOutput(cfthread.worker.data); // done
Inside the thread body, thread.virtual tells you whether the thread actually runs on a virtual thread (true on Java 21+, false when Lucee fell back to a platform thread).
Interrupt and terminate
thread name="fetch" virtual=true {
// long-running I/O — virtual thread releases its carrier while waiting
http url="https://httpbin.org/delay/30" result="rsp" timeout=60;
}
sleep(100); // let the thread start
thread action="interrupt" name="fetch";
thread action="join" name="fetch";
priority has no effect on virtual threads — the JVM does not apply thread priorities to them.
Task threads (type="task") do not support the virtual attribute.
Parallel Functions
Iteration functions accept a parallel argument with three modes:
| Value | Behaviour |
|---|---|
"none" |
Sequential — runs on the calling thread (default) |
"thread" |
Parallel on platform threads (bounded pool) |
"virtual" |
Parallel on virtual threads (unbounded by default) |
Applies to arrayMap, arrayEach, arrayFilter, arrayReduce, arraySome, arrayEvery, and the equivalent list*, struct*, and query* functions. Collection member syntax works too: ids.each(callback, "virtual").
HTTP fan-out example
ids = [101, 102, 103, 104, 105];
// Each closure runs on its own virtual thread
results = arrayMap(ids, function(id) {
http url="https://httpbin.org/get?id=#id#" result="rsp" timeout=10;
return deserializeJSON(rsp.fileContent).args.id;
}, "virtual");
writeDump(results);
Concurrency limit
By default, parallel="virtual" is unbounded — every element gets its own virtual thread. That is usually fine for I/O-bound work, but you can cap it:
// At most 10 virtual threads running closures at once
arrayEach(ids, function(id) {
queryExecute("SELECT * FROM items WHERE id = :id", { id: id });
}, "virtual", 10);
With parallel="thread", omitting maxConcurrency uses a bounded pool. With parallel="virtual", maxConcurrency=0 (the default) means unbounded.
Deprecated boolean syntax
Older code may pass true/false as the parallel argument. This still works but is deprecated — prefer the string modes:
false→"none"true→"virtual"on Java 21+ (Lucee 8 default), or"thread"whenlucee.allow.virtual.threadsis set tofalse(see below)
In Lucee 7.1,
trueonly used virtual threads on Java 25+ withlucee.allow.virtual.threads=true(defaultfalse). In Lucee 8, the setting defaults totrueand Java 21 is enough, so code that passesparallel=truenow runs on virtual threads by default. Useparallel="thread"if you need platform threads for a specific call.
Global Defaults
Two separate settings control the defaults. Each one can be set as a system property or an environment variable.
<cfthread>: lucee.thread.virtual
lucee.thread.virtual=true
LUCEE_THREAD_VIRTUAL=true
Default false. When enabled, <cfthread> without an explicit virtual attribute runs on virtual threads. It does not affect the parallel functions.
parallel=true: lucee.allow.virtual.threads
lucee.allow.virtual.threads=false
LUCEE_ALLOW_VIRTUAL_THREADS=false
Default true (Java 21+). While enabled, the deprecated parallel=true runs on virtual threads; set it to false to make parallel=true use platform threads. Lucee also uses this setting for some internal threads. The explicit modes parallel="thread" and parallel="virtual" are not affected.
Per-call overrides always win: virtual=false on a thread tag, or parallel="thread" on a function, forces platform threads.
See Also
- Complete Guide to Threading in Lucee — platform threads, thread pools, coordination
- Thread Tasks — daemon vs task threads
<cfthread>— full tag reference