Java 21 Virtual ThreadsをDocker上で試してみました

こんにちは、さるまりんです🐒

今回は Java 21 の機能、Virtual Threads を試してみたいと思います。

Virtual Threadsって?

Virtual Threads(JEP 444)は、軽いスレッドを大量に作って“待つ処理(I/O)”を同期コードのままスケールさせる仕組みです。
「1リクエスト=1スレッド」を低コストでやりたい時に効いてきます。

事情があってローカルには JDK 11(Amazon Corretto 11)が入っているので、今回は Dockerで JDK 21 を起動して試してみます。

準備

上にも書いたように JDK 11 の環境はそのままに、Docker でやります。
まずは Docker が使えることだけ確認。

docker --version

なければインストールします。

Step 1:JDK 21 をコンテナで起動

Docker で JDK 21 を動かします。

docker run --rm eclipse-temurin:21-jdk java -version

もしAmazon Corretto 21でやりたい場合はこうです。

docker run --rm amazoncorretto:21 java -version

Step 2:エイリアスを作成

起動時の入力を減らすためにエイリアスを作っておきます。
長いコマンドでも問題なければ不要ですが、あると楽!)

alias j21='docker run --rm -v "$PWD":/work -w /work eclipse-temurin:21-jdk'

以降、j21 sh -lc '...'で「JDK 21 の入ったコンテナでコマンド実行」できます。

Step 3:サンプルを作成

このサンプルでは、5000 個の「100ms だけ待つ仕事」を一気に投げます。
仮想スレッドは“待ってる間に席を譲る”ので、窓口(OS スレッド)を塞がず同時にたくさん待てます。
逆に従来スレッドは窓口を占有して順番待ちになるため時間がかかります。
※CPU を回す重い処理は別物(ここでは「待ち」の体感に焦点を当てています)。

VTDemo1.java(大量に sleep する“待ち”のダミー処理)

import java.util.*;
import java.util.concurrent.*;

/* デモの意図:
   - 5000タスク × 100ms の「待つだけの仕事」を同時投入
   - virtual: 待機中はOSスレッドを解放 → 同時に大量の“待ち”をさばける
   - platform: 固定200スレッド → 窓口を占有して順番待ち(時間がかかる)
   - =「待ちに強い」= 待機中に席を譲れるから
*/
public class VTDemo1 {
  public static void main(String[] args) throws Exception {
    int tasks   = args.length > 0 ? Integer.parseInt(args[0]) : 5000;
    int sleepMs = args.length > 1 ? Integer.parseInt(args[1]) : 100;
    String mode = args.length > 2 ? args[2] : "virtual"; // "virtual" or "platform"

    ExecutorService exec = mode.equals("platform")
        ? Executors.newFixedThreadPool(200)                    // 従来スレッド
        : Executors.newVirtualThreadPerTaskExecutor();         // 仮想スレッド

    long start = System.nanoTime();
    var futures = new ArrayList<Future<?>>(tasks);
    for (int i = 0; i < tasks; i++) {
      futures.add(exec.submit(() -> { Thread.sleep(sleepMs); return null; }));
    }
    for (Future<?> f : futures) f.get();
    exec.shutdown();
    System.out.printf("%s threads: tasks=%d sleep=%dms -> %.3fs%n",
        mode, tasks, sleepMs, (System.nanoTime()-start)/1e9);
  }
}

Step 4:コンパイル&実行

実際に動かして両モード比較してみます。

# 仮想スレッド(Virtual)
j21 sh -lc 'javac --release 21 VTDemo1.java && java VTDemo1 5000 100 virtual'

# 比較:従来スレッド(固定200本)
j21 sh -lc 'javac --release 21 VTDemo1.java && java VTDemo1 5000 100 platform'

目安の結果

  • virtual:0.2 秒前後
  • platform:2.5 秒前後(= 5000/200*0.1s + α)

同じ同期コードでも、I/O の“待ち”中に OS スレッドを塞がないため、仮想スレッドの方が速い
差が小さい場合は、tasks=10000sleepMs=200にすると体感差が出やすいです。

実行例

実際に走らせてみました。

% j21 sh -lc 'javac --release 21 VTDemo1.java && java VTDemo1 5000 100 virtual' 
virtual threads: tasks=5000 sleep=100ms -> 0.168s
% j21 sh -lc 'javac --release 21 VTDemo1.java && java VTDemo1 5000 100 platform'
platform threads: tasks=5000 sleep=100ms -> 2.602s

目安のように差が出ていますね。

なぜ速くなるの?

仮想スレッドは待ちsleepやネットワーク I/O)に入ると“駐車(park)”され、OS スレッドを解放します。だから大量並列でもスレッドコストで詰まらない、という仕組みです。

どこに効いてくるの?

こんなところで力を発揮しそうです。適用のヒントになると思います。

  • Web バックエンド:外部 API / DB / FS へ“待つ呼び出し”が多いほど効く
  • バッチ / CLI:大量 URL / ファイル / メッセージを“1 タスク=1 スレッド”で素直に並列処理

これらは実際にどう使えるのかを今後の記事で検証していきたいと思います。

待ってるだけの時間ってもったいないなって思うことが多いです。
うまく待つという方向性とは少し違うかもしれませんが、効率よくさばけるのはありがたい。
このVirtual Threadsを実践的に使えるようになっていきたいです。

読んでくださってありがとうございます。
それでは、また!