Flight Recorder でメモリー リークを調査し、Sequence を使って改善する

よく、素朴に List を返す関数を書いてメモリー リーク[^leak]が発生することがあります。これを Kotlin の Seqeunce に書き換えて改善する方法を紹介します。

[^leak]: 厳密にはメモリー リークではない気がしますが、そう呼ばれます。

性能測定に Flight Recorder を使います。JDK 17 で実行しました。


# メモリー リーク発生

次の例を考えます。

```CartesianProduct1.kt
import java.io.OutputStream
import java.io.PrintStream

object CartesianProduct1 {
    private val list1 = (1..300).toList()
    private val list2 = (1..300).toList()
    private val list3 = (1..300).toList()

    private fun <T> cartesianProduct(list1: List<T>, list2: List<T>, list3: List<T>): List<List<T>> {
        return list1.flatMap { elem1 ->
            list2.flatMap { elem2 ->
                list3.map { elem3 ->
                    listOf(elem1, elem2, elem3)
                }
            }
        }
    }

    @JvmStatic
    fun main(vararg args: String) {
        for (product in cartesianProduct(list1, list2, list3)) {
            System.setOut(PrintStream(OutputStream.nullOutputStream()))
            println(product)
        }
    }
}
```

`System.setOut(PrintStream(OutputStream.nullOutputStream()))` は、`println` での大量の出力を抑制するために使っています。

以下のVM オプションを付けて実行すると、プロファイルを取ることができます[^jdk8]。
```
-XX:StartFlightRecording=filename=CartesianProduct1.jfr,settings=profile
````

[^jdk8]: JDK 8 では `-XX:+UnlockCommercialFeatures` も必要です。

JDK Mission Control (ここでは Azul Mission Control) に読み込ませ、アウトラインから「メモリー」を選択肢、グラフの「メモリー使用状況」をクリックすると、次のような情報が表示されました。

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/782342/d3160d81-2240-8723-77bf-cfd5fddfae00.png)

最大で 2 GiB のメモリーを消費し、その後は最低でも 1.5 GiB 程度のオブジェクトがコミットされていることがわかります。「スレッド」を選択すると開始時刻と終了時刻が表示されるのですが、そこから計算すると実行時間は40秒ほどだったようです。

`char[]` や `byte[]` の割当量が多いですが、`println` のためのすぐに開放可能な一時的なメモリー使用なので気にしなくて大丈夫そうです。`ArrayList` の割当量が 1.12 GiB で、処理の流れを見てもリストがメモリーを使いそうですし、量的にもグラフの最低ラインの使用量に近く、疑わしいところです。詳しい調査が必要な場合はヒープ ダンプを取りたいところですが、あたりをつけるには十分な情報が得られました。

「最大ライブ・サイズ」などの列が空欄になっていますが、これは ウィンドウ > フライト記録テンプレート・マネージャ で `$JAVA_HOME\lib\jfr\profile.jfc` をロードして複製し、Memory Profiling を All に編集し、エクスポートして `settings=edited.jfc` のようにすると出力されるようです。今回は設定しませんでしたが、開発環境でのメモリー リークの調査では設定したほうが良さそうですね。


# Sequence で改善

`.asSequence` で Sequence にしても良いのですが、使える場面が多い `sequence` と `yield` を使った改善例を紹介します。

```CartesianProduct2.kt
import java.io.OutputStream
import java.io.PrintStream

object CartesianProduct2 {
    private val list1 = (1..300).toList()
    private val list2 = (1..300).toList()
    private val list3 = (1..300).toList()

    private fun <T> cartesianProduct(list1: List<T>, list2: List<T>, list3: List<T>): Sequence<List<T>> {
        return sequence {
            list1.flatMap { elem1 ->
                list2.flatMap { elem2 ->
                    list3.map { elem3 ->
                        yield(listOf(elem1, elem2, elem3))
                    }
                }
            }
        }
    }

    @JvmStatic
    fun main(vararg args: String) {
        for (product in cartesianProduct(list1, list2, list3)) {
            System.setOut(PrintStream(OutputStream.nullOutputStream()))
            println(product)
        }
    }
}
```

同様に JDK Mission Control にプロファイルを読み込ませると、次のような情報を得ることができました。

![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/782342/148d6ab3-0da7-0002-6332-0d3ed1497b49.png)

最大で 868 GiB のメモリーを消費し、その後は最低ラインがじわじわ上昇し、多いときには最低で 280 MiB 程度がコミットされていることがわかります[^jvm]。実行時間は33秒ほどだったようです。

[^jvm]: 最低ラインは、JVM 内部 > ガベージ・コレクション の Heap Post GC からも確認できます。

最低ラインがじわじわ上昇していることからメモリー リークが完全には解消されていないことがわかりますが、こんなに簡単な修正でここまで改善するなら許容範囲ではないでしょうか。2次元目までのリストの構築分でメモリーを消費しているものと思われます。`flatMap` や `map` を `for` に直すか、`.asSequence()` で書き換えたところ完全に解消されたので、この例ではそれらへの書き換えがおすすめです。


# おまけ: Guava で解消

Guava による Cartesian product の実装も使ってみました。Set を返しますが、Set のインターフェースを通して返しているだけで Set の中身はメモリ上に展開されないのでメモリー リークが起きないという、すごい実装です。

```CartesianProduct3.kt
import com.google.common.collect.Sets.cartesianProduct
import java.io.OutputStream
import java.io.PrintStream

object CartesianProduct3 {
    private val list1 = (1..300).toSet()
    private val list2 = (1..300).toSet()
    private val list3 = (1..300).toSet()

    @JvmStatic
    fun main(vararg args: String) {
        for (product in cartesianProduct(list1, list2, list3)) {
            System.setOut(PrintStream(OutputStream.nullOutputStream()))
            println(product)
        }
    }
}
```

同様に JDK Mission Control にプロファイルを読み込ませると、次のような情報を得ることができました。

![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/782342/281ed7fa-93fe-81ef-ccf3-74bf6a97cef0.png)

左下のプロパティー欄を見ると最大で 846 MiB のメモリーを消費していますが、グラフを見る限り 512 MiB 程度までに収まっているようです。最低ラインは 5.17 MiB 程度にとどまっています。完全にメモリー リークが解消されたことがわかります。実行時間は32秒ほどでした。