Virtual Threads

edit Introduced: 8.0

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 with virtual=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 interrupt or terminate until 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" when lucee.allow.virtual.threads is set to false (see below)

In Lucee 7.1, true only used virtual threads on Java 25+ with lucee.allow.virtual.threads=true (default false). In Lucee 8, the setting defaults to true and Java 21 is enough, so code that passes parallel=true now runs on virtual threads by default. Use parallel="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

See also