Autoboxing/Unboxing

先日、幾つかの言語でパフォーマンスを測定して比較しているサイトを見る機会があったのですが、Javaのサンプルコードが悪意に満ちていた為、検証してみました。まずは結果です。
試行回数100(単位ナノ秒

int 2,040
Integer 22,630

試行回数100万(単位ミリ秒)

int 10
Integer 217

要はAutoboxing/Unboxingを最悪のケースで適用したパターンなのですが、これでパフォーマンスを測定するサンプルとするには疑問どころじゃありません。ある程度は理解していない言語で特にパフォーマンス関連で下手な事は書いちゃダメですね。
と、いうことでAutoboxing/Unboxingで暗黙の変換が行われると、状況によっては酷いパフォーマンス劣化が起きる事は有名(な筈)ですが、あまり数値が出ているサンプルがないので検証してみました。

intとInteger

Javaには歴史的経緯から整数にintとIntegerという2つの型があります。intはプリミティブ型と呼ばれ、Javaの中ではオブジェクトではない特殊な型です。一方、Integerはオブジェクト型でintのラッパークラスと呼ばれます。全ての整数をオブジェクトとして扱わなかった点については賛否*1があるわけですが、パフォーマンスと使いやすさを考慮して、非オブジェクトであるintなどが定義されていた経緯が有ります。
このようにJavaでは整数に2つの型がある特殊な状況ですが、Java5以前は特にプログラマが意識して使い分ける必要がありました。通常はプリミティブ型のintを使用しますが、Listに追加するなどオブジェクト型が求められる場合はラッパークラスを使用するのです。この時、intからIntegerへの変換とその逆変換が必要であり、冗長で面倒なコーディングを強いられます。

// 1.4
int num = 10;
// int => Integer
Integer number = new Integer(num);
// Integer => int
int n = number.intValue();

Java5ではGenericsの導入など様々な仕様拡張が行われました。その中で地味ながら重要な機能にAutoboxingがあります。Autoboxingは、一言で言えばプリミティブ型とオブジェクトラッパー型を暗黙的に切り替える機能です。プログラマがコードを書く時に面倒な変換をしないで済むような機能です。

// 5.0
int num = 10;
// Autoboxing
Integer number = num;
// Unboxing
int n = number;

このようにintとIntegerのように対応するラッパーとプリミティブが存在する場合、暗黙的に変換されます。しかし、仕組みを知らないと恥ずかしいコードを書くだけでなく、パフォーマンス的に問題のあるコードを書くことになります。

パフォーマンス検証(1)

それでは、単純にn回のループを行うコードで検証しましょう。次のような単純なループを実行させ、100回の平均を取りました。尚、実行環境はMacOS X,JDK1.6です。

int max = 100;
long start = System.nanoTime();
int count = 0;
while (count < max) {
  count ++;
}
System.out.println(System. nanoTime() - start);

単純に100回のループですが、結果は2040ナノ秒になります。

次に少しだけコードを修正して実行します。

int max = 100;
long start = System.nanoTime();
Integer count = 0;
while (count < max) {
  count ++;
}
System.out.println(System. nanoTime() - start);

結果は22,630ナノ秒でした。尚、System.nanoTime()の精度にはバラツキがありますし、毎回の実行時間もナノ秒単位ではバラバラです。ただ、結果のオーダーが1桁違うのは見て取れると思います。

Autoboxing/Unboxingによるコスト

2つのサンプルコードの差は1行しかありません。countがオブジェクト型かプリミティブ型かの違いです。ところが、実行性能に10倍近い開きが出ています。これはcountの宣言がIntegerである為、無駄な変換が行われているからです。まず、「count < max」の箇所では評価式になっている為、左辺と右辺の両方がプリミティブと解釈されます。また、++演算子もプリミティブ型にしか適用できません。したがって、実際に動くコードは次のようになります。

Integer count = 0;
while (count.intValue() < max) {
  count = new Integer(count.intValue() + 1);
}

1.5より追加されたメソッドがあるので、正確には次のコードになります。

Integer count = 0;
while (count.intValue() < max) {
  count = Integer.valueOf(count.intValue() + 1);
}

このようなコードが動いている為、実行性能に10倍近い差が生じる訳です。とはいえ、ナノ秒のオーダーでの話ですから実害があるケースは少ないでしょう。これにはInteger#valueOfにトリックがあるのですが、後述します。

パフォーマンス測定(2)

続けてループ回数を100万回にして検証します(単位はミリ秒に変更)。

int max = 10000000;
long start = System.currentTimeMillis();
int count = 0;
while (count < max) {
  count ++;
}
System.out.println(System.currentTimeMillis() - start);

おおよそ10ミリ秒でした。
同じようにIntegerにして測定してみましょう。おそらく、100ミリ秒程度になる事が予想されます。

int max = 10000000;
long start = System.currentTimeMillis();
Integer count = 0;
while (count < max) {
  count ++;
}
System.out.println(System.currentTimeMillis() - start);

ところが、結果は217ミリ秒と、20倍近い差が生まれています。

Integerのキャッシュ

100までのループと100万までのループの違いはどこで生まれているのでしょうか?
実は言語仕様の中に次のように定められています。

If the value p being boxed is true, false, a byte, a char in the range \u0000 to \u007f, or an int or short number between -128 and 127, then let r1 and r2 be the results of any two boxing conversions of p. It is always the case that r1 == r2.

http://java.sun.com/docs/books/jls/third_edition/html/conversions.html#5.1.7

つまり、intで-128〜127の範囲の場合、同じオブジェクト(r1 == r2)が再利用されるとなっています。

  count = new Integer(count.intValue() + 1);

ではなくて、ファクトリーメソッドであるvalueOfが追加されて採用されている経緯はこの辺りにあります。

  count = Integer.valueOf(count.intValue() + 1);

まとめ

性能が要求される状況ではAutoboxingには充分に注意しましょう。ですが、ちょっとしたアプリケーションで100にも満たないループであれば気にする必要まではありません。

*1:自分は肯定派