Java Virtual Threadを試してみる

昔から注目していたProject LoomJava 19にPreview版としてようやく入ってきました。 そこで、Virtual Threadの効果を試してみました。

今回試したのは、以下の環境でデータベースアクセスして結果を返すRest APIを作成し、大量同時アクセスしてみる、ということを行いました。
これは私が本業で使っている環境に近い構成です。今後の採用指針として調査する目的も兼ねてます。

効果があるなら、少ないメモリ、スレッド数でたくさんの処理をさばいてくれるはずです。

  • Java openjdk 19.0.1 2022-10-18
  • Spring boot 3.0.0-RC2
  • Spring Data JPA
  • MySQL 8.0.26
  • Kotlin 1.7.20

Spring WebMVCでVirtual Threadを有効にする

今回、Spring WebMVCを使っています。サーバはデフォルトのTomcatで、Tomcatがスレッドを生成する処理を置き換える形で、Virtual Thread が生成されるようにします。
処理の参考は、こちらを参考にしています。

/**
 * Virtual Threadを生成する設定。
 * @see https://spring.io/blog/2022/10/11/embracing-virtual-threads
 */
@Configuration
class ExecutorConfig {

    @Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
    fun asyncTaskExecutor(): AsyncTaskExecutor? {
        return TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor())
    }

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

Virtual Threadはまだpreview機能なので、実行するときにJavaのオプションに、--enable-previewを指定して実行しないと、機能が有効になりません。

サーバ本体は、以下のような感じで起動してます。

java --enable-preview -jar build/libs/virtualthread-0.0.1-SNAPSHOT.jar

データベースアクセス

データベースはMySQL 8です。使用したORMはJPA(Hybernate)となります。今回、usersテーブル、articlesテーブル、commentsテーブルという3つのテーブルを用意し、articlsに100件、それぞれコメントデータを1件づつ関連付けてデータを入れました。

ベンチマーク

早速ベンチマークしてみましょう。実行環境は私が使っているデスクトップマシンで、以下のようなスペックです。

条件

wrkというベンチマークツールを使い、5スレッド生成、同時 500 アクセスで30秒間実行しています。 処理する機能は、100件あるarticlesデータをすべてJson形式で取得する処理となっています。 また、サーバは最大500スレッドまで生成可能にしました。

Virtual Threadを使わない

まずはVirtual Threadを使わない、今と同じ1接続、1Threadを生成して処理するパターンです。

1回目

yaku@ubuntu:~$ wrk -t5 -c500 -d30s http://localhost:8080/api/articles
Running 30s test @ http://localhost:8080/api/articles
  5 threads and 500 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   311.84ms  207.85ms   1.84s    72.09%
    Req/Sec   335.71    119.71   770.00     66.34%
  49339 requests in 30.08s, 3.54GB read
  Socket errors: connect 0, read 0, write 0, timeout 1
Requests/sec:   1640.14
Transfer/sec:    120.48MB

Normal Thread1

2回目

yaku@ubuntu:~$ wrk -t5 -c500 -d30s http://localhost:8080/api/articles
Running 30s test @ http://localhost:8080/api/articles
  5 threads and 500 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   244.59ms  140.92ms   1.28s    72.79%
    Req/Sec   417.50    125.41     1.13k    73.34%
  61028 requests in 30.08s, 4.37GB read
Requests/sec:   2028.76
Transfer/sec:    148.72MB

Normal Thread2

きちんと1接続、1Thread生成しているようですね。メモリもかなり食います。

Virtual Threadを使う

次に、Virtual Threadを使用したパターンで実施しました。

1回目

yaku@ubuntu:~$ wrk -t5 -c500 -d30s http://localhost:8080/api/articles
Running 30s test @ http://localhost:8080/api/articles
  5 threads and 500 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   305.18ms  203.35ms   1.99s    79.87%
    Req/Sec   344.68    144.35   808.00     68.14%
  50687 requests in 30.07s, 3.63GB read
  Socket errors: connect 0, read 0, write 0, timeout 79
Requests/sec:   1685.55
Transfer/sec:    123.55MB

Virtual Thread 1

2回目

    yaku@ubuntu:~$ wrk -t5 -c500 -d30s http://localhost:8080/api/articles
    Running 30s test @ http://localhost:8080/api/articles
      5 threads and 500 connections
      Thread Stats   Avg      Stdev     Max   +/- Stdev
        Latency   261.18ms   79.57ms 890.32ms   91.12%
        Req/Sec   378.99    116.71   828.00     69.27%
      56642 requests in 30.05s, 4.05GB read
    Requests/sec:   1885.12
    Transfer/sec:    138.18MB

Virtual Thread 2

すばらしい。Virtual Threadを使わない場合と比べて、メモリ使用量が2分の1以下、最大スレッド数も14分の1と、消費するマシンリソースはかなり減ってます。
1秒あたりのリクエスト数は、約8%ほどVirtual Threadのほうが少ないようです。 また、最初の方はタイムアウト(=2秒)も結構発生していました。

まとめ

Virtual Threadを使用すると、同じリクエスト数の処理に対して、明らかに必要とするマシンリソースは少なくなるようです。 ただ、1リクエストの処理は若干遅く、起動直後は処理が追いつかなくなることもあるようでした。 この処理の遅延がどこにあるのかまだ分かりませんが、正式リリースされる頃には解消されることを期待したいです。

現状、mysqlJDBCドライバはPostgreSQLJDBCドライバと同様に、 synchronized ブロックが入った処理があるようです。この処理がVirtual Threadに親和性のあるロック処理に変わらないと、性能が発揮できないのかなぁと思ったりしてます。

今回使用したソースはこちら github.com