daisuzz.log

Spring Boot 3.2でサポートされたVirtual Threadsを試してみる

JDK21でリリースされたVirtual ThreadsSpring Boot 3.2からサポートされたので試してみる。

Virtual Threadsとは

JDK21でリリースされたJavaの軽量スレッド。

Javaが従来提供しているThreadは、OSのスレッドと1:1で紐づくもの(これをPlatform Threadと呼ぶ)のため、作成コストがかかったり、ThreadがIO待ち状態のときにOSのスレッドも確保したままになってしまう。

これを改善するために提案されたのがVirutal Threads。

例えばWebアプリケーションの場合、従来のThreadでは1つのリクエストに対して1つのPlatform Threadが割り当てられるため、そのリクエストの中でAPIやDBのアクセスでIO待ちが発生した場合、その間は対応するOSのスレッドもブロックされてしまう。

Virtual Threadsの場合は、処理の中でIO待ちが発生すると、そのVirtual Threadsと対応するCarrier Thread(Virtual Threadが割り当てられたPlatform Threadのこと)は、他のVirtual Threadsの処理を行う。

その後、IO待ちが解消されると元のVirtual ThreadsがCarrier Threadに再度割り当てられて後続の処理が行われる。 これにより、従来のThreadに比べて効率よく多くのリクエストを処理することが可能になる。

Virtual Threadsの詳細は以下のOracleのドキュメントがわかりやすい。

(参考リンク)

https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html

Spring BootでVirtual Threadsを使ってみる

以下の環境で手元でも試してみる。

  • Spring Boot 3.2.3
  • Java 21

Spring BootでVirtual Threadsを使う場合、設定ファイルや起動時のオプションでspring.threads.virtual.enabledtrueにすれば有効化できる。

spring:
  threads:
    virtual:
      enabled: true

裏側では、ThreadingというEnumで、上記で設定した値を参照してVirtualThreadsかどうかを判定している。

   VIRTUAL {

        @Override
        public boolean isActive(Environment environment) {
            return environment.getProperty("spring.threads.virtual.enabled", boolean.class, false)
                    && JavaVersion.getJavaVersion().isEqualOrNewerThan(JavaVersion.TWENTY_ONE);
        }

    };

その値を@ConditionalOnThreading(Threading.VIRTUAL)というアノテーションで利用しており、このアノテーションがつけられたAutoConfigurationクラスのBean定義メソッドが、Virtual Threads用の設定を行うような処理になっている。

        @Bean
        @ConditionalOnThreading(Threading.VIRTUAL)
        TomcatVirtualThreadsWebServerFactoryCustomizer tomcatVirtualThreadsProtocolHandlerCustomizer() {
            return new TomcatVirtualThreadsWebServerFactoryCustomizer();
        }

試しに@ConditionalOnThreading(Threading.VIRTUALでSpring Bootのプロジェクトを検索してみると、以下の場所でVirtual Threadsが使われるようになっている。

  • 組み込みWebサーバ(Tomcat, Jetty)のWorker
  • Task Executorを使う処理(@Asyncがついたメソッド, Spring MVCの非同期リクエスト処理, Spring WebFluxのブロッキング処理など)
  • Task Schedulingを使う処理(@Scheduledがついたメソッドや直接TaskSchedulerを呼び出す処理)
  • Rabbit MQ, Apache KafkaのListener
  • Spring Data Redis(Jedis, Lettuce)のコネクション接続

アノテーションで制御していないが、Apache PulsarのListenerもVirtual Threadsが使われるようになっている

    @Bean
    @ConditionalOnMissingBean(name = "pulsarListenerContainerFactory")
    ConcurrentPulsarListenerContainerFactory<?> pulsarListenerContainerFactory(

                 // ...

        if (Threading.VIRTUAL.isActive(environment)) {
            containerProperties.setConsumerTaskExecutor(new VirtualThreadTaskExecutor());
        }
                
                 // ...
    }

注意点

pinning

Virtual Threadsは通常IO待ちが発生すると、Platform Threadへの割り当てがアンマウントされ、別のVirtual ThreadがPlatform Threadにマウントされる。 ただ、Virtual Threadsがpinningと呼ばれる以下の状況にあるときは、Carrier Threadへの割り当てがアンマウントされずそのままになってしまう。

  • Virtual Threadsがsynchronizedブロックもしくはsynchronizedメソッド内のコードを実行している場合
  • Virtual Threadsがnativeメソッドまたは外部の関数(Foreign function)を実行している場合

pinningが長期間かつ頻繁に行われる場合はスループットに悪影響を与える可能性があるため、JFRを使って検出して実装を修正できないか検討する必要がある。

(参考リンク)

https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-04C03FFC-066D-4857-85B9-E5A27A875AF9

daemon thread

Virtual Threadsはデーモンスレッドとして実行されるが、JVMはデーモンスレッド以外のスレッドがない場合終了してしまうため、Virtual Threadsを利用する場合は注意。

例えばTask Schedulingの処理でVirtual Threadsを利用する場合、メインスレッドが先に終了すると、JVM上ではTask Schedulingのデーモンスレッド以外のスレッドがない状況になってしまい、Task Schedulingの処理が実行される前にJVMが終了してしまう。

これを回避するためにSpring Bootはspring.main.keep-aliveという設定を用意している。 これをtrueにすることで残りのスレッドが全てVirtual Threadsになってしまっても、JVMを終了せずに残すことができる。

(参考リンク)

https://docs.spring.io/spring-boot/docs/current-SNAPSHOT/reference/html/features.html#features.spring-application.virtual-threads

その他

その他Virtual Threadsを導入する場合のガイドがOracleのドキュメントにまとまっている。

(参考リンク)

https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-8AEDDBE6-F783-4D77-8786-AC5A79F517C0

参考情報