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

気まま研究所ブログ

思ったことをてきとーに書きます。

後置インクリメントより前置インクリメントのほうが速いという都市伝説

f:id:AonaSuzutsuki:20170123201212p:plain

お久しぶりです。
最近気になったことがあったのでちょっと調べてみました。

C/C++では前置インクリメントと後置インクリメントでどちらが速いか、ということがよく議論されています。
Javaでも一種の都市伝説的な扱いを受けている印象がありますが、あまり詳しく言及している記事が見つからなかったので今回ちょっと検証してみることにしました。


検証環境

  1. Macbook Air Early 2015
    OS: Windows 10 Pro x64
    CPU: Core i7 5650U
    Mem: 8GB


前置・後置インクリメントとは

この話を始める前に前置インクリメントと後置インクリメントについて軽く触れておきます。
インクリメント自体は変数値を1加算する演算子で、変数に++をつけることで利用できます。
その中の前置インクリメントと後置インクリメントはC/C++と同様に加算後の値を渡すか加算前の値を渡すかの違いです。

int n = 0;
int _n = 0;
_n = ++n;
System.out.println(String.format("渡される値 _n: %d", _n));
System.out.println(String.format("加算する値  n: %d", n));
n = 0;
_n = 0;
_n = n++;
System.out.println(String.format("渡される値 _n: %d", _n));
System.out.println(String.format("加算する値  n: %d", n));
渡される値 _n: 1
加算する値  n: 1
渡される値 _n: 0
加算する値  n: 1

上のコードの結果では渡される値が前置インクリメントで1となり、後置インクリメントで0となります。
加算されるn自体は最終的に1となりますが、渡される値で異なる結果が得られます。

で、ここの違いで、後置インクリメントは事前に加算前の値を確保するための変数確保とコピーによるコストがかかり、前置インクリメントの場合はその必要がなく、高速である。というのがよくある話でしょうか。
あまり詳しく知らないんですが、C/C++では前置インクリメントのほうが速い場合が多いようですね。
ではJavaの場合はどうなんでしょうか。


バイトコードで確認してみる

理屈を並べるより確認するほうが早いです、実際に検証してみましょう。
まずはただのインクリメントから確認します。
なお、出力結果は今回大して意味が無いので割愛します。

int n = 0;
n++;
++n;
System.out.println(n);
 0: iconst_0
 1: istore_1
 2: iinc          1, 1
 5: iinc          1, 1
 8: getstatic     #16                 // Field java/lang/System.out:Ljava/io/PrintStream;
11: iload_1
12: invokevirtual #22                 // Method java/io/PrintStream.println:(I)V
15: return

該当部分を抜粋します。

 2: iinc          1, 1               // 前置インクリメント
 5: iinc          1, 1               // 後置インクリメント

あれ?どっちも同じだぞ。
実はJavaではこのようなただインクリメントするだけの場合、前置インクリメントでも後置インクリメントでも出力されるバイトコードは同じなんです。
試しにこれをEclipseで逆コンパイルすると以下のようになります。

byte n = 0;
int arg1 = n + 1;
++arg1;
System.out.println(arg1);

ちょっと元のコードとは違いますが、結果的には同じです。
つまり、Javaにおいて単純にインクリメントする場合は前置インクリメントだろうと後置インクリメントだろうと変わりません。


ループの場合

よく問題になるのがループです。
単純にインクリメントするくらいではあまり大きな差にはなりません(そもそも同じなのだけど)が、ループとなると量が増えるのでその分差が大きくなってしまいます。
そこでループについても検証してみましょう。

まずは代表的なforループでやってみます。
なお、謎の1加算などは実際の処理みたいなことをやりたかっただけなので特に深い意味はないです。

int n = 0;
for (int i = 0; i < 100000; ++i) {
    n = i + 1;
}
System.out.println(n);

n = 0;
for (int i = 0; i < 100000; i++) {
    n = i + 1;
}
System.out.println(n);

該当部分だけ抜粋します。
ますは前置インクリメント側から。

 2: iconst_0
 3: istore_2
 4: goto          14
 7: iload_2
 8: iconst_1
 9: iadd
10: istore_1
11: iinc          2, 1
14: iload_2
15: ldc           #16                 // int 100000

次に後置インクリメント。

29: iconst_0
30: istore_2
31: goto          41
34: iload_2
35: iconst_1
36: iadd
37: istore_1
38: iinc          2, 1
41: iload_2
42: ldc           #16                 // int 100000

こちらも全く同じですね。
実は、forループでも前置インクリメントでも後置インクリメントでも同じコードが出力されます。
どちらかと言えば前置インクリメントの結果でしょうか。
つまり、どちらを使っても結果だけが必要となるfor文では前置インクリメントと同じコードが出力される事になります。

じゃあwhile文はどうでしょうか。

int i = 0;
while (i < 10) {
    System.out.println(i);
    i++;
}

i = 0;
while (i < 10) {
    System.out.println(i);
    ++i;
}

まずは前置インクリメントから。

 0: iconst_0
 1: istore_1
 2: goto          15
 5: getstatic     #16                 // Field java/lang/System.out:Ljava/io/PrintStream;
 8: iload_1
 9: invokevirtual #22                 // Method java/io/PrintStream.println:(I)V
12: iinc          1, 1
15: iload_1
16: bipush        10

次に後置インクリメント。

21: iconst_0
22: istore_1
23: goto          36
26: getstatic     #16                 // Field java/lang/System.out:Ljava/io/PrintStream;
29: iload_1
30: invokevirtual #22                 // Method java/io/PrintStream.println:(I)V
33: iinc          1, 1
36: iload_1
37: bipush        10

こちらも同じですね。
こちらも同様の理由で、単純にインクリメントするだけなら出力されるコードは全く同じになります。


whileとfor

上の項目に該当する補足なんですが、ちょっと長くなるので分けます。
実は、forとwhileでは、出力されるバイトコードとしては全く同じコードが出力されます。

for (int i = 0; i < 10; ++i) {
    System.out.println(i);
}
int i = 0;
while (i < 10) {
    System.out.println(i);
    i++;
}

for文の場合

 0: iconst_0
 1: istore_1
 2: goto          15
 5: getstatic     #16                 // Field java/lang/System.out:Ljava/io/PrintStream;
 8: iload_1
 9: invokevirtual #22                 // Method java/io/PrintStream.println:(I)V
12: iinc          1, 1
15: iload_1
16: bipush        10

while文の場合

21: iconst_0
22: istore_1
23: goto          36
26: getstatic     #16                 // Field java/lang/System.out:Ljava/io/PrintStream;
29: iload_1
30: invokevirtual #22                 // Method java/io/PrintStream.println:(I)V
33: iinc          1, 1
36: iload_1
37: bipush        10

全く同じですね。
Javaのソースとしては違いますが、内部では結局同じ処理がされています。
なので、whileに変えてもforに変えても大して違いはないです。


きちんとインクリメントの値を利用する場合

インクリメントの値をきちんと利用する場合はその差が出てきます。

public class increment {
    public static void main(String[] args) {
        int n = 0;
        int i = 0;
        while (i < 10000) {
            n = ++i;
            test(n);
        }

        n = 0;
        i = 0;
        while (i < 10000) {
            n = i++;
            test(n);
        }
    }
    static void test(int value) {
    }
}

whileの中だけを見ると前置インクリメントと後置インクリメントできちんと使い分けをしています。
testメソッドは空ですが、そこまではコンパイラに見られないようなので放置します。
この場合、出力されるコードは少し異なります。

まずは前置インクリメントから。

 8: goto          20
11: iinc          2, 1
14: iload_2
15: istore_1
16: iload_1
17: invokestatic  #22                 // Method test:(I)V
20: iload_2
21: sipush        10000

次に、後置インクリメント。

41: goto          53
44: iload_2
45: iinc          2, 1
48: istore_1
49: iload_1
50: invokestatic  #22                 // Method test:(I)V
53: iload_2
54: sipush        10000

前置インクリメントと後置インクリメントでインクリメント命令とスタックへ変数を入れる命令が逆になっています。
前置インクリメントでは11, 14がその行で、前置インクリメントでは先に加算をし、その後スタックへと突っ込みます。
対して、後置インクリメントでは先にスタックへ突っ込み、その後インクリメントしています。
その他は同じです。

この違いは正に前置インクリメントと後置インクリメントの機能差であるのですが、変数生成コストなどは対して無いように思えます。
結局どちらも同じ命令が成されるのでそこで速度差が出るように思えません。
ならどちらも同じ速度なのか・・・というとそういうわけでもないようです。

計測コードは以下のようなもので、インクリメント部分だけを変更して5回計測の平均を取る作業を2回行いました。
順番によって結果が変わるようで、同一な条件のもと行います。

public class increment {
    public static void main(String[] args) {
        int n = 0;
        int i = 0;
        long t1 = System.nanoTime();
        while (i < 10000) {
            n = i++; // n = ++i;
            test(n);
        }
        long t2 = System.nanoTime();

        System.out.println((t2 - t1));
    }
    static void test(int value) {
    }
}

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

- 計測1 計測2 計測3 計測4 計測5 平均
++i 336,989ns 296,029ns 295,098ns 426,822ns 332,334ns 337,454ns
i++ 308,597ns 292,306ns 284,858ns 285,324ns 303,942ns 295,005ns

面白いことに後置インクリメントのほうがおよそ42,449nsだけ速いんですね。

これは闇夜のC++さんの記事で言及されているパイプラインストールが原因であると考えています。
前置インクリメント VS 後置インクリメント | 闇夜のC++
パイプラインストールとはパイプラインが止まることで、処理を行うのに前提条件がクリアできていないとそこで処理が止まることを言います。
例えば、a = b + cみたいな命令があるとすると、bとcが確定するまでaは確定できません。
この時、bとcが確定するまでパイプラインはストールします。
インクリメントでは、前置インクリメントは加算が先におこなわれ、その後に結果が渡されます。
この時、インクリメント処理でストールするとその後の処理が行なえません。
逆に後置インクリメントでは加算処理の前に結果が得られるため、別の処理をそのまま行うことができます。
そのため、前置インクリメントよりも後置インクリメントのほうが速くなったと考えられます。

ただ、前置インクリメントと後置インクリメントでは返される値がそもそも異なるため、こんな使い方するのかという話になって、かなり限定的な例になりそうです。
こんな違いがあるというくらいで大丈夫でしょうか。


終わりに

以上がJavaにおける前置インクリメントと後置インクリメントの違いでした。
結論、ただただインクリメントするだけならば生成されるコードが同じなのでどちらを使っても一緒です。
私も前置インクリメントのほうが速いと信じて使ってましたが、どちらも一緒と知って少し驚きました。
書きやすい、好きな方を使うといいと思います。
パイプラインストールについてはあまり自信がないので誤りがありましたらご指摘していただけると幸いです。