1. 序章

KotlinはJavaよりもはるかに表現力があり簡潔ですが、コストはかかりますか? より正確に言うと、JavaではなくKotlinを選択した場合のパフォーマンスの低下はありますか?

ほとんどの場合、KotlinはJavaと同じバイトコードにコンパイルされます。 クラス、関数、関数の引数、およびifforなどの標準のフロー制御演算子は同じように機能します。

ただし、KotlinとJavaには違いがあります。 たとえば、Kotlinにはインライン関数があります。 そのような関数が引数としてラムダをとる場合、バイトコードに実際のラムダはありません。 代わりに、コンパイラは、インライン関数で必要になったときにラムダ内の命令を呼び出すように呼び出しサイトを書き直します。 マップ、フィルター、関連付け、最初、検索、その他多くのコレクション変換関数はすべてインライン関数です。

さらに、Javaのコレクションで機能変換を使用するには、コレクションから Stream を作成し、後でCollectorを使用してそのStreamを収集する必要があります。ターゲットコレクションに。 大規模なコレクションに対して一連の変換を行う必要がある場合、これは理にかなっています。 ただし、短いコレクションを1回マップして結果を取得するだけでよい場合、追加のオブジェクト作成のペナルティは、有用なペイロードに匹敵します。

この記事では、JavaコードとKotlinコードの違いを測定する方法と、この違いの大きさを分析する方法について説明します。

2. Javaマイクロベンチマークハーネス

KotlinはJavaと同じJVMバイトコードにコンパイルされるため、 Java Microbenchmark Harness (JMH)を使用して、JavaコードとKotlinコードの両方のパフォーマンスを分析できます。 プロジェクトを設定するには、単純なGradleベースのプロジェクトを作成し、ニートリトルプラグインを使用してJMHフレームワークに接続します。

いくつかのテストケースを書いてみましょう。 表示される数値は、macOS Monterey12.1およびOpenJDK64ビットサーバーVM、17.0.1+12-39を実行する32GBのRAMを搭載したAppleM1Proラップトップで取得されたものです。 マイレージは、他のシステムやソフトウェアバージョンによって異なる場合があります。

各テスト関数からいくつかの値を返し、それらをBlackholeに配置します。 これにより、JITコンパイラが実行するコードを過度に最適化するのを防ぐことができます。 適切に平均化された結果を得るために、妥当な数のウォームアップ反復を使用します。

@Benchmark
@BenchmarkMode(Mode.Throughput)
@Fork(value = 5, warmups = 5)
@OutputTimeUnit(TimeUnit.MILLISECONDS)

結果とそれに続く結果は、スループットを説明しています。単位時間あたりの繰り返し回数– 数値が大きいほど、操作が速くなります。 時間の単位はテストごとに異なります。単位時間あたりの操作の数が多すぎたり、少なすぎたりしないようにする必要があります。 実験の場合、それは関係ありませんが、妥当なサイズの数で理解して操作する方が簡単です。

3. インライン高階関数

最初のケースは、インライン高階関数です。 この関数は、トランザクションでのアクションの実行を模倣します。トランザクションオブジェクトを作成して開き、引数として渡されたアクションを実行してから、トランザクションをコミットします。

ラムダを処理するJavaの方法には、バイトコードのinvokedynamic命令が含まれます。 この命令では、JITコンパイラが、機能インターフェイスを実装する生成されたクラスに属する呼び出しサイトオブジェクトを作成する必要があります。 これらはすべて舞台裏で行われ、ラムダを作成するだけです。

public static <T> T inTransaction(JavaDatabaseTransaction.Database database, Function<JavaDatabaseTransaction.Database, T> action) {
    var transaction = new JavaDatabaseTransaction.Transaction(UUID.randomUUID());
    try {
        var result = action.apply(database);
        transaction.commit();
        return result;
    } catch (Exception ex) {
        transaction.rollback();
        throw ex;
    }
}

public static String transactedAction(Object obj) throws MalformedURLException {
    var database = new JavaDatabaseTransaction.Database(new URL("http://localhost"), "user:pass");
    return inTransaction(database, d -> UUID.randomUUID() + obj.toString());
}

これを行うKotlinの方法は非常に似ていますが、より簡潔です。 ただし、バイトコードレベルでは、話はまったく異なります inTransaction メソッドは呼び出しサイトにコピーされるだけで、ラムダはまったくありません。

inline fun <T> inTransaction(db: Database, action: (Database) -> T): T {
    val transaction = Transaction(id = UUID.randomUUID())
    try {
        return action(db).also { transaction.commit() }
    } catch (ex: Exception) {
        transaction.rollback()
        throw ex
    }
}

fun transactedAction(arg: Any): String {
    val database = Database(URL("http://localhost"), "user:pass")
    return inTransaction(database) { UUID.randomUUID().toString() + arg.toString() }
}

データベースを使用して何かを行う実際のケースでは、ここでの駆動コストはネットワークIOになるため、2つのアプローチの違いはごくわずかです。 しかし、インライン化が多くの違いをもたらすかどうかを見てみましょう。

Benchmark                            Mode  Cnt     Score       Error   Units
KotlinVsJava.inlinedLambdaKotlin     thrpt  25     1433.691 ± 108.326  ops/ms
KotlinVsJava.lambdaJava              thrpt  25      993.428 ±  25.065  ops/ms

結局のところ、そうです! インライン化は、実行時に動的呼び出しサイトを生成するよりも44%効率的であるようです。 つまり、特にクリティカルパス上の高階関数の場合、インラインを検討する必要があります。

4. 機能コレクションの変換

すでに述べたように、Java Stream APIでは、Kotlinコレクションライブラリ関数よりも1つ多くのオブジェクトを作成する必要があります。 これが目立つかどうか見てみましょう。 文字列のモデルリストを@State(Scope.Benchmark)コンテナーに入れて、JITコンパイラーが繰り返しの操作を最適化しないようにします。

Javaの実装は非常に短いです:

public static List<String> transformStringList(List<String> strings) {
    return strings.stream().map(s -> s + System.currentTimeMillis()).collect(Collectors.toList());
}

そして、Kotlinのものはさらに短いです:

fun transformStringList(strings: List<String>) =
    strings.map { it + System.currentTimeMillis() }

currentTimeMillis を使用しているため、関数が呼び出されるたびに各文字列が異なります。 そうすれば、実行するすべてのコードを最適化しないようにJITコンパイラーに指示できます。

その実行の結果は決定的ではありません:

Benchmark                              Mode    Cnt   Score     Error    Units
KotlinVsJava.stringCollectionJava    thrpt   25    1982.486 ± 112.839 ops/ms
KotlinVsJava.stringCollectionKotlin  thrpt   25    1760.223 ± 69.072  ops/ms

Javaは12% fasterのようにさえ見えます。 これは、Kotlinがnull不可能な引数のnullチェックなど、追加の暗黙的なチェックを実行するという事実によって説明できます。これは、バイトコードを見ると表示されます。

  public final static transformStringList(Ljava/util/List;)Ljava/util/List;
  // skip irrelevant stuff
   L0
    ALOAD 0
    LDC "strings"
    INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNullParameter (Ljava/lang/Object;Ljava/lang/String;)V
...

map 関数には、正しいArrayListサイズを確認するための追加のチェックもあります。 私たちの機能では他に多くのことをしていないので、これらの小さなことが現れ始めます。

短いコレクションを処理し、安価な変換を行うと、追加のインスタンス化の効果がより顕著になるはずです。 その場合、Streamオブジェクトの作成が目立ちます。

fun mapSmallCollection() =
    (1..10).map { java.lang.String.valueOf(it) }

そして、Javaバージョンの場合:

public static List<String> transformSmallList() {
    return IntStream.range(1, 10)
      .mapToObj(String::valueOf)
      .collect(Collectors.toList());
}

違いが逆になっていることがわかります。

Benchmark                              Mode    Cnt   Score     Error    Units
KotlinVsJava.smallCollectionJava     thrpt   25    15.135 ± 0.932     ops/us
KotlinVsJava.smallCollectionKotlin   thrpt   25    17.826 ± 0.332     ops/us

一方、どちらの操作も非常に高速であり、実際の製品コードではその違いが重要な要素になる可能性はほとんどありません。

5. Spread演算子を使用した可変引数

Javaでは、可変引数の構成は単なるシンタックスシュガーです。 そのような各引数は実際には配列です。 すでにデータが配列にある場合は、すぐに引数として使用できます。

public static String concatenate(String... pieces) {
    StringBuilder sb = new StringBuilder(pieces.length * 8);
    for(String p : pieces) {
        sb.append(p).append(",");
    }
    return sb.toString();
}

public static String callConcatenate(String[] pieces) {
    return concatenate(pieces);
}

ただし、Kotlinでは、可変引数は特殊なケースです。 データがすでに配列内にある場合は、その配列をスプレッドする必要があります。

fun concatenate(vararg pieces: String): String = pieces.joinToString()

fun callVarargFunction(pieces: Array<out String>) = concatenate(*pieces)

それがパフォーマンスのペナルティを意味するかどうかを見てみましょう。

Benchmark                              Mode    Cnt   Score     Error    Units
KotlinVsJava.varargsJava               thrpt    25    14.653 ± 0.089    ops/us
KotlinVsJava.varargsKotlin             thrpt    25    12.468 ± 0.279    ops/us

実際、Kotlinは約17% fまたは拡散(アレイコピーを含む)のペナルティが課せられます。 これは、コードのパフォーマンスクリティカルセクションでは、vararg引数を使用して関数を呼び出すためにspreadingを使用しない方がよいことを意味します。

6. JavaBeanの変更と データクラスのコピー、初期化が含まれています

Kotlinは、データクラスの概念を導入し、セッターメソッドを介して編集可能な従来のJavaフィールドとは対照的に、不変のvalフィールドの多用を促進します。 データクラスのフィールドを変更する必要がある場合は、 copy メソッドを使用して、変更するフィールドを目的の値に置き換える必要があります。

fun changeField(input: DataClass, newValue: String): DataClass = input.copy(fieldA = newValue)

これにより、新しいオブジェクトがインスタンス化されます。 Javaスタイルの編集可能なフィールドを使用するよりも大幅に時間がかかるかどうかを確認しましょう。 実験では、フィールドが1つだけの最小限のデータクラスを作成します。

data class DataClass(val fieldA: String)

また、可能な限り単純なPOJOを使用します。

public class POJO {
    private String fieldA;

    public POJO(String fieldA) {
        this.fieldA = fieldA;
    }

    public String getFieldA() {
        return fieldA;
    }

    public void setFieldA(String fieldA) {
        this.fieldA = fieldA;
    }
}

JITがテストコードを過度に最適化するのを防ぐために、初期フィールド値と新しい値の両方を@Stateオブジェクトに入れましょう。

@State(Scope.Benchmark)
public static class InputString {
    public String string1 = "ABC";
    public String string2 = "XYZ";
}

次に、オブジェクトを作成して変更し、Blackholeに渡すテストを実行してみましょう。

Benchmark                              Mode    Cnt   Score     Error    Units
KotlinVsJava.changeFieldJava           thrpt   25    337.300 ± 1.263     ops/us

KotlinVsJava.changeFieldKotlin         thrpt   25    351.128 ± 0.910     ops/us

実験では、オブジェクトをコピーすると、そのフィールドを変更するよりも4% fアスターで表示されることが示されています。 実際、さらなる研究では、フィールドの数が増えると、2つのアプローチ間のパフォーマンスの違いが大きくなることが示されています。 より複雑なデータクラスを見てみましょう。

data class DataClass(
    val fieldA: String,
    val fieldB: String,
    val addressLine1: String,
    val addressLine2: String,
    val city: String,
    val age: Int,
    val salary: BigDecimal,
    val currency: Currency,
    val child: InnerDataClass
)

data class InnerDataClass(val fieldA: String, val fieldB: String)

そして、類似のJavaPOJO。 その場合、同様のベンチマークの結果は次のようになります。

Benchmark                        Mode   Cnt    Score     Error   Units
KotlinVsJava.changeFieldJava     thrpt   25    100,503 ± 1,047    ops/us
KotlinVsJava.changeFieldKotlin   thrpt   25    126,282 ± 0,232    ops/us

これは直感に反するように思えるかもしれませんが、1つのフィールドを持つ些細なデータクラスと比較して、このクラスのパフォーマンスは全体でほぼ3倍遅いことに注意してください。 次に、フィールドの変更だけでなく、初期構造をベンチマークに入れたことを思い出してください。 以前のexploredと同様に、 finalキーワードはパフォーマンスに影響を与えることがあり、小さいながらもポジティブなキーワードであることがよくあります。 どうやら、コンストラクターのコストがこのベンチマークを支配しているようです。

7. JavaBeanの変更と データクラスのコピー、初期化は除外

Kotlinのコピーと、 @Stateオブジェクトを使用したJavaの変更を分離する場合:

@State(Scope.Thread)
public static class InputKotlin {
    public DataClass pojo;

    @Setup(Level.Trial)
    public void setup() {
        pojo = new DataClass(
                "ABC",
                "fieldB",
                "Baker st., 221b",
                "Marylebone",
                "London",
                (int) (31 + System.currentTimeMillis() % 17),
                new BigDecimal("30000.23"),
                Currency.getInstance("GBP"),
                new InnerDataClass("a", "b")
        );
    }

    public String string2 = "XYZ";
}

// Proper benchmark annotations
public void changeFieldKotlin_changingOnly(Blackhole blackhole, InputKotlin input) {
    blackhole.consume(DataClassKt.changeField(input.pojo, input.string2));
}

別の写真が表示されます:

Benchmark                                     Mode    Cnt    Score     Error   Units
KotlinVsJava.changeFieldJava_changingOnly     thrpt   25     364,745 ± 2,470    ops/us
KotlinVsJava.changeFieldKotlin_changingOnly   thrpt   25     163,215 ± 1,235    ops/us

したがって、の変更はのコピーより2.23倍高速であるように思われます。 ただし、可変オブジェクトのインスタンス化はかなり遅くなります。不変オブジェクトを2回作成しても、mutationメソッドを使用して可変オブジェクトコンストラクターよりも先に進むことができます。

全体として、不変のアプローチは間違いなく遅くなります。 ただし、コピーは桁違いに遅くなることはありません。 次に、複数のプロパティを変更する必要がある場合は、先に進みます:Koltinデータクラスの場合は、単一の copy()呼び出しのままですが、可変オブジェクトは、いくつかのセッター呼び出しを意味します。 第3に、可変オブジェクトのインスタンス化は不変オブジェクトのインスタンス化よりもコストがかかるため、コード全体によっては、最終スコアがPOJOに有利でない場合があります。 そして最後に、これらのコストはすべてとにかく小さいので、実際のアプリケーションではIOとビジネスロジックによって支配されます。

したがって、不変構造を使用し、セッターを使用する代わりにコピーしても、プログラムのパフォーマンスに大きな影響はないと結論付けることができます。

8. 結論

この記事では、Javaと比較したKotlinのパフォーマンスに関する仮説を確認する方法について説明しました。 ほとんどの場合、予想どおり、KotlinのパフォーマンスはJavaのパフォーマンスに匹敵します。 ラムダのインライン化など、一部の場所ではわずかなゲインがあります。 逆に、 vararg 引数で配列を拡散するなど、他の場合には明らかな損失があります。

すべてがかなり同じであるため、他の関数またはラムダをパラメーターとして受け取るインライン関数が非常に有益であることは明らかです

ただし、重要なのは Kotlinは、Javaとほぼ同じランタイムパフォーマンスを提供します。 その使用は本番環境では問題になりません

また、JMHフレームワークを迅速かつ効率的に使用してコードのパフォーマンスを測定する方法も学びました。 賢明なパフォーマンステストハーネスを使用すると、本番環境での問題が発生する前に予測できます。 ここで重要なことは、多くのことがJMHテストに影響を与える可能性があることであり、すべてのベンチマーク結果は一粒の塩で取得する必要があります。

いつものように、コード例はGitHubのにあります。