Skip to content

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

Editorial illustration of one SPI connecting Java 21 and Java 25 runtime implementations
One public API can hide two runtime choices when the SPI boundary is boring in the right way.

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.

ServiceLoader module layout for Java 21 and Java 25 virtual thread runtimes
Hide Java 21/25 differences inside runtime providers and keep application code on one Virtual Threads API.

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.

ReleaseMeaning for Virtual Threads
Java 21Virtual Threads final, thread-per-task APIs available
Java 24JEP 491 reduces synchronized pinning
Java 25LTS. 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.

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.

ItemWhy
runtimeNameDiagnostics and logs show which provider was selected
priorityThe 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")
}

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.

Each runtime module registers itself with one service file.

META-INF/services/io.bluetape4k.concurrent.virtualthread.VirtualThreadRuntime

The Java 21 module lists its Java 21 implementation.

io.bluetape4k.concurrent.virtualthread.jdk21.Jdk21VirtualThreadRuntime

The 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.

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.

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.

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.

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.