Virtual Threads Part 4: Hiding Java 21 and Java 25 Behind One API

This is Part 4 of the Virtual Threads series. The full series continues with Part 1: introduction and cautions, Part 2: workshop rules, Part 3: JDBC + Virtual Threads benchmark, and Part 4: Java 21/25 SPI design.
Part 1 explained how to think about Virtual Threads. Part 2 covered workshop rules. Part 3 used
bluetape4k-exposed benchmarks to show why JDBC + Virtual Threads can beat R2DBC + Coroutines
more often than expected.
The final topic is library design: how do we keep application code simple while still supporting different JDK lines internally?
Can we support Java 21 and Java 25 while exposing only one API to application code?
The virtualthread module in bluetape4k-projects splits the problem into a common API and
JDK-specific runtime providers. If application code has to ask “am I on Java 21 or Java 25?”
everywhere, too much implementation detail has leaked out.
The key is that the application does not know the jdk21 or jdk25 implementation classes. It
calls the common API. ServiceLoader selects the runtime provider. The Java-version-specific
logic stays inside the artifact and provider implementation.

Java 21 and Java 25
Section titled “Java 21 and Java 25”Java 21 is the Virtual Threads baseline. JEP 444 made Virtual
Threads final, and it gives us the core tools: Executors.newVirtualThreadPerTaskExecutor(),
Thread.ofVirtual(), thread dumps, and JFR observability.
Java 25 does not reinvent Virtual Threads. It is an LTS point that carries forward Loom-related improvements after Java 21.
| Release | Meaning for Virtual Threads |
|---|---|
| Java 21 | Virtual Threads final, thread-per-task APIs available |
| Java 24 | JEP 491 reduces synchronized pinning |
| Java 25 | LTS. Scoped Values final, Structured Concurrency remains preview |
The JDK 25 project page includes Structured Concurrency and Scoped Values in its feature list. Scoped Values are especially useful with Virtual Threads because they offer a more predictable context model than ThreadLocal for scoped, read-only values.
But a library that still needs to run on Java 21 should not expose Java 25 APIs on its compile classpath. Application code should see the common API, while runtime modules vary by JDK.
Common API: VirtualThreadRuntime
Section titled “Common API: VirtualThreadRuntime”The core interface should stay small.
interface VirtualThreadRuntime { val runtimeName: String val priority: Int
fun isSupported(): Boolean fun threadFactory(prefix: String = "vt-"): ThreadFactory fun executorService(): ExecutorService}Three details matter.
| Item | Why |
|---|---|
runtimeName | Diagnostics and logs show which provider was selected |
priority | The Java 25 provider can win when it is available |
isSupported() | A provider can be on the classpath but still unsafe on the current JVM |
Application code does not import provider implementations directly.
import io.bluetape4k.concurrent.virtualthread.VirtualThreads
VirtualThreads.executorService().use { executor -> val answer = executor.submit<Int> { 21 * 2 }.get() println("${VirtualThreads.runtimeName()} -> $answer")}Provider Selection with ServiceLoader
Section titled “Provider Selection with ServiceLoader”The VirtualThreads facade discovers providers through ServiceLoader, filters unsupported
providers, and picks the highest-priority available implementation.
object VirtualThreads { private val providers: List<VirtualThreadRuntime> by lazy { ServiceLoader.load(VirtualThreadRuntime::class.java) .filter { provider -> provider.isSupported() } .sortedByDescending { it.priority } .toList() }
fun runtime(): VirtualThreadRuntime = providers.firstOrNull() ?: PlatformThreadRuntime
fun runtimeName(): String = runtime().runtimeName
fun threadFactory(prefix: String = "vt-"): ThreadFactory = runtime().threadFactory(prefix)
fun executorService(): ExecutorService = runtime().executorService()}The real code also guards provider discovery so one broken provider cannot break application startup. If a Java 25 provider cannot load on the current JVM, the system should fall back to a Java 21 provider or platform-thread fallback instead of failing the whole virtual-thread facade.
Provider Registration
Section titled “Provider Registration”Each runtime module registers itself with one service file.
META-INF/services/io.bluetape4k.concurrent.virtualthread.VirtualThreadRuntimeThe Java 21 module lists its Java 21 implementation.
io.bluetape4k.concurrent.virtualthread.jdk21.Jdk21VirtualThreadRuntimeThe Java 25 module lists a higher-priority Java 25 provider.
class Jdk25VirtualThreadRuntime : VirtualThreadRuntime { override val runtimeName: String = "jdk25" override val priority: Int = 25
override fun isSupported(): Boolean = Runtime.version().feature() >= 25
override fun threadFactory(prefix: String): ThreadFactory = Thread.ofVirtual().name(prefix, 0).factory()
override fun executorService(): ExecutorService = Executors.newVirtualThreadPerTaskExecutor()}The Java 21 provider has a lower priority.
class Jdk21VirtualThreadRuntime : VirtualThreadRuntime { override val runtimeName: String = "jdk21" override val priority: Int = 21
override fun isSupported(): Boolean = Runtime.version().feature() >= 21
override fun threadFactory(prefix: String): ThreadFactory = Thread.ofVirtual().name(prefix, 0).factory()
override fun executorService(): ExecutorService = Executors.newVirtualThreadPerTaskExecutor()}The common API is the same, but the Java 25 module can use Java 25-only APIs internally when needed. Application code does not see that difference.
Gradle Shape
Section titled “Gradle Shape”Keep only the common API on the compile classpath.
dependencies { implementation("io.github.bluetape4k:bluetape4k-virtualthread-api")}Put the provider matching the deployment JDK on the runtime classpath.
dependencies { runtimeOnly("io.github.bluetape4k:bluetape4k-virtualthread-jdk21") // or runtimeOnly("io.github.bluetape4k:bluetape4k-virtualthread-jdk25")}The important rule is not to put Java 25 class files on a Java 21 runtime. Even with
isSupported(), class loading can fail before the provider can opt out. Deployment pipelines
should split JDK-specific artifacts or include only the provider that matches the runtime image.
Why Fallback Exists
Section titled “Why Fallback Exists”The common API also provides a platform-thread fallback. On JDK 17 or in an environment with no provider, it can fall back to a cached platform-thread executor.
private object PlatformThreadRuntime : VirtualThreadRuntime { override val runtimeName: String = "platform-fallback" override val priority: Int = Int.MIN_VALUE override fun isSupported(): Boolean = true override fun executorService(): ExecutorService = Executors.newCachedThreadPool(threadFactory("pt-"))}Fallback does not promise virtual-thread performance. It keeps the library graceful across more
environments. In production, record VirtualThreads.runtimeName() in startup logs or metrics so
you know which provider actually won.
Example: Application Code Does Not Change
Section titled “Example: Application Code Does Not Change”The application code is the same on Java 21 and Java 25.
class BlockingReportService( private val repository: ReportRepository,) { fun generate(ids: List<Long>): List<Report> = VirtualThreads.executorService().use { executor -> ids.map { id -> executor.submit<Report> { repository.loadAndRender(id) } }.map { future -> future.get() } }}repository.loadAndRender(id) can use JDBC or a blocking SDK. The Virtual Thread handles the
blocking wait. The rules from Part 1 still apply: DB connection pools, timeouts, and downstream
concurrency must be limited separately. A simpler API does not remove operational constraints.
Series Conclusion
Section titled “Series Conclusion”Virtual Threads are now a practical option for Java/Kotlin backend services.
- Part 1: Virtual Threads are cheap threads, not magic threads.
- Part 2: workshop rules keep pooling, semaphores, context, and locks honest.
- Part 3: in batch workloads, JDBC + Virtual Threads can beat R2DBC + Coroutines more often than expected.
- Part 4: a library can hide Java 21/25 differences behind a common API and runtime providers.
My favorite conclusion is this: Virtual Threads do not replace reactive programming or coroutines. They make the existing blocking ecosystem worth measuring again. A good library does not have to force one style. It should leave a clean path for the workload and runtime in front of it.
Comments
Leave a note or reaction with your GitHub account.