JDK21でリリースされたVirtual ThreadsがSpring 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.enabled
をtrue
にすれば有効化できる。
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を使って検出して実装を修正できないか検討する必要がある。
(参考リンク)
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を終了せずに残すことができる。
(参考リンク)
その他
その他Virtual Threadsを導入する場合のガイドがOracleのドキュメントにまとまっている。
(参考リンク)