※本記事は、Ben Evans による “Behind the scenes: How do lambda expressions really work in Java?” を翻訳したものです。
バイトコードを調べて、 Java でラムダ式がどのように処理されているかを確認する
September 25, 2020
Javaコードの内部、そしてJVMの内部で、ラムダ式はどのように見えるのでしょうか。何らかの種類の値であることは間違いありませんが、Javaではプリミティブタイプとオブジェクト参照という2種類の値しか許可されていません。ラムダ式がプリミティブタイプではないのは明らかであるため、オブジェクト参照を返す何らかの式であるに違いありません。
例を見てみます。
public class LambdaExample {
private static final String HELLO = "Hello World!";
public static void main(String[] args) throws Exception {
Runnable r = () -> System.out.println(HELLO);
Thread t = new Thread(r);
t.start();
t.join();
}
}
インナー・クラスに精通しているプログラマーなら、ラムダ式は実際のところ、Runnableの匿名実装を表すシンタックス・シュガーにすぎないと考えるかもしれません。しかし、先ほどのクラスをコンパイルしても、生成されるのはLambdaExample.classという1つのファイルだけです。インナー・クラスに対応する、その他のクラス・ファイルはありません。
つまり、ラムダ式はインナー・クラスではなく、別の仕組みであるはずです。実際、javap -c -pでバイトコードを逆コンパイルしてみると、2つのことがわかります。その1つは、次に示すように、ラムダ式の本体がプライベートな静的メソッドにコンパイルされ、メイン・クラスに含まれていることです。
private static void lambda$main$0();
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #9 // String Hello World!
5: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
このプライベートな本体メソッドと先ほどのラムダ式は、同じシグネチャを持つのではと思われるかもしれません。実はそのとおりです。次のようなラムダ式があるとします。
public class StringFunction {
public static final Function<String, Integer> fn = s -> s.length();
}
このラムダ式からは、次のような本体メソッドが生成されます。文字列を受け取って整数を返すので、関数型インタフェース・メソッドのシグネチャに一致します。
private static java.lang.Integer lambda$static$0(java.lang.String);
Code:
0: aload_0
1: invokevirtual #2 // Method java/lang/String.length:()I
4: invokestatic #3 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
7: areturn
このバイトコードに関して注目すべきもう1つの点は、mainメソッドが次に示すような構造になっていることです。
public static void main(java.lang.String[]) throws java.lang.Exception;
Code:
0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: new #3 // class java/lang/Thread
9: dup
10: aload_1
11: invokespecial #4 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
14: astore_2
15: aload_2
16: invokevirtual #5 // Method java/lang/Thread.start:()V
19: aload_2
20: invokevirtual #6 // Method java/lang/Thread.join:()V
23: return
このバイトコードがinvokedynamic呼出しで始まっている点に注目してください。この命令コードは、バージョン7でJavaに追加されたものです(そして、これまでにJVMバイトコードに追加された唯一の命令コードです)。メソッド呼出しについては、「ASMによるバイトコード処理の実際」と「Javaでのinvokedynamicによるメソッド呼出しを理解する」という記事で説明しました。いずれも本記事を読むうえで参考となるものです。
このコードのinvokedynamic呼出しを一番簡単に理解する方法は、特殊な形式のファクトリ・メソッドを呼び出していると考えることです。このメソッド呼出しでは、Runnableを実装した何らかの型のインスタンスを返します。バイトコードで厳密な型は指定されていませんが、その点はまったく問題とはなりません。
実際の型はコンパイル時には存在せず、実行時にオンデマンドで作成されます。もう少しわかりやすく説明するために、この機能を実現している3つの仕組みについて説明することにしましょう。その仕組みとは、コール・サイト、メソッド・ハンドル、ブートストラップの3つで、これらの仕組みが連携し合って動作しています。
コール・サイト
バイトコードの中でメソッド呼出しが起こる場所のことを、コール・サイト(呼出し場所)と呼びます。
従来、Javaのバイトコードには各種の状況に応じてメソッド呼出しを扱う4つの命令コードがありました。その4つとは、静的メソッド、「通常の」呼出し(メソッドのオーバーライドを伴う可能性がある仮想呼出し)、インタフェースのルックアップ、「特殊な」呼出し(スーパークラスの呼出しやプライベート・メソッドなど、オーバーライドの解決が必要ない場合)です。
動的な呼出しであるinvokedynamicは、前述の4つよりもはるかに進化した機能で、実際にどのメソッドを呼び出すかをプログラマーがコール・サイトごとに判断する仕組みが提供されています。
その際、Javaヒープでは、invokedynamicのコール・サイトをCallSiteオブジェクトとして表現します。これは奇妙なことではありません。Javaでは、Java 1.1以降、Method、さらにはClassなどの型を使用して、Reflection APIで同様のことが行われてきました。Javaは実行時に多くの動的な振る舞いをします。そのため、現在のJavaが実行時の型情報(コール・サイトなど)をモデリングしていると知っても、何ら驚くことはないはずです。
invokedynamic命令に到達すると、JVMではその命令に対応するコール・サイト・オブジェクトを探します(初めて到達したコール・サイトの場合は、JVMでコール・サイト・オブジェクトが新規作成されます)。コール・サイト・オブジェクトには、メソッド・ハンドルが含まれています。メソッド・ハンドルは、実際に呼び出すメソッドを表すオブジェクトです。
このコール・サイト・オブジェクトはいわば必要なレベルの回り道で、関連付けられた呼出しターゲット(つまり、メソッド・ハンドル)を時間によって変更できるようにする仕組みです。
CallSiteは抽象クラスですが、利用できるサブクラスが3つあります。そのサブクラスが、ConstantCallSite、MutableCallSite、VolatileCallSiteです。ベース・クラスにはパッケージ・プライベートなコンストラクタしかありませんが、3つのサブタイプにはパブリックなコンストラクタがあります。つまり、ユーザーのコードでCallSiteを直接サブクラス化することはできませんが、サブタイプをサブクラス化することはできます。たとえばJRuby言語では、実装の一部にinvokedynamicを使っており、MutableCallSiteをサブクラス化しています。
注:invokedynamicのコール・サイトの中には、事実上、遅延計算されるだけというものもあります。その場合、ターゲット・メソッドは一度実行されると、その後に変更されることはありません。これはConstantCallSiteの非常によくあるユースケースで、そこにラムダ式も含まれています。
つまり、定数でないコール・サイトは、プログラムの生存期間を通して、多数の異なるメソッド・ハンドルがターゲットとして含まれる可能性があります。
メソッド・ハンドル
リフレクションは、実行時に特殊な操作を行う際の強力なテクニックですが、そこには設計的欠陥が多数あります(もちろん、後からであれば何とでも言えます)。そして、間違いなく老朽化しています。リフレクションが抱える大きな問題の1つがパフォーマンスです。特に、Just-In-Time(JIT)コンパイラがリフレクションの呼出しをインライン化するのは困難です。
これが不都合なのは、インライン化がJITコンパイルにとってさまざまな面で非常に重要だからです。その理由の中でも大きいのが、一般的にインライン化は最初に適用される最適化であり、他のテクニック(エスケープ分析やデッド・コードの削除など)につながる扉を開くものであるためです。
2つ目の問題は、Method.invoke()のコール・サイトが登場するたびに、リフレクションの呼出しがリンクされることです。つまり、たとえばセキュリティ・アクセス・チェックが行われるということです。これは非常に無駄です。通常、このようなチェックは最初の呼出しで成功または失敗するもので、一度成功すればプログラムの生存期間を通して成功し続けるからです。それでも、リフレクションではこのリンク処理が何度も繰り返されます。そのため、リフレクションでは再リンクによる多くの不要なコストがかかり、CPUタイムが無駄に消費されます。
以上のような(そしてその他の)問題を解決するため、Java 7で新しいAPI、java.lang.invokeが導入されました。導入された主要なクラスの名前から、通常はメソッド・ハンドルと呼ばれています。
メソッド・ハンドルは、Java版のタイプ保証された関数ポインタです。メソッド・ハンドルは、JavaのリフレクションのMethodオブジェクトと同じように、コードで呼び出される可能性があるメソッドを参照する方法です。メソッド・ハンドルにはinvoke()メソッドがあり、実際にはこのメソッドがリフレクションと同じ方法で下層のメソッドを実行しています。
ある意味、メソッド・ハンドルは実際のところ、効率がよくなった、低レベルのリフレクションの仕組みでしかありません。Reflection APIのオブジェクトで表現されるものはすべて、等価なメソッド・ハンドルに変換することができます。たとえば、リフレクションのMethodオブジェクトは、Lookup.unreflect()を使ってメソッド・ハンドルに変換することができます。作成したメソッド・ハンドルを使用すると、大抵の場合、下層のメソッドにアクセスする際の効率が向上します。
メソッド・ハンドルに対してMethodHandlesクラスのヘルパー・メソッドを使うと、コンポジションやメソッド引数の部分バインディング(カリー化)などの方法によって、さまざまな適応処理を施すことができます。
通常、メソッドをリンクするには、型記述子が厳密に一致する必要があります。しかし、メソッド・ハンドルのinvoke()メソッドにはポリモーフィックで特殊なシグネチャが存在し、呼び出されているメソッドのシグネチャによらず、リンクを続行できるようになっています。
実行時、invoke()コール・サイトのシグネチャは、参照先のメソッドを直接呼び出しているように見えるため、型変換やオートボクシングのコストは回避されます。このようなコストは、リフレクションの呼出しでは一般的です。
Javaは静的に型付けされる言語であるため、このように根本的に動的な仕組みが使われる場合に、どの程度のタイプ保証が維持可能であるのかという疑問が生じます。メソッド・ハンドルのAPIでは、MethodTypeと呼ばれる型を使ってこれに対処しています。この型は、メソッドが受け取る引数、すなわちメソッドのシグネチャの不変表現です。
メソッド・ハンドルの内部実装は、Java 8の時代に変更されました。新しい実装はラムダ形式と呼ばれています。ラムダ形式により、パフォーマンスが劇的に向上しました。今では多くのユースケースで、メソッド・ハンドルのパフォーマンスがリフレクションのパフォーマンスを上回っています。
ブートストラップ
バイトコードの命令ストリームの中で、あるinvokedynamicコール・サイトが初めて登場した場合、JVMではターゲットとするメソッドを認識していません。実は、その命令には何のコール・サイト・オブジェクトも関連付けられていません。
コール・サイトには、ブートストラップを行う必要があります。JVMでブートストラップ・メソッド(BSM)を実行し、コール・サイト・オブジェクトを生成してそのオブジェクトを返すというのが、ブートストラップの処理です。
それぞれのinvokedynamicのコール・サイトに、BSMが関連付けられています。BSMはクラス・ファイル内の専用領域に格納されます。ユーザーのコードにおいて実行時にプログラムによってリンクを決定できるのは、このメソッドのおかげです。
最初のRunnableの例を含め、invokedynamic呼出しを逆コンパイルすると、次のパターンが含まれていることがわかります
0: invokedynamic #2, 0
そして、クラス・ファイルの定数プールのエントリ#2が、CONSTANT_InvokeDynamic型の定数であることに注目してください。定数プールの関連部分は、次のとおりです。
#2 = InvokeDynamic #0:#31
...
#31 = NameAndType #46:#47 // run:()Ljava/lang/Runnable;
#46 = Utf8 run
#47 = Utf8 ()Ljava/lang/Runnable;
手がかりは定数に0が存在することです。定数プールのエントリは1から始まるので、0であることにより、実際のBSMがクラス・ファイル内の別の部分に存在すると気づきます。
ラムダ式では、NameAndTypeエントリが特殊な形式になっています。名前は任意ですが、型シグネチャには有用な情報が含まれています。
戻りタイプはinvokedynamicファクトリの戻りタイプに一致し、ラムダ式のターゲット型となります。同様に、引数リストも、ラムダ式が受け取る要素の型で構成されます。ステートレスなラムダ式の場合、戻りタイプは常に空になります。引数が存在するのは、Javaクロージャのみです。
BSMでは少なくとも3つの引数を受け取り、CallSiteを返します。標準の引数の型は次のとおりです。
MethodHandles.Lookup:コール・サイトが存在するクラスのルックアップ・オブジェクトString:NameAndType で示されている名前MethodType: NameAndType の型記述子を解決したもの
BSMで必要とされる追加の引数が、上記の引数に続きます。ドキュメントで、この追加の引数は追加静的引数と呼ばれています。
一般的なBSMでは非常に柔軟な仕組みが可能であり、非Java言語の実装者はこの仕組みを使用しています。しかし、Java言語では、任意のinvokedynamicコール・サイトを作成する言語レベルの構造は提供されていません。
ラムダ式のBSMは特殊な形式になっています。この仕組みがどのように動作するかを完全に理解するために、さらに細かく説明しましょう。
ラムダ式のブートストラップ・メソッドの解読
ブートストラップ・メソッドを確認するためには、javapで-v引数を使用します。これが必要なのは、ブートストラップ・メソッドがクラス・ファイル内の特殊な部分に存在しており、メイン定数プールを逆方向に参照しているからです。今回の単純なRunnableの例では、BootstrapMethodsセクションに1つのブートストラップ・メソッドが存在します。
BootstrapMethods:
0: #28 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:
(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;
Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;
Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#29 ()V
#30 REF_invokeStatic LambdaExample.lambda$main$0:()V
#29 ()V
少し読みにくいですが、解読していきましょう。
このコール・サイトのブートストラップ・メソッドは、定数プールのエントリ#28です。このエントリはMethodHandle型(Java 7で標準に追加された定数プールの型)です。それでは、次に示す、先ほどのStringFunctionの場合と比較してみましょう。
0: #27 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:
(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;
Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;
Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#28 (Ljava/lang/Object;)Ljava/lang/Object;
#29 REF_invokeStatic StringFunction.lambda$static$0:(Ljava/lang/String;)Ljava/lang/Integer;
#30 (Ljava/lang/String;)Ljava/lang/Integer;
BSMとして使われているメソッド・ハンドルは、同じ静的メソッドLambdaMetafactory.metafactory( … )です。
違っている部分はメソッドの引数です。この引数はラムダ式用の追加静的引数で、全部で3つあります。この追加静的引数が表しているのは、ラムダ式のシグネチャと、実際にラムダ式の最終的な呼出しターゲットとなるラムダ式本体へのメソッド・ハンドルです。3番目の静的引数は、型消去された形式のシグネチャです。
次は、java.lang.invokeのコードをたどってみましょう。そして、プラットフォームがメタファクトリを使って、ラムダ式のターゲット型を実際に実装したクラスを動的に生成する方法を確認してみます。
ラムダ式のメタファクトリ
BSMではこの静的メソッドを呼び出します。この静的メソッドでは、最終的にコール・サイト・オブジェクトを返します。invokedynamic命令が実行されると、コール・サイトに含まれるメソッド・ハンドルでは、ラムダ式のターゲット型を実装したクラスのインスタンスを返します。
メタファクトリ・メソッドのソース・コードは、比較的単純です。
public static CallSite metafactory(MethodHandles.Lookup caller,
String invokedName,
MethodType invokedType,
MethodType samMethodType,
MethodHandle implMethod,
MethodType instantiatedMethodType)
throws LambdaConversionException {
AbstractValidatingLambdaMetafactory mf;
mf = new InnerClassLambdaMetafactory(caller, invokedType,
invokedName, samMethodType,
implMethod, instantiatedMethodType,
false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
mf.validateMetafactoryArgs();
return mf.buildCallSite();
}
ルックアップ・オブジェクトは、invokedynamic命令が存在するコンテキストに一致します。この場合、そのコンテキストはラムダ式が定義されたクラスと同じクラスであるため、ルックアップ・コンテキストはラムダ式本体がコンパイルされたプライベート・メソッドにアクセスできる適切なパーミッションを持つことになります。
呼び出される名前と型を提供するのはVMですが、これは実装の詳細です。最後の3つのパラメータは、BSMの追加静的引数です。
現在のメタファクトリの実装では、シェードされた、ASMバイトコード・ライブラリの内部用コピーを使用するコードに処理を委譲することによって、ターゲット型を実装したインナー・クラスを生成しています。
ラムダ式がその包含スコープから何のパラメータも取得していない場合、結果として生成されるオブジェクトはステートレスです。そのため、実装では事前に1つのインスタンスを計算しておくことで最適化を行います。この場合、ラムダ式の実装クラスは実質的にシングルトンになります。
jshell> Function<String, Integer> makeFn() {
...> return s -> s.length();
...> }
| created method makeFn()
jshell> var f1 = makeFn();
f1 ==> $Lambda$27/0x0000000800b8f440@533ddba
jshell> var f2 = makeFn();
f2 ==> $Lambda$27/0x0000000800b8f440@533ddba
jshell> var f3 = makeFn();
f3 ==> $Lambda$27/0x0000000800b8f440@533ddba
ドキュメントにおいて、Javaプログラマーはラムダ式の識別セマンティックをいかなる形でも使用すべきではないと強調されていますが、その理由の1つはこの点にあります。
まとめ
本記事では、JVMでラムダ式のサポートが正確にはどのように実装されているかについて、かなり細かく説明しました。この機能は、今後目にするプラットフォーム機能の中でも特に複雑と言えるものの1つです。言語実装者の領域に深く踏み込んでいるからです。
説明の中で、invokedynamicとメソッド・ハンドルAPIについて触れました。この2つは、最新のJVMプラットフォームの主要な部分を占める重要なテクニックです。どちらの仕組みも、エコシステム全体で使用が増加しています。たとえば、invokedynamicは、Java 9以降において新しい形態での文字列連結の実装に使われています。
こういった機能を理解することで、Javaアプリケーションが依存するプラットフォームや最新フレームワークのもっとも内側の動作を見通す重要な手がかりが得られます。
