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



## はじめに

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

---

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

## 機能紹介
```java
 @Pipe(
            value = AsyncTestProcessor.class,
            phase = PipelinePhase.PRE_EXECUTE,
            async = true
    )
    public String process7(String input) {
        return input;
    }
```
現在、pipeという機能を開発中です。

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

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


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/ee80caed-da4d-0d78-8382-9ff6b1de1bd3.png)


```java
@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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/e59936cb-04e1-39a7-9b15-8fb8ffa4ffa2.png)

条件は以下の通りです。

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


## コアコード


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/c1e9c8f2-1f5b-6673-38c1-8a9fb73a6322.png)

```java
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

```java
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/777bef98-8e62-04f9-ef38-36c1a20ace08.png)

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

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

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

```java
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`氏の公式が目に留まりました
```java
int optimalThreadPoolSize = numberOfCores * targetUtilization * (1 + waitTime / computeTime);
```
このような公式を見つけることができましたが、サービス時間を正確に測定するのが難しいため、この公式を適用しませんでした。

https://backendhance.com/en/blog/2023/optimal-thread-pool-size/


### 結果

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/b95b9fa7-5526-e8cc-7388-dadf69915a3b.png)

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/796b323e-e4c6-c562-12ae-b447dc786b59.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/490effe8-a349-9dd6-d00f-716c6ac37615.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/6fe1a74f-f04e-2326-7a00-19e81d550d99.png)
メモリ使用率は380.59MB〜477.23MBでしたが

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/4a814352-ade0-534d-e94c-c04cda97ea29.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/b78999ea-8f45-2680-4c87-18f113036a50.png)

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

---

## VirtualThread

```java
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/6bc664df-8383-eb0d-b4eb-c586f0c35b95.png)

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


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/f233f7d6-c0f7-fc1d-6f3d-9042e3f9e44c.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/e199b2d1-d215-1f4b-c72d-8221eeab1a23.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処理ではなくアプリケーション側の処理であったため、速度が改善されたと推測することができました。


https://openjdk.org/jeps/444


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

### カーネルスレッド（プラットフォームスレッド）

基本的な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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/3d450f5d-a9fd-cd95-ad07-cf24c0b2a2d1.png)

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


https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-2BCFC2DD-7D84-4B0C-9222-97F9C7C6C521


### Virtual Thread

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/aa0530d5-5deb-16cb-39a0-3fd61b6a1fa4.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/deee2928-89d8-ea6b-8098-27cc11aefe3b.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/7a7e0227-446f-5001-a88b-2cc41c6bb47a.png)

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

```java
@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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/ed2a9bec-84d7-d3b1-52fa-06685f8e8449.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/24e85762-1efc-cacc-984c-69cb3c757c84.png)

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/d624d9cc-f250-2395-9af5-070feeb5f0cd.png)

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


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3815412/03269c58-a097-649b-3f9c-b1f867bc62af.png)

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

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

- Virtual ThreadsはOSスレッドに束縛されず、非常に軽量で大量に生成可能です
    - バーチャルスレッドはJVMの独自スケジューラーによって管理され、OSスレッドではなくプラットフォームスレッドの上で動作します。これにより、スレッドの生成と管理が大幅に効率化され、多数のスレッドを同時に生成・運用することができます

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


## 注意点
- バーチャルスレッドが`synchronized`ブロック内で作業を行うと、OSスレッドがブロックされる可能性があります
    - バーチャルスレッドが`synchronized`ブロックやメソッド内で実行されると、スレッドはピン（pin）状態になり、キャリアOSスレッドに固定されます。この状態でブロッキング操作を行うと、バーチャルスレッドとキャリアOSスレッドの両方がブロックされ、OSスレッドのリソースを効率的に活用できなくなり、アプリケーションの拡張性を妨げる可能性があります

- バーチャルスレッドのスタックはヒープメモリで管理されており効率的ですが、あまりに深い呼び出しスタックは`StackOverflowError`を引き起こす可能性があります

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

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

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

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

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



## リファレンス

https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-DC4306FC-D6C1-4BCC-AECE-48C32C1A8DAA

https://techblog.woowahan.com/15398/

https://openjdk.org/jeps/444

https://d2.naver.com/helloworld/1203723

https://findstar.pe.kr/2023/04/17/java-virtual-threads-1/

https://tech.kakao.com/posts/608

https://medium.com/@dharmvirt27/java-virtual-threads-and-journey-beyond-9ad3fc686367