Project Loom
先日の Java Developers Summit Online 2023 にて Project Loom に関する講演がありました。
仮装スレッド
という新たな仕組みが導入され、同時性(Concurrency) に関する非同期処理のパフォーマンスが飛躍的に向上するようです。
そこで、過去に「なぜGo言語が人気なのか?」の1つのアンサーとして「軽量スレッドをサポートしている」と聞いたことがあるので、同じアルゴリズムの処理を Go
と Java17
と Java20
で実装し、実行時間を計測してみようと思います。
サンプルアルゴリズム
同時性を評価したいので、独立したタスクということで考えました。
- 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版 をダウンロードして解凍します。
ダウンロードした javac
や java
コマンドを直接使用します。
なお、Java 20
は RC
のため、プレビュー機能を利用する場合、コンパイルや実行時に --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
でプログラミングしていたいなぁと思う検証でした。
ありがとうございました!