【やってみた】Java20の仮想スレッドとGoのゴルーチンの簡単な比較

Project Loom

先日の Java Developers Summit Online 2023 にて Project Loom に関する講演がありました。

仮装スレッド という新たな仕組みが導入され、同時性(Concurrency) に関する非同期処理のパフォーマンスが飛躍的に向上するようです。

そこで、過去に「なぜGo言語が人気なのか?」の1つのアンサーとして「軽量スレッドをサポートしている」と聞いたことがあるので、同じアルゴリズムの処理を GoJava17Java20 で実装し、実行時間を計測してみようと思います。

サンプルアルゴリズム

同時性を評価したいので、独立したタスクということで考えました。

  • 100年間の複利を計算する
  • 元金は都度ランダム(100万円~2000万円)に生成する
  • 年利も都度ランダム(0.001~0.7)に生成する

上記を 1 つのタスクとして、100,000 タスクを生成し、それぞれ非同期に並行実行させます。

  • Java17: new Thread().start() で起動
  • Java20: Thread.startVirtualThread() で起動
  • Go: ゴルーチン で起動

といった感じでやってみましょう!

Java 17 のソースコード

コードはこんな感じです。
簡易的な実験のため Main クラスのみで全て static メソッドにしてます。

import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;

public class Main {

    private static AtomicInteger COUNTER = new AtomicInteger(0);
    private static final int THREAD_COUNT = 100_000;

    public static void main(String[] args) {
        System.out.println("start!");

        for (int i = 0; i < THREAD_COUNT; i++) {
            new Thread(Main::execute).start();
        }

        while (COUNTER.get() < THREAD_COUNT) {
            // wait...
        }

        System.out.println("done!");
    }

    public static void execute() {
        int principal = randomPrincipal();
        double annualInterestRate = randomAnnualInterestRate();
        int years = 100;

        // 複利計算
        calculateCompoundInterest(principal, annualInterestRate, years);

        COUNTER.incrementAndGet();
    }

    public static double calculateCompoundInterest(int principal, double annualInterestRate, int years) {
        int compoundedFrequency = 1;
        int totalPeriods = compoundedFrequency * years;
        double rate = annualInterestRate / compoundedFrequency;
        return principal * Math.pow(1+rate, totalPeriods);
    }

    public static int randomPrincipal() {
        Random random = new Random();
        // 100,000から20,000,000まで
        return random.nextInt((20000000 - 100000) + 1) + 100000;
    }

    public static double randomAnnualInterestRate() {
        Random random = new Random();
        // 0.001から0.7まで
        return 0.001 + (0.7 - 0.001) * random.nextDouble();
    }

}

Go のソースコード

基本的には上記の Java17 のコードを Go に置き換えました。

※筆者は Go 言語に全く知見がないため、chatGPT の力を利用して実装しました笑

package main

import (
    "fmt"
    "math"
    "math/rand"
    "time"
    "sync/atomic"
)

var counter int64
var threadCount int64 = 100_000

func main() {
    fmt.Println("start!")

    var i int64
    for i = 0; i < threadCount; i++ {
        go exexute();
    }

    // wait...
    for true {
        if threadCount <= atomic.LoadInt64(&counter) {
            break
        }
    }

    fmt.Println("done!")
}

func exexute() {
    principal := randomPrincipal()
    annualInterestRate := randomAnnualInterestRate()
    years := 100

    // 複利計算
    calculateCompoundInterest(principal, annualInterestRate, years)

    atomic.AddInt64(&counter, 1)
}

func calculateCompoundInterest(principal int, annualInterestRate float64, years int) float64 {
    compoundedFrequency := 1
    totalPeriods := compoundedFrequency * years
    rate := annualInterestRate / float64(compoundedFrequency)
    result := float64(principal) * math.Pow(1+rate, float64(totalPeriods))
    return result
}

func randomPrincipal() int {
    rand.Seed(time.Now().UnixNano())
    // 100,000から20,000,000まで
    return rand.Intn(20000000-100000+1) + 100000
}

func randomAnnualInterestRate() float64 {
    rand.Seed(time.Now().UnixNano())
    // 0.001から0.7まで
    return rand.Float64()*(0.7-0.001) + 0.001
}

Java 20 のソースコード

そして今回のメインとなる Java20 の仮装スレッドですが、Java17 のコードを一箇所変更するだけで済みます。

for (int i = 0; i < THREAD_COUNT; i++) {
    //new Thread(Main::execute).start();
    // これが Java 20 の仮装スレッド
    Thread.startVirtualThread(Main::execute);
}

既存のソースコードも簡単に仮装スレッドに置き換えることができるかもしれないですね。

サンプルコードの実行

それでは各コードをコンパイルして実行してみましょう!

Java17

Java のバージョンを確認し、コンパイルして実行してみます。
実行時には time コマンドで実行時間を計測します。

% java -version
openjdk version "17.0.6" 2023-01-17
OpenJDK Runtime Environment GraalVM CE 22.3.1 (build 17.0.6+10-jvmci-22.3-b13)
OpenJDK 64-Bit Server VM GraalVM CE 22.3.1 (build 17.0.6+10-jvmci-22.3-b13, mixed mode, sharing)
% javac Main.java 
% time java Main 
start!
done!

real    0m6.543s
user    0m5.863s
sys     0m6.950s
% 

上記の実行時間が基準になりますね!
続いては Go のコードを実行してみましょう。

Go

同じ容量で実行してみます。

% go version
go version go1.20.2 darwin/amd64
% go build main.go 
% time ./main
start!
done!

real    0m2.064s
user    0m4.713s
sys 0m0.969s
%

やはり Java17 のスレッドベースの処理と比較すると実行時間は 50% 以下という感覚でしょうか。

最後に Java20 のコードを実行してみます。

Java20

まずは OpenJDK 20 RC版 をダウンロードして解凍します。

ダウンロードした javacjava コマンドを直接使用します。

なお、Java 20RC のため、プレビュー機能を利用する場合、コンパイルや実行時に --enable-preview --source 20 などと指定する必要があるようです。

% ~/Downloads/jdk-20.jdk/Contents/Home/bin/java --version
openjdk 20 2023-03-21
OpenJDK Runtime Environment (build 20+36-2344)
OpenJDK 64-Bit Server VM (build 20+36-2344, mixed mode, sharing)
% ~/Downloads/jdk-20.jdk/Contents/Home/bin/javac --enable-preview --source 20  Main.java
ノート: Main.javaはJava SE 20のプレビュー機能を使用します。
ノート: 詳細は、-Xlint:previewオプションを指定して再コンパイルしてください。
% time ~/Downloads/jdk-20.jdk/Contents/Home/bin/java --enable-preview Main
start!
done!

real    0m0.204s
user    0m0.435s
sys     0m0.049s
%

!!!!!
速いですね! 1 秒も要していません。恐るべし仮想スレッド...

まとめ

実行時間をまとめると下記表になりました。

Java11 Go Java20
real 6.543s 2.064s 0.204s
user 5.863s 4.713s 0.435s

圧倒的に Java20 の仮想スレッドが速いですね。

もちろん、パフォーマンスは実行時間だけでなく、CPUやメモリの使用率も重要になると思います。

今回はそこまで詳細な検証はできておりませんが、Java20の仮想スレッドが非常に魅力的であることが分かりました。

筆者は Java大好きマン なので、こういった Java がもともと苦手としていた部分が大きく改善されていくのは非常に嬉しいです。

これからも Java でプログラミングしていたいなぁと思う検証でした。

ありがとうございました!