1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JAVAの仮想スレッドを使ってみました

Last updated at Posted at 2024-11-18

はじめに

  • 韓国人として、日本語とコンピュータの勉強を同時に行うために、ここに文章を書いています
  • 翻訳ツールの助けを借りて書いた文章なので、誤りがあるかもしれません

最近、学習のために開発中のDIフレームワークにおいて非同期処理を実装する際、Javaの仮想スレッドとプラットフォームスレッドの性能差を体験しました。その経験を記録しようと思います。

機能紹介

 @Pipe(
            value = AsyncTestProcessor.class,
            phase = PipelinePhase.PRE_EXECUTE,
            async = true
    )
    public String process7(String input) {
        return input;
    }

現在、pipeという機能を開発中です。

このpipe機能では、メソッドの実行前、実行後、またはエラー発生時の特定のタイミングを指定して、追加のロジックを実行することができます。

また、非同期処理機能もサポートしており、並列処理を実現する予定です。

image.png

@Container(scope = ContainerScope.SINGLETON)
public class AsyncTestProcessor implements PipeProcessor<String> {
    private final TestTracker tracker;

    public AsyncTestProcessor(TestTracker tracker) {
        this.tracker = tracker;
    }

    @Override
    public ProcessResult<String> transform(String input) throws Exception {
        tracker.track("Async Start: " + input);
        Thread.sleep(1000); 
        tracker.track("Async End: " + input);
        return new ProcessResult<>(input + "-async");
    }
}

次のような TestProcesser テストクラスを設計しました。

image.png

条件は以下の通りです。

  • 各リクエストごとに1000msの遅延を設定し、I/O操作をシミュレートする
  • 同時に100,000件のリクエストを処理する

コアコード

image.png

private Object executeAsync(PipeProcessor<?> processor, Object input) {
    executorService.submit(() -> {
        try {
            executeSync(processor, input);
        } catch (Exception e) {
            logger.error("Async execution failed", e);
        }
    });
    
    return input;
}

次のように非同期処理を実装しています。

Cached Thread Pool

public class PipelineHandler {
    private final Logger logger = LoggerFactory.getLogger(PipelineHandler.class);
    private final ContainerRegistry containerRegistry;
    private final ExecutorService executorService;

    public PipelineHandler(ContainerRegistry containerRegistry, ThreadPoolType poolType, int threadPoolSize) {
        this.containerRegistry = containerRegistry;
        this.executorService = Executors.newCachedThreadPool();
    }
//...

결과

image.png

  • 過剰なスレッドが生成され、OutOfMemoryErrorが発生しました

なぜなら、CachedThreadPoolの場合、スレッドを無制限に生成できるため、大量のリクエストが入ると
メモリ不足の問題が発生したようです。

ThreadPoolを設定する方法もありました。

public class PipelineHandler { 
    private final Logger logger = LoggerFactory.getLogger(PipelineHandler.class);
    private final ContainerRegistry containerRegistry;
    private final ExecutorService executorService;

    public PipelineHandler(ContainerRegistry containerRegistry) {
        this.containerRegistry = containerRegistry;
        this.executorService = Executors.newFixedThreadPool(200);  
    }
}

任意で、Spring Bootの組み込みTomcatの最大スレッド数が200であることを参考にして、200個のスレッドを確保してテストを行いました。

スレッドプールの設定方法がわからず調べていたところ、Brian Goetz氏の公式が目に留まりました

int optimalThreadPoolSize = numberOfCores * targetUtilization * (1 + waitTime / computeTime);

このような公式を見つけることができましたが、サービス時間を正確に測定するのが難しいため、この公式を適用しませんでした。

結果

image.png

結果は次のように1分1秒程度でした。

5回ほど追加で実施しましたが、1分〜1分10秒程度でした。

image.png

image.png

CPU使用率は30%がピークでした。

image.png
メモリ使用率は380.59MB〜477.23MBでしたが

image.png

実験時に457〜638MBが記録されるなど、値が上に跳ねる場合がありました。

image.png

その他に特異点はありませんでした。


VirtualThread

public class PipelineHandler { 
   private final Logger logger = LoggerFactory.getLogger(PipelineHandler.class);
   private final ContainerRegistry containerRegistry;
   private final ExecutorService executorService;

   public PipelineHandler(ContainerRegistry containerRegistry) {
       this.containerRegistry = containerRegistry;
       this.executorService = Executors.newVirtualThreadPerTaskExecutor();  
   }
}

次のように仮想スレッドで作成して実験を行いました。

image.png

驚異的な速度の低下が見られ、わずか9秒しかかかりませんでした。
何度か試しましたが、値が跳ねても20秒を超えることはありませんでした。

image.png

CPU使用率はわずかに減少しました。

image.png

メモリ使用量は162MBと絶対的な使用量が減少し、安定していることが確認できました。

他の指標も確認しましたが、特に大きな差はありませんでした。

結論

このロジックをテストする中で、以下の2つの結論に達しました。

  1. 仮想スレッドはプラットフォームスレッドよりもメモリの消費がはるかに少ないと考えられます
  2. 仮想スレッドはプラットフォームスレッドよりも速度がはるかに速いと判断できます

公式ドキュメントでも、以下のような記述を見つけることができました。

To put it another way, virtual threads can significantly improve application throughput when
The number of concurrent tasks is high (more than a few thousand), and
The workload is not CPU-bound, since having many more threads than processor cores cannot improve throughput in that case.
Virtual threads help to improve the throughput of typical server applications precisely because such applications consist of a great number of concurrent tasks that spend much of their time waiting.

  • 同時に数千以上の作業がある場合
  • 作業が主に待機時間を含むI/O操作である場合

仮想スレッド(Virtual Threads)は、アプリケーションのスループットを大幅に向上させることができると記載されていました。

今回の実験では、処理量が多く、CPU側のI/O処理ではなくアプリケーション側の処理であったため、速度が改善されたと推測することができました。

一般的なスレッドと仮想スレッドの違いは?

カーネルスレッド(プラットフォームスレッド)

基本的なJavaのスレッドについて、以下のように記述されています。

What is a Platform Thread?
A platform thread is implemented as a thin wrapper around an operating system (OS) thread. A platform thread runs Java code on its underlying OS thread, and the platform thread captures its OS thread for the platform thread's entire lifetime. Consequently, the number of available platform threads is limited to the number of OS threads.

Platform threads typically have a large thread stack and other resources that are maintained by the operating system. They are suitable for running all types of tasks but may be a limited resource.

  • プラットフォームスレッドとは、JavaでOSスレッドを利用できるようにラップしたものです
  • 1つのプラットフォームスレッドは1つのOSスレッドのみを使用します
    • したがって、スレッドの数だけ生成することが可能です
  • すべての種類の作業を実行するのに適していますが、リソースが制限される可能性があります
    • OSはスレッドごとに大きなスタックメモリを使用するため、多数のスレッドを生成するとシステムリソースが急速に消費されます

image.png

  • 図のように1対1でマッピングされるため、OSのスレッド数に応じて使用することができ、
  • OSスレッドを使用するため、生成数が制限され、維持コストが高くなります

Virtual Thread

image.png

以下の部分で、さらに詳しい説明をご確認いただけます。
追加で図を用意し、私のコードの状況を説明しようと思います。

image.png

以下のように、JVMメモリを使用して数十、数百、数千、さらには数万のバーチャルスレッドを生成し、ロジックを処理することが可能です。

image.png

マウントを行い、実行する際に、前述の私のコードのように

@Container(scope = ContainerScope.SINGLETON)
public class AsyncTestProcessor implements PipeProcessor<String> {
    private final TestTracker tracker;

    public AsyncTestProcessor(TestTracker tracker) {
        this.tracker = tracker;
    }

    @Override
    public ProcessResult<String> transform(String input) throws Exception {
        tracker.track("Async Start: " + input);
        Thread.sleep(1000); 
        tracker.track("Async End: " + input);
        return new ProcessResult<>(input + "-async");
    }
}

次のようにブロッキングが発生するたびに

image.png

image.png

実行中のタスクがキャリアスレッドからアンマウント(unmount)され、

image.png

別のバーチャルスレッドのリクエストを処理します。

image.png

その間にタスクが完了すると、上記のプロセスを繰り返しながらタスクを処理します。

まとめると以下のようになります。

  • Virtual ThreadsはOSスレッドに束縛されず、非常に軽量で大量に生成可能です

    • バーチャルスレッドはJVMの独自スケジューラーによって管理され、OSスレッドではなくプラットフォームスレッドの上で動作します。これにより、スレッドの生成と管理が大幅に効率化され、多数のスレッドを同時に生成・運用することができます
  • バーチャルスレッドはJVMメモリを活用してメモリを効率的に使用します

    • バーチャルスレッドのスタックはヒープに格納されており、メモリ効率を向上させます。また、スタックフレームの再利用やガベージコレクターの対象となることで、メモリ割り当てを削減することが可能です

注意点

  • バーチャルスレッドがsynchronizedブロック内で作業を行うと、OSスレッドがブロックされる可能性があります

    • バーチャルスレッドがsynchronizedブロックやメソッド内で実行されると、スレッドはピン(pin)状態になり、キャリアOSスレッドに固定されます。この状態でブロッキング操作を行うと、バーチャルスレッドとキャリアOSスレッドの両方がブロックされ、OSスレッドのリソースを効率的に活用できなくなり、アプリケーションの拡張性を妨げる可能性があります
  • バーチャルスレッドのスタックはヒープメモリで管理されており効率的ですが、あまりに深い呼び出しスタックはStackOverflowErrorを引き起こす可能性があります

  • CPU依存のスレッドが存在する場合、バーチャルスレッドは効果的でない可能性があります

  • バーチャルスレッドとネイティブコードの相互作用において、予期しない動作が発生する可能性があります

当然のことですが、OS(CPU関連)を介したアクセスや関連API、およびJVM上で動作する際には、StackOverflowErrorへの注意が求められます。

これらのケースを除けば、非常に効果的な方法であると考えられます。

さらに詳しい内容については、次回の記事で取り上げる予定です。

リファレンス

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?