Javaのinterfaceの遅さについて考える。

ふと、「Javaのinterfaceのメソッド起動は遅いかもしれない」と思って調べてみました。

まずは動作テスト

メソッド起動よりループの方が遅いのか、普通にループさせてテストしても差がでませんでした。
なので、下記のようなテストコードを書いて検証。
JVMは1.6.0_27でテストしました。

interface IFoo { void foo(); }
abstract class AbstractFoo { public abstract void foo(); }
class Foo extends AbstractFoo implements IFoo { @Override public void foo() {} }
public class VirtualVSInterface {
	public static void main(String[] args) {
		// ↓自作ライブラリ。見たまんまなので、説明略。
		PerformanceMeter.init(System.out);

		Foo foo = new Foo();
		for (int time = 0; time < 10; time++) {
			PerformanceMeter.start();
			for (int i = 0; i < 30000; i++) {
				callFoo3000Times(foo);
			}
			PerformanceMeter.end();
		}
		PerformanceMeter.show();
	}

	static void callFoo3000Times(Foo foo) { // ← ここを書き換える
		foo.foo();
		foo.foo();
		foo.foo();
		foo.foo();
		foo.foo();
		// :
		// :
		// こんな感じで3000回呼び出す。
	}
}

callFoo3000Timesメソッド内で3千回呼び出して、それを3万回ループさせて、合計9千万回のメソッド呼び出しを行います。

これの結果は↓

[PerformanceMeter][****] Elapsed time is 1227.436899(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1288.487153(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1267.502598(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1265.594484(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1250.509644(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1267.367435(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1269.642116(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1252.13631(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1257.907276(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1356.383693(ms), Used memory is 0.0(KB).
[PerformanceMeter][Summary]
    [****]: 12702.967608(ms), performed 10 times, average 1270.29676(ms)

10回テストして、平均が1.27秒くらい。
次に、callFoo3000Timesメソッドのパラメタを↓のようにinterfaceに書き換えます。

	static void callFoo3000Times(IFoo foo) { // ← ここを書き換える

これの実行した結果が↓

[PerformanceMeter][****] Elapsed time is 1383.983558(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1486.349774(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1415.176375(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1406.891197(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1406.291089(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1436.857707(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1406.008785(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1407.861295(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1403.233229(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1425.40517(ms), Used memory is 0.0(KB).
[PerformanceMeter][Summary]
    [****]: 14178.058179(ms), performed 10 times, average 1417.805817(ms)

こちらは平均約1.42秒くらい。
やっぱり、interfaceとして起動した方が遅くなりますね・・・。
ついでに抽象クラスとしての起動も試してみます。

	static void callFoo3000Times(AbstractFoo foo) { // ← ここを書き換える

[PerformanceMeter][****] Elapsed time is 1232.08464(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1246.014175(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1252.963544(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1259.100222(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1251.726542(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1284.862976(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1332.044005(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1263.652151(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1246.69983(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 1250.21408(ms), Used memory is 0.0(KB).
[PerformanceMeter][Summary]
    [****]: 12619.362165(ms), performed 10 times, average 1261.936216(ms)

平均約1.26秒くらい。
通常の呼び出しとの有意な差はないみたいです。
思ったとおり、遅くなるのはinterfaceのときだけですね。

何故interfaceが遅いと思ったのか?

Javaのメソッドの構造は、基本下記のようになっているそうです。

Javaでメソッドを実装すると、↑の図のように各クラスが一つずつ持っている、関数テーブルに登録されます。
親クラスの関数テーブルの内容は、子クラスの関数テーブルに全てコピーされます。
子クラスでメソッドをオーバーライドした場合には、該当するメソッドが登録されている箇所が上書きされます。
つまり、上の図で言うとSubClass1はmethodBarをオーバーライドしているのでインデックス1が、SubClass2はmethodFooをオーバーライドしているのでインデックス0が、それぞれ上書きされます。


下記コードを例に考えると、

    public static void main(String[] args) {
        SubClass1 sub1 = new SubClass1();
        SubClass2 sub2 = new SubClass2();

        callAsSuperClass(sc1);
        callAsSuperClass(sc2);
    }
    
    void callAsSuperClass(SuperClass sc) {
        sc.methodFoo();  // (1)
        sc.methodBar();  // (2)
    }

(1)は「インスタンスのクラスの保持する関数テーブルのインデックス0を起動する」、(2)は「インスタンスのクラスの保持する関数テーブルのインデックス1を起動する」という意味になります。
インスタンスがSubClass1のときには、(1)ではSubClass1の関数テーブルのインデックス0が、(2)では同テーブルのインデックス1が、それぞれ起動されます。
この場合、インデックス0のmethodFooはコピーされたままオーバーライドされていないので、実際にはSuperClassのmethodFooと同じものが起動されます。
SubClass2の場合も同様に、(1)ではSubClass2でオーバーライドされたmethodFooが、(2)ではSuperClassのmethodBarが起動されます。
Javaのような静的型付言語では、基本的にはこのようにしてポリモーフィズム(多態性)を実現しています。
abstractメソッドは関数テーブルのインデックスを特定のシグニチャのメソッドのために予約した状態と考えることができますし、

   super.methodFoo();

のようなコードは、親クラスの関数テーブルのメソッドを呼び出していると考えることができます。


・・・と、ここまではクラスが一つしか親を持てない、単一継承だけについて考えてきました。
しかし、多重継承についても考え出ると、途端に話がややこしくなります。
Javaにも制限付きながら、「interface」という多重継承が存在しています。
では、上の図にinterfaceを足してみます。

FooInterfaceとBarInterfaceとで、別のメソッドをそれぞれインデックス0に割り当てています。
もしこの図のとおりだとすると、下記のコードの(3)では関数テーブルのインデックス0番を起動しようとしますが、そこに登録されているのは実際にはmethodFooです。

    public static void main(String[] args) {
        SubClass1 sub1 = new SubClass1();
        SubClass2 sub2 = new SubClass2();

        callAsSuperClass(sub1);
        callAsSuperClass(sub2);
    }
    
    void callAsSuperClass(BarInterface bar) {
        bar.methodBar();  // (3)
    }

このように、interfaceは複数implements可能なので、↑で説明したような単純な関数テーブルだけでは実現できません。


「じゃあ、BarInterfaceはメソッド一つだけど、領域を二つ分とってインデックス1にmethodBarを登録して、methodFooとかぶらないようにすれば?」と思うかもしれないですが、これもダメです。
何故なら、Javaでは一つのクラスがimplementsできるinterface数には特に上限はないですし、interfaceも多数のクラスに実装されることがあります。
さらにjava.sql.Connectionのように、約50ものメソッドを持っているinterfaceも存在しています。
これら全ての整合性を取るのは大変ですし、関数テーブルの肥大化も招いてしまうため、現実的ではないでしょう。
と言うわけで、javaのinterfaceはメソッドを起動する為になんらかの対策をとっているはずで、その分通常のクラスと比べてメソッド起動が遅くなるのでは?と考えたわけです。

アセンブルしてみる

実際のメソッド起動の様子を見れるかもしれないと思って、↓のようなコードを書いて、逆アセンブルしてみました。

interface IFuga{ void fuga(); }
abstract class AbstractFuga { public abstract void fuga();}
class Fuga extends AbstractFuga implements IFuga{
	@Override public void fuga() {System.out.println("fugafuga.");}
}

public class Sample {
	public static void main(String[] args) {
		Sample sample = new Sample();
		Fuga fuga = new Fuga();
		sample.callAsInterface(fuga);
		sample.callAsAbstract(fuga);
		sample.callAsMethod(fuga);
	}
	private void callAsInterface(IFuga fuga){
		fuga.fuga();
	}
	private void callAsAbstract(AbstractFuga fuga){
		fuga.fuga();
	}
	private void callAsMethod(Fuga fuga){
		fuga.fuga();
	}
}

アセンブル用のコマンドは↓。

javap -c -private Sample

得られたバイトコードは、↓のようになりました。

Compiled from "Sample.java"
public class tetz42.sample.Sample extends java.lang.Object{
public tetz42.sample.Sample();
  Code:
   0:	aload_0
   1:	invokespecial	#8; //Method java/lang/Object."<init>":()V
   4:	return

public static void main(java.lang.String[]);
  Code:
   0:	new	#1; //class tetz42/sample/Sample
   3:	dup
   4:	invokespecial	#16; //Method "<init>":()V
   7:	astore_1
   8:	new	#17; //class tetz42/sample/Fuga
   11:	dup
   12:	invokespecial	#19; //Method tetz42/sample/Fuga."<init>":()V
   15:	astore_2
   16:	aload_1
   17:	aload_2
   18:	invokespecial	#20; //Method callAsInterface:(Ltetz42/sample/IFuga;)V
   21:	aload_1
   22:	aload_2
   23:	invokespecial	#24; //Method callAsAbstract:(Ltetz42/sample/AbstractFuga;)V
   26:	aload_1
   27:	aload_2
   28:	invokespecial	#28; //Method callAsMethod:(Ltetz42/sample/Fuga;)V
   31:	return

private void callAsInterface(tetz42.sample.IFuga);
  Code:
   0:	aload_1
   1:	invokeinterface	#37,  1; //InterfaceMethod tetz42/sample/IFuga.fuga:()V
   6:	return

private void callAsAbstract(tetz42.sample.AbstractFuga);
  Code:
   0:	aload_1
   1:	invokevirtual	#42; //Method tetz42/sample/AbstractFuga.fuga:()V
   4:	return

private void callAsMethod(tetz42.sample.Fuga);
  Code:
   0:	aload_1
   1:	invokevirtual	#46; //Method tetz42/sample/Fuga.fuga:()V
   4:	return
}

通常のメソッドの起動にはinvokevirtual、そしてinterfaceでのメソッド起動ではinvokeinterfaceというのが使われています。
どうやら、このinvokeinterfaceが遅くなった原因のようです。
 ※ 記事のテーマとは無関係ですが、コンストラクタやprivateメソッドの起動はinvokespecialです。多態性が必要ないとき向けはこれみたいです。

invokeinterfaceについて調べてみる

invokeinterfaceの動作が気になったので、ググってみました。で、英語ですが↓の仕様書が見つかりました。
invokeinterface
一応読んでみたのですが・・・、「クラスCに該当するメソッドが見つかったらそれを起動。見つからなかったら親クラスを再帰的にたどっていく。」みたいに書かれてました。
うーん、流石にそんな風に動作するとは思えない・・・。もし本当にそんなつくりだったら、もっと遅いと思うし・・・。
これは「Javaの仕様としては、こうだよ。」という話で、実際の実装とは違う話なんだと思います。


もう少し探してみたところ、下記を発見しました。
Java の invokevirtual 命令と invokeinterface 命令の違い
なるほど、クラス側でimplementsされたinterfaceごとに変換テーブルを持っておくってことか。
先ほどの図に追加すると、下記みたいな感じかな?

↓のコードを例に考えると、

    public static void main(String[] args) {
        SubClass1 sub1 = new SubClass1();
        SubClass2 sub2 = new SubClass2();

        callAsSuperClass(sub1);
        callAsSuperClass(sub2);
    }
    
    void callAsSuperClass(BarInterface bar) {
        bar.methodBar();  // (3)
    }

 ・(3)では、BarInterfaceなので、BarInterface用変換テーブルが選ばる。
 ・methodBarはインデックス1番に変換されて、これを元に関数テーブルのmethodBarを見つけて起動。
という感じですね。
interfaceと対応するテーブルを見つける手段は、ハッシュを使ったり、最近起動したinterfaceをキャッシュして再利用したりして高速化するそうです。
ということは、interfaceを激しく切り替えるようなコードを書けば、遅くなるはずですね。
試してみます。

最初のコードを↓のように、異なるinterfaceを順番に起動するように書き換えました。

interface IFoo { void foo(); }
interface IFooFoo { void foo(); }
interface IFooFooFoo { void foo(); }
abstract class AbstractFoo { public abstract void foo(); }
class Foo extends AbstractFoo implements IFoo, IFooFoo, IFooFooFoo {
	@Override public void foo() {} 
}
public class VirtualVSInterface {
	public static void main(String[] args) {
		// ↓自作ライブラリ。見たまんまなので、説明略。
		PerformanceMeter.init(System.out);

		Foo foo = new Foo();
		for (int time = 0; time < 10; time++) {
			PerformanceMeter.start();
			for (int i = 0; i < 30000; i++) {
				callFoo3000Times(foo, foo, foo);
			}
			PerformanceMeter.end();
		}
		PerformanceMeter.show();
	}

	static void callFoo3000Times(IFoo foo, IFooFoo foofoo, IFooFoo foofoofoo) { // ← ここを書き換える
		foo.foo();
		foofoo.foo();
		foofoofoo.foo();
		foo.foo();
		foofoo.foo();
		foofoofoo.foo();
		// :
		// :
		// これを3000回
	}
}

この状態で実行すると・・・

[PerformanceMeter][****] Elapsed time is 2114.41286(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2166.698675(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2118.031035(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2129.420224(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2254.794114(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2258.398601(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2140.098524(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2138.503943(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2306.046959(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2151.361103(ms), Used memory is 0.0(KB).
[PerformanceMeter][Summary]
[****]: 21777.766038(ms), performed 10 times, average 2177.776603(ms)

おお、確かに遅くなった!
平均2.18秒くらいです。
念のため、↓のパラメタ全てが同一のinterfaceとなるパターンも試してみます。

	static void callFoo3000Times(IFoo foo, IFoo foofoo, IFoo foofoofoo) { // ← ここを書き換える

[PerformanceMeter][****] Elapsed time is 2309.425604(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2361.475312(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2322.6211(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2324.873959(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2342.970822(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2336.956926(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2317.427165(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2312.028347(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2319.148782(ms), Used memory is 0.0(KB).
[PerformanceMeter][****] Elapsed time is 2327.235032(ms), Used memory is 0.0(KB).
[PerformanceMeter][Summary]
[****]: 23274.163049(ms), performed 10 times, average 2327.416304(ms)

あれ!?
平均2.33秒くらい?
別interfaceで起動する例に比べると、キャッシュが効く分早いかと思ったのですが・・・。


試しにjavaの起動オプションに「-server」とか「-Xint」とか足してみて何回かテストしたのですが、どの条件でも別interface経由で起動するほうが早い、という結論になってしまいました。
javapでの逆アセンブルも試してみたのですが、特筆するような結果は得られなかったので、割愛します。


それにしても最初の事例に比べると、随分遅いですね・・・。
interfaceを多数implementsしたときの影響は、思っていたより大きいようです。

まとめ

Javaは単一継承が基本の言語であり、多重継承が可能となるinterfaceは特殊な存在であり、バイトコード上も特別な処理がされています。
そのため、通常のクラスに比べると少々メソッド起動が遅いです。
 ※とはいえ、その差は微々たるものなので、通常の用途では気にする必要はありません。


また、多数のinterfaceをimplementsするとより遅くなってしまうような結果が今回出ましたが、充分な実験&考察ができていません。
この件に関しては、また機会があったら試して記事にしてみたいと思います。