読者です 読者をやめる 読者になる 読者になる

Appresso Engineer Blog

アプレッソのエンジニアが書く技術ブログです。

メソッド参照を使うと無駄なオブジェクトが生成される場合がある(が、通常気にする必要はない)

こんにちは、アプレッソ開発部の野口です。

戦々恐々の一人旅でしたが、どうにか JavaOne 2015 から無事帰国することができました。

間違いました

さて、JavaOneでは 4 日目のセッション「Legacy Lambda Code in Java」に参加しました。

appresso.hatenablog.com

そして、レポート記事に、セッション資料を否定して、以下のような間違いを書いてしまいました。

メソッド参照だと、無駄なオブジェクトが、作られません。
メソッド参照を使った場合、(スコープ内の変数の)キャプチャを行う必要がなく、匿名クラスが呼び出しの都度生成されることもないので、すべての呼び出しのインスタンスは一致するはずです。

同僚に検証してもらったところ、これは(思い込みによる)間違いである(セッション資料に書いてあることが正しい)ことがわかり、また同じく JavaOne 参加者の id:bitter_fox さんに以下のようなコメントをいただきました。

メソッド参照のところですが,::の左側が式の場合はその式を評価して,その値をメソッド呼び出しのレシーバに使うので,毎回評価して新しいオブジェクトを生成する必要があります.なので無駄なオブジェクトがいっぱい作られるって言っていたんです.
(そもそも同じオブジェクトを使うかは最適化によるものなので,式がオブジェクト参照でその参照が過去に作ったメソッド参照のレシーバの参照と同じであるならば,新しいオブジェクトを生成しないといった実装も考えられるかと思います.)
JLSではラムダ式メソッド参照においてどの時に新しいオブジェクトが作られるか,既にあるオブジェクトが使われるかを定義しておらず,JDKの実装に委ねています.なので,ラムダ式メソッド参照の参照が同じかは一般には言えないんですが,あのプレゼンでは一般論かのように言っていたのでよくないですね・・・

というわけで、検証してみました。

メソッド参照を使うと無駄なオブジェクトが生成される問題」

System.out::println とは

前述のセッションでの説明において例として取り上げられていた System.out::println は、

へのメソッド参照です。

そもそも問題は

文字列を受け取って標準出力に出力する Consumer として、以下の 2 種類の書き方が可能です。

ここで、メソッド参照の方では、「この Consumer が参照されるたびに新しいオブジェクトが作られる」(無駄)というのがセッションで指摘されていた問題です。
一方、ラムダ式による記法ではそのようなことはない、という説明でした。

検証

ここでは、「インスタンスフィールドのメソッド」や「static メソッド」についても色々と試してみたいので、適当なクラスを作って動作検証してみることにします。

環境

検証は以下の環境で実施しました。

実施

まず、static メソッドと instance メソッドをそれぞれ持つクラスを用意します。

public class MethodReferences {
    public static void staticMethod(String x) {
        System.out.println(x);
    }

    public void instanceMethod(String x) {
        System.out.println(x);
    }
}

そして、この MethodReferences クラスのメソッドメソッド参照で参照したり、ラムダ式で呼び出したりする Consumer を返すメソッドを別のクラス(MethodReferencesTest)に定義します。
それぞれ 2 回ずつ呼び出して、オブジェクトが一致するか確認します。

import java.util.function.Consumer;

public class MethodReferencesTest {
    public static MethodReferences staticField = new MethodReferences();
    public MethodReferences instanceField = new MethodReferences();

    public static void main(String[] args) throws Exception {
        System.out.println(instanceMethodReferenceOnStaticField() == instanceMethodReferenceOnStaticField());
        MethodReferencesTest m = new MethodReferencesTest();
        System.out.println(instanceMethodReferenceOnInstanceField(m) == instanceMethodReferenceOnInstanceField(m));
        System.out.println(staticMethodReference() == staticMethodReference());
        System.out.println(nonCapturinglambda() == nonCapturinglambda());
        System.out.println(capturingLambda() == capturingLambda());
    }

    public static Consumer<String> instanceMethodReferenceOnStaticField() {
        return staticField::instanceMethod;
    }

    public static Consumer<String> instanceMethodReferenceOnInstanceField(MethodReferencesTest m) {
        return m.instanceField::instanceMethod;
    }

    public static Consumer<String> staticMethodReference() {
        return MethodReferences::staticMethod;
    }

    public static Consumer<String> nonCapturinglambda() {
        return x -> System.out.println(x);
    }

    public static Consumer<String> capturingLambda() {
        String local = "local";
        return x -> System.out.println(x + local);
    }
}

すると、以下のような結果となります。

false
false
true
true
false

考察

上から順に結果について考えていきましょう。

::の左側が式の場合

public static Consumer<String> instanceMethodReferenceOnStaticField() {
    return staticField::instanceMethod;
}

これは、セッションで例として上がっていた System.out::println と同じ、static フィールドに対するインスタンスメソッドへのメソッド参照です。
これは、id:bitter_fox さんが指摘してくださった

::の左側が式の場合はその式を評価して,その値をメソッド呼び出しのレシーバに使うので,毎回評価して新しいオブジェクトを生成する必要があります

に該当します。
このとき、オブジェクトが一致しません。

続いて、

public static Consumer<String> instanceMethodReferenceOnInstanceField(MethodReferencesTest m) {
    return m.instanceField::instanceMethod;
}

についても、同様ですね。

::の左側がクラス名の場合

public static Consumer<String> staticMethodReference() {
    return MethodReferences::staticMethod;
}

については、クラス名による static メソッドの参照です。
この場合、オブジェクトが一致するという結果となりました。

メソッド参照なら必ずオブジェクトが生成されるわけではない、と言えます。

変数をキャプチャしないラムダ式の場合

続いて、

public static Consumer<String> nonCapturinglambda() {
    return x -> System.out.println(x);
}

ですが、これは「変数をキャプチャしないラムダ式」です。
この場合、セッション資料にもあるように、オブジェクトが一致します。

変数をキャプチャするラムダ式の場合

一方、

public static Consumer<String> capturingLambda() {
    String local = "local";
    return x -> System.out.println(x + local);
}

については、ラムダ式ではありますが、オブジェクトが一致しないという結果になりました。
これは、ローカル変数 local をキャプチャするために、その都度新しいオブジェクトを生成する必要があるものと考えられます。*1

これで、セッション資料にないパターンも含め、メソッド参照・ラムダ式を使った場合にオブジェクトが何度も生成されるかどうか、見当がつきました。

が、通常気にする必要はない

ここまで、「メソッド参照やラムダ式の記法によって、複数回参照された場合のオブジェクトが異なるかどうか」の検証を行ってきました。
しかし、そもそもそんなことを気にする必要があるのか……といえば、以下 2 つの理由から、通常気にする必要はない、と言えます。

  • オブジェクトのキャッシュは JDK の実装に依存する
  • オブジェクトが複数生成されるといっても、影響は微々たるもの

オブジェクトのキャッシュは JDK の実装に依存する

これは、id:bitter_fox さんのご指摘の通りで、Java 言語仕様を読む限り*2メソッド参照やラムダ式においてオブジェクトをキャッシュすべきともすべきでないとも書かれていません。

そのため、変数をキャプチャしないラムダ式であっても複数の参照が一致しない JDK の実装はありうるし、逆に、static フィールドに対するインスタンスメソッドへのメソッド参照(たとえば、System.out::printlin)についてキャッシュする(複数の参照が一致する)実装もありえます。
Oracle JDK についても、今後のバージョンで改善が行われる可能性があります。

オブジェクトが複数生成されるといっても、影響は微々たるもの

それはそうと、手元の環境で違いがあるのは事実なので、測ってみました。

それぞれ、Integer.MAX_VALUE(2147483647)回の参照を行うのにかかる時間を計測し、それを 10 回ずつ繰り返しています。*3

コード

import java.util.function.Consumer;

public class MethodReferencesTest {
    public static MethodReferences staticField = new MethodReferences();
    public MethodReferences instanceField = new MethodReferences();

    public static void main(String[] args) throws Exception {
        record("instanceMethodReferenceOnStaticField", () -> {
            for (int i = 0; i < Integer.MAX_VALUE; i++)
                instanceMethodReferenceOnStaticField();
        });
        MethodReferencesTest m = new MethodReferencesTest();
        record("instanceMethodReferenceOnInstanceField", () -> {
            for (int i = 0; i < Integer.MAX_VALUE; i++)
                instanceMethodReferenceOnInstanceField(m);
        });
        record("staticMethodReference", () -> {
            for (int i = 0; i < Integer.MAX_VALUE; i++)
                staticMethodReference();
        });
        record("nonCapturinglambda", () -> {
            for (int i = 0; i < Integer.MAX_VALUE; i++)
                nonCapturinglambda();
        });
        record("capturingLambda", () -> {
            for (int i = 0; i < Integer.MAX_VALUE; i++)
                capturingLambda();
        });
    }

    private static void record(String name, Runnable target) {
        System.out.println(name);
        for (int i = 0; i < 10; i++) {
            long first = System.currentTimeMillis();
            target.run();
            System.out.println(System.currentTimeMillis() - first);
        }
    }

    // あとは同様...
}

結果

instanceMethodReferenceOnStaticField
43
39
37
37
37
37
38
39
37
37
instanceMethodReferenceOnInstanceField
45
40
37
37
37
36
38
37
36
38
staticMethodReference
3
1
0
0
0
0
0
0
0
0
nonCapturinglambda
2
1
0
0
0
0
0
0
0
0
capturingLambda
40
41
37
37
37
36
37
38
38
37

傾向としては、予想通りの結果と言えます。
それぞれ、序盤の実行よりもそれ以降の実行のほうが時間が短くなっているのは、何らかの最適化が作動しているのでしょう。

そして、「オブジェクトが複数生成されるケース」と「そうでないケース」の差は、Integer.MAX_VALUE(2147483647)回繰り返した場合でもわずか 40 ミリ秒前後です。
これも、あくまで環境によって異なると言えますが……。

メソッド参照が悪影響をおよぼすのでは、と考えてメソッド参照を使わない、といった対応は(プロファイラによってボトルネックがそこであるとはっきり示されないかぎり)不要と言えそうです。

まとめ

*1:話をシンプルにするため「必要がある」と言いましたが、local は結局のところ文字列リテラルなので、オブジェクトを使い回すという最適化も可能だと思います。ただ、そこまで積極的な最適化を行うコンパイラはおそらく少ないでしょう

*2:もちろん読み通したわけではなく、関係のありそうなところを拾い読みしただけですが……

*3:ここで計測メソッドに Runnable を渡していますが、このラムダ式の評価は一度しか行われないため、計測への影響は無視できます。