Enabling Virtual Thread in Kotlin + Spring Boot

Enabling Virtual Thread in Kotlin + Spring Boot

Overview

  • Starting with Java 19, the concept of Virtual Thread has been newly added as a Preview Feature, and it is scheduled to be included as an official feature from Java 21 LTS. While the traditional Platform Thread directly maps to the threads of the Operating System(OS), Virtual Thread operates as a lightweight virtual thread abstracted by the Java Virtual Machine(JVM), consuming significantly less memory. Virtual threads are automatically managed by the JVM's scheduler, allowing developers to focus more on business logic while enjoying performance benefits.

  • Spring Boot 3 and Spring Framework 6 officially support Virtual Threads. This article summarizes how to replace platform threads that handle Spring Web MVC requests, @Async, and coroutine executions in Spring Boot-based projects with virtual threads. (All the content below has been verified in a production environment.)

Virtual Thread Features

  • Java 19/20 started offering Virtual Thread as a Preview Feature, and as a full feature in Java 21, which is the LTS version.

  • Traditional Platform Threads, which directly wrap operating system threads, could not be used for the duration of blocking caused by IO or computing, such as network and database operations. Virtual Threads, on the other hand, are much lighter as logical units managed by the JVM, isolated from physical OS threads. Crucially, when blocking occurs, the OS thread being used can be utilized by another Virtual Thread, significantly improving concurrency processing. This allows developers to achieve dramatic performance improvements in traditional sequential programming without significant changes, unlike the Reactive approach. (This represents a major innovation in the JVM community after many years.)

  • The Java community has made meticulous efforts to maintain backward compatibility with the introduction of Virtual Threads. Executors.newVirtualThreadPerTaskExecutor() can be used to easily create an Executor object. Integrating this with servlet containers and Kotlin coroutines allows for the immediate use of Virtual Threads without changing existing code.

  • There are two ways to use Virtual Thread. Java 19/20 requires you to add the option --release 19 --enable-preview at project build time and --enable-preview at run time. Java 21, on the other hand, does not need to add this option.

Installing OpenJDK 21

  • Install OpenJDK 21 in the development environment as follows. (For convenience, SDKMAN was used.)
# Install SDKMAN in Linux, macOS
$ curl -s "https://get.sdkman.io" | bash
$ source "$HOME/.sdkman/bin/sdkman-init.sh"

# Install Amazon Corretto 21 and set as the default JDK
$ sdk i java 21.0.1-amzn
$ sdk default java 21.0.1-amzn

$ sdk current java
Using java version 21.0.1-amzn

# Check installed version
$ java --version
openjdk 21.0.1 2023-10-17 LTS
OpenJDK Runtime Environment Corretto-21.0.1.12.1 (build 21.0.1+12-LTS)
OpenJDK 64-Bit Server VM Corretto-21.0.1.12.1 (build 21.0.1+12-LTS, mixed mode, sharing)

Environment Variables

  • Add the following to your project or operating environment variables.
# Activate Virtual Threads in Spring Boot
SPRING_THREADS_VIRTUAL_ENABLED=true

Activating JDK 21 in IntelliJ IDEA

  • In IntelliJ IDEA, activate the previously installed JDK 21 at the project level as follows:
Settings → Project Structure
→ SDK: select [corretto-21]
→ Language Level: select [21 (Preview) - String templates, unnamed classes and instance main methods etc.]

build.gradle.kts

  • Add the following content to build.gradle.kts in the project root to activate JDK 21 at the build level.
// BEFORE: java.sourceCompatibility = JavaVersion.VERSION_17
java.sourceCompatibility = JavaVersion.VERSION_21

tasks.withType<KotlinCompile> {
    kotlinOptions {
        // BEFORE: freeCompilerArgs = listOf("-Xjsr305=strict")
        // BEFORE: jvmTarget = "17"
        // AFTER: Only JDK 19/20, Add --release 19 --enable-preview option at the compile stage
        freeCompilerArgs = listOf("-Xjsr305=strict --release 21")
        jvmTarget = "21"
    }
}

// AFTER: Only JDK 19/20, Add --enable-preview option at runtime
tasks.withType<JavaExec> {
    jvmArgs = listOf("--enable-preview")
}

Switching HTTP Request Handling to Virtual Threads

  • In the Java ecosystem, servlet containers handle requests through physical platform threads, which are OS threads wrapped for each unit request. The following code allows you to instruct Apache Tomcat, the servlet implementation embedded in Spring Boot, to process all requests using virtual threads instead of platform threads.
import org.apache.coyote.ProtocolHandler
import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.concurrent.Executors

@Configuration
class TomcatConfig {

    @Bean
    fun protocolHandlerVirtualThreadExecutorCustomizer(): TomcatProtocolHandlerCustomizer<*>? {
        return TomcatProtocolHandlerCustomizer<ProtocolHandler> { protocolHandler: ProtocolHandler ->
            protocolHandler.executor = Executors.newVirtualThreadPerTaskExecutor()
        }
    }
}

Switching Asynchronous Execution to Virtual Threads

  • One of the most convenient methods for executing asynchronous logic in the Spring Boot ecosystem, @Async, traditionally runs on threads allocated by AsyncTaskExecutor implementations based on a platform thread pool. This can be switched to virtual threads as follows.
import org.slf4j.MDC
import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.task.AsyncTaskExecutor
import org.springframework.core.task.TaskDecorator
import org.springframework.core.task.support.TaskExecutorAdapter
import org.springframework.scheduling.annotation.EnableAsync
import java.util.concurrent.Executors

@Configuration
@EnableAsync
class AsyncConfig {

    @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
    fun asyncTaskExecutor(): AsyncTaskExecutor {

        val taskExecutor = TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor())
        taskExecutor.setTaskDecorator(LoggingTaskDecorator())

        return taskExecutor
    }
}

class LoggingTaskDecorator : TaskDecorator {

    override fun decorate(task: Runnable): Runnable {

        val callerThreadContext = MDC.getCopyOfContextMap()

        return Runnable {
            callerThreadContext?.let {
                MDC.setContextMap(it)
            }
            task.run()
        }
    }
}
  • In Spring Beans, you can run asynchronous logic with Virtual Thread as shown below.
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service

@Service
class FooService {

    @Async(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
    fun doSomething() {
        // doSomething
    }
}

Switching Scheduler Execution to Virtual Threads

  • In the Spring Boot ecosystem, @Scheduled tasks, which are executed at specific times based on defined rules, traditionally run on threads allocated by ThreadPoolTaskExecutor implementations based on a platform thread pool. This can be switched to virtual threads as follows.
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.TaskScheduler
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.concurrent.ConcurrentTaskScheduler
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler
import java.util.concurrent.Executors

@Configuration
@EnableScheduling
class SchedulingConfig {

    @Bean
    fun taskScheduler(): TaskScheduler {

        // [Option 1] Activate Virtual Threads in JDK 21
        return SimpleAsyncTaskScheduler().apply {
            this.setVirtualThreads(true)
            this.setTaskTerminationTimeout(30 * 1000)
        }

        // [Option 2] Activate Virtual Threads in JDK 19/20
        return ConcurrentTaskScheduler(
            Executors.newScheduledThreadPool(0, Thread.ofVirtual().factory())
        )
    }
}

Switching Kotlin Coroutine Execution to Virtual Threads

  • Kotlin has long provided developers with a similar suspend function through coroutines, even before the advent of Virtual Threads. By writing the code below and using Dispatchers.LOOM, which applies virtual threads instead of the traditionally used Dispatchers.IO, coroutines can be executed on virtual threads.
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import java.util.concurrent.Executors

val Dispatchers.LOOM: CoroutineDispatcher
    get() = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()

Creating a Dockerfile for Amazon ECS Container Deployment

  • To launch a container with JVM 21 in an Amazon ECS environment, a Dockerfile can be written as follows. (At the time of writing this article, there wasn't a suitable OpenJDK 21-based Docker base image available, so an example is provided where OpenJDK 21 is installed directly. This method has been verified in a production-level environment.)
# To avoid rate limit restrictions, AWS Public ECR is used instead of DockerHub, and a base image verified for compatibility with Amazon ECS is utilized.
FROM public.ecr.aws/ews-network/amazoncorretto:17-debian
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
      HTTP_PROXY=http:... \
      HTTPS_PROXY=http:...
EXPOSE 8080
USER root
# Install OpenJDK 21, you can use a base image based on 20 according to your environment.
RUN apt update -y
RUN apt install wget gnupg -y
RUN update-ca-certificates
RUN wget https://apt.corretto.aws/corretto.key
RUN apt-key add corretto.key
RUN echo 'deb https://apt.corretto.aws stable main' | tee /etc/apt/sources.list.d/corretto.list
RUN apt-get update -y
RUN apt-get install java-21-amazon-corretto-jdk -y
# Assuming the path to the .jar file built via Gradle settings is build/libs/app.jar, modify it according to your environment.
COPY build/libs/app.jar /app.jar
COPY buildspec/entrypoint.sh /
ENTRYPOINT ["sh", "/entrypoint.sh"]
  • Write entrypoint.sh as follows. The key point is specifying the --enable-preview option at runtime.
#!/bin/sh
export ECS_INSTANCE_IP_TASK=$(curl --retry 5 -connect-timeout 3 -s ${ECS_CONTAINER_METADATA_URI})
export ECS_INSTANCE_HOSTNAME=$(cat /proc/sys/kernel/hostname)
export ECS_INSTANCE_IP_ADDRESS=$(echo ${ECS_INSTANCE_IP_TASK} | jq -r '.Networks[0] | .IPv4Addresses[0]')
echo "${ECS_INSTANCE_IP_ADDRESS} ${ECS_INSTANCE_HOSTNAME}" | sudo tee -a /etc/hosts
exec java ${JAVA_OPTS} -server -XX:+UseZGC -XX:+ZGenerational --enable-preview -jar /app.jar
  • For integration with AWS CodePipeline, a buildspec.xml can be written as follows. (The {region}, {repository-uri}, {image-name}, {container-name} parameters should be appropriately modified for your project environment.)
version: 0.2

phases:
  install:
    runtime-versions:
      java: corretto21
    run-as: root
    commands:
      - update-ca-trust
      - javac --version
  pre_build:
    commands:
      - REGION={region}
      - REPOSITORY_URI={repository-uri}
      - IMAGE_NAME={image-name}
      - IMAGE_TAG=latest
      - DEPLOY_TAG=dev
      - COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
      - BUILD_TAG=${COMMIT_HASH:=dev}
      - CONTAINER_NAME={container-name}
      - DOCKERFILE_PATH=buildspec/Dockerfile
      - echo Logging in to Amazon ECR...
      - aws --version
      - aws ecr get-login-password --region $REGION | docker login -u AWS --password-stdin $REPOSITORY_URI
  build:
    commands:
      - echo Building the Docker image...
      - chmod +x ./gradlew
      - ./gradlew build -x test
      - docker build -f $DOCKERFILE_PATH -t $IMAGE_NAME .
      - docker tag $IMAGE_NAME:$IMAGE_TAG $REPOSITORY_URI/$IMAGE_NAME:$DEPLOY_TAG
  post_build:
    commands:
      - echo Pushing the Docker images...
      - docker push $REPOSITORY_URI/$IMAGE_NAME:$DEPLOY_TAG
      - printf '[{"name":"%s","imageUri":"%s"}]' $CONTAINER_NAME $REPOSITORY_URI/$IMAGE_NAME:$DEPLOY_TAG > imagedefinitions.json
      - cat imagedefinitions.json

cache:
  paths:
    - '/root/.m2/**/*'
    - '/root/.gradle/caches/**/*'

artifacts:
  files:
    - imagedefinitions.json

Reference Articles