(この記事は 地平線に行く とのマルチポストです)
Java のラムダ式が、どのようにコンパイルされ実行されているか気になりますよね。
そこで、クラスファイルを分析して、その中身を調べてみました。
なお、今回は OpenJDK 17.0.1 を使って調べています。
バージョンによって挙動が異なる場合があるので、ご注意ください。
外側のローカル変数を参照しない場合
おさらい:匿名クラスのコンパイル結果
本題のラムダ式を調べてみる前に、匿名クラスの場合にどのようにコンパイルされているかを確認しておきましょう。
(ご存じの方は、読み飛ばしてもらって大丈夫です)
今回は、Runnable インタフェースを匿名クラスとして実装しました。
public class Main {
public static void main(String[] args) throws InterruptedException {
var thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Hello world!");
}
});
thread.run();
thread.join();
}
}
このクラスをコンパイルすると、2つのクラスファイルが出来上がりました。
Main.class
Main$1.class
後者が匿名クラスのコンパイル結果です。
これを、OpenJDK 付属の javap でリバースアセンブルしてみました。
Classfile /C:/Users/YujiSoftware/Desktop/java/Main$1.class
Last modified 2021/12/18; size 534 bytes
SHA-256 checksum f6f8ec5118c480c54b31ee06db110837e3459fa50f8d052a7d157b32885fd22d
Compiled from "Main.java"
class Main$1 implements java.lang.Runnable
minor version: 0
major version: 61
flags: (0x0020) ACC_SUPER
this_class: #21 // Main$1
super_class: #2 // java/lang/Object
interfaces: 1, fields: 0, methods: 2, attributes: 4
{
Main$1();
descriptor: ()V
flags: (0x0000)
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public void run();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello world!
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 6: 0
line 7: 8
}
この javap の結果を、@YujiSoftware を使って Java のソースコードに変換してみました。 1
class Main$1 implements java.lang.Runnable {
Main$1(){
super();
}
public void run() {
System.out.println("Hello world!");
}
}
「外側のクラス名 + $ + 連番」という名前のクラスです。
クラスの中身は、普通の(匿名ではない)クラスとして実装したのと同じです。
なお、Java 言語仕様に基づいて、匿名コンストラクタが追加されています。
【 Java言語仕様:15.9.5.1 匿名コンストラクタ 】
匿名クラスは、明示的にコンストラクタを宣言することができない。その代わりに、コンパイラは匿名クラスに対して匿名コンストラクタ(anonymous constructor)を自動的に提供しなければならない。
ラムダ式のコンパイル結果
続いて、ラムダ式の場合を見ていきましょう。
先ほどの匿名クラスを使って書いた部分を、ラムダ式に置き換えました。
public class Main {
public static void main(String[] args) throws InterruptedException {
var thread = new Thread(() -> System.out.println("Hello world!"));
thread.run();
thread.join();
}
}
このクラスをコンパイルすると、1つのクラスファイルが出来上がりました。
Main.class
匿名クラスの時のように、コンパイル時にはクラスファイルは作られませんでした。
代わりに、 実行時(より詳しく言えば、ラムダ式を含んだメソッドを初めて実行する際) に、メモリ上にクラスファイルが作られました。
メモリ上…、調べるのがとてもめんどくさいですね。
でも、簡単な方法があります。
システムプロパティ jdk.internal.lambda.dumpProxyClasses
でディレクトリを指定しておくと、その作られたクラスファイルを出力してくれるのです。
今回は、以下のように Java コマンドの引数でシステムプロパティを指定しました。
java -Djdk.internal.lambda.dumpProxyClasses=output Main
こうして実行すると、output ディレクトリにクラスファイルが出来上がりました。
Main$$Lambda$1.class
このクラスファイルを javap して、Java のソースコードに変換してみました。
Main$$Lambda$1.class の javap 結果
Classfile /C:/Users/YujiSoftware/Desktop/java/Main$$Lambda$1.class
Last modified 2021/12/25; size 227 bytes
SHA-256 checksum b1184aa81051852c052de612ab4b9f272f87cfd655717abb39dfa8ee74d8c5f4
final class Main$$Lambda$1 implements java.lang.Runnable
minor version: 0
major version: 59
flags: (0x1030) ACC_FINAL, ACC_SUPER, ACC_SYNTHETIC
this_class: #2 // Main$$Lambda$1
super_class: #4 // java/lang/Object
interfaces: 1, fields: 0, methods: 2, attributes: 0
{
private Main$$Lambda$1();
descriptor: ()V
flags: (0x0002) ACC_PRIVATE
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #10 // Method java/lang/Object."<init>":()V
4: return
public void run();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=0, locals=1, args_size=1
0: invokestatic #16 // Method Main.lambda$main$0:()V
3: return
}
final class Main$$Lambda$1 implements java.lang.Runnable {
private Main$$Lambda$1() {
}
public void run() {
Main.lambda$main$0();
}
}
ラムダ式の中身である System.out.println("Hello world!");
がありません。
代わりに、 Main
クラスの lambda$main$0()
という static
メソッドを呼び出しています。
このメソッドは何者でしょうか。
それを確認するために、Main.class
を、 javap してみました。
すると、このクラス内に lambda$main$0()
というメソッドが含まれていることが分かりました。
private static void lambda$main$0();
descriptor: ()V
flags: (0x100a) ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #21 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #27 // String Hello world!
5: invokevirtual #29 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
private static void lambda$main$0() {
System.out.println("Hello world!");
}
どういうことかというと…
- コンパイル時に、 ラムダ式の中身が
static
メソッドに変換され、 コンパイルされる - 実行時に、 インタフェース(今回は
Runnable
)の実装クラスが作られ、 コンパイルされる- このクラスは、事前に作られた
static
メソッドを呼び出すだけ
- このクラスは、事前に作られた
匿名クラスとは、処理が大きく異なりますね。
外側のローカル変数を参照する場合
続いて、匿名クラスやラムダ式で その外側にあるローカル変数を参照した場合、 どのようなクラスファイルが出来上がるのかを見ていきましょう。
おさらい:匿名クラスのコンパイル結果
今回は、 main
メソッド内にあるmessage
というローカル変数を匿名クラス内で参照した場合に、どのようなクラスファイルになるかを見ていきます。
public class Main {
public static void main(String[] args) throws InterruptedException {
var message = "Hello world!";
var thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(message);
}
});
thread.run();
thread.join();
}
}
このクラスをコンパイルすると、先ほどと同様に2つのクラスファイルが出来上がりました。
Main.class
Main$1.class
後者のクラスファイルを、javap して、Java のソースコードに変換してみました。
Main$1.class の javap 結果
Classfile /C:/Users/YujiSoftware/Desktop/java/Main$1.class
Last modified 2021/12/25; size 603 bytes
SHA-256 checksum 4b78e2e34c543f25184362a99d8b506dbe2fd4c46e7a662e245e6b96b8908c95
Compiled from "Main.java"
class Main$1 implements java.lang.Runnable
minor version: 0
major version: 61
flags: (0x0020) ACC_SUPER
this_class: #2 // Main$1
super_class: #8 // java/lang/Object
interfaces: 1, fields: 1, methods: 2, attributes: 4
{
final java.lang.String val$message;
descriptor: Ljava/lang/String;
flags: (0x1010) ACC_FINAL, ACC_SYNTHETIC
Main$1();
descriptor: (Ljava/lang/String;)V
flags: (0x0000)
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: putfield #1 // Field val$message:Ljava/lang/String;
5: aload_0
6: invokespecial #7 // Method java/lang/Object."<init>":()V
9: return
LineNumberTable:
line 4: 0
Signature: #12 // ()V
public void run();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_0
4: getfield #1 // Field val$message:Ljava/lang/String;
7: invokevirtual #19 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
10: return
LineNumberTable:
line 7: 0
line 8: 10
}
SourceFile: "Main.java"
EnclosingMethod: #34.#36 // Main.main
NestHost: class Main
InnerClasses:
#2; // class Main$1
class Main$1 implements java.lang.Runnable {
final String val$message;
Main$1(String arg1) {
this.val$message = arg1;
super();
}
public void run() {
System.out.println(this.val$message);
}
}
なんと、コンストラクタに引数が追加されています。
そして、その引数の値をフィールド変数として保持しています。
run()
メソッド内では、そのフィールド変数の値を参照しています。
直接、ローカル変数の値を参照しているわけではないんですね…。
よく見ると、コンストラクタでフィールド変数に代入してから、super()
を呼び出しています。
Java 言語仕様(8.8.7 コンストラクタの本体)としては、最初に this
または super
のコンストラクタを明示的または暗黙的に呼び出さなくてはいけないので、あれ?という感じがしますね。
でも、大丈夫です。この点は、Javaのクラスファイルとしては問題ありません。
【 Java仮想マシン仕様:4.8.2 構造上の制約 】
クラス Object のコンストラクタから導出されたインスタンス初期化メソッドを除いた各インスタンス初期化メソッドは、該当インスタンス・メンバに対するアクセスの前に、this に対する別のインスタンス初期化メソッドか、その直接のスーパークラス super に対するインスタンス初期化メソッドのいずれかを呼び出さなければならない。しかし、インスタンス初期化メソッドの呼び出しに先立って、カレント・クラスで宣言されている this に対するインスタンスフィールドへの代入を行うことができる。
ラムダ式のコンパイル結果
続いて、ラムダ式の場合を見ていきましょう。
先ほどの匿名クラスを使って書いた部分を、ラムダ式に置き換えました。
public class Main {
public static void main(String[] args) throws InterruptedException {
var message = "Hello world!";
var thread = new Thread(() -> System.out.println(message));
thread.run();
thread.join();
}
}
このクラスをコンパイルすると、1つのクラスファイルが出来上がりました。
Main.class
そして、これをシステムプロパティ jdk.internal.lambda.dumpProxyClasses=output
を付けて実行した結果、output ディレクトリにクラスファイルが出来上がりました。
Main$$Lambda$1.class
このクラスファイルを javap して、Java のソースコードに変換してみました。
Main$$Lambda$1.class の javap 結果
Classfile /C:/Users/YujiSoftware/Desktop/java/Main$$Lambda$1.class
Last modified 2021/12/25; size 307 bytes
SHA-256 checksum 9ba2c22eca9a152b636e2a0b00a4a12cb0039ed659914bcc48103ec6718a0d17
final class Main$$Lambda$1 implements java.lang.Runnable
minor version: 0
major version: 59
flags: (0x1030) ACC_FINAL, ACC_SUPER, ACC_SYNTHETIC
this_class: #2 // Main$$Lambda$1
super_class: #4 // java/lang/Object
interfaces: 1, fields: 1, methods: 2, attributes: 0
{
private final java.lang.String arg$1;
descriptor: Ljava/lang/String;
flags: (0x0012) ACC_PRIVATE, ACC_FINAL
private Main$$Lambda$1(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: (0x0002) ACC_PRIVATE
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: invokespecial #13 // Method java/lang/Object."<init>":()V
4: aload_0
5: aload_1
6: putfield #15 // Field arg$1:Ljava/lang/String;
9: return
public void run();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #15 // Field arg$1:Ljava/lang/String;
4: invokestatic #21 // Method Main.lambda$main$0:(Ljava/lang/String;)V
7: return
}
final class Main$$Lambda$1 implements java.lang.Runnable {
private final String arg$1;
private Main$$Lambda$1(String arg1) {
super();
this.arg$1 = ar$1;
}
public void run() {
Main.lambda$main$0(this.arg$1);
}
}
匿名クラスと同様に、コンストラクタに引数が追加されています。
そして、その引数の値をフィールド変数として保持しています。
run()
メソッド内では、そのフィールド変数の値を参照しています。
あとは、「外側のローカル変数を参照しない場合」と同じですね。
外側のローカル変数をいっぱい参照する場合
ローカル変数は、1メソッド内で 65,535 個まで宣言できます。
また、メソッドの引数には int
型などでは 255 個まで宣言できます。2
(コンストラクタも同様)
ということは、ラムダ式の内部から、外側のローカル変数を256個以上参照した場合はどうなるのでしょうか。
ここまで見てきたように、ローカル変数はコンストラクタの引数で受け渡していますが、メソッドの引数は 255 個までなので 256 個以上は渡せません。
ローカル変数を256個参照した場合
外側のローカル変数を256個参照するラムダ式を、実際にコンパイルしてみました。
public class Main {
public static void main(String[] args) throws InterruptedException {
var number1 = 1;
var number2 = 2;
(中略)
var number255 = 255;
var number256 = 256;
var thread = new Thread(() -> {
System.out.println(number1);
System.out.println(number2);
(中略)
System.out.println(number255);
System.out.println(number256);
});
thread.run();
thread.join();
}
}
その結果、 コンパイルエラーになりました。
C:\Users\YujiSoftware\Desktop\java>javac *.java
Main.java:1: エラー: パラメータが多すぎます
エラー1個
特に対処することなく、256個の引数を持つ static メソッドを作ろうとしてエラーになるみたいです。
ちなみに、匿名クラスの場合はコンパイルに成功しました。
ただし、無効なクラスファイルになったため、実行時に ClassFormatError
になりました。
C:\Users\YujiSoftware\Desktop\java>java -Djdk.internal.lambda.dumpProxyClasses=. Main
Exception in thread "main" java.lang.ClassFormatError: Too many arguments in method signature in class file Main$1
at java.base/java.lang.ClassLoader.defineClass1(Native Method)
at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1012)
at java.base/java.security.SecureClassLoader.defineClass(SecureClassLoader.java:150)
at java.base/jdk.internal.loader.BuiltinClassLoader.defineClass(BuiltinClassLoader.java:862)
at java.base/jdk.internal.loader.BuiltinClassLoader.findClassOnClassPathOrNull(BuiltinClassLoader.java:760)
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClassOrNull(BuiltinClassLoader.java:681)
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:639)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:520)
at Main.main(Main.java:260)
ローカル変数を255個参照した場合
ラムダ式で参照する外側のローカル変数を255個に減らしてみました。
すると、コンパイルに成功しました。
しかし、実行してみると BootstrapMethodError になりました。
C:\Users\YujiSoftware\Desktop\java>java -Djdk.internal.lambda.dumpProxyClasses=. Main
Exception in thread "main" java.lang.BootstrapMethodError: java.lang.IllegalArgumentException: bad parameter count 256
at Main.main(Main.java:260)
Caused by: java.lang.IllegalArgumentException: bad parameter count 256
at java.base/java.lang.invoke.MethodHandleStatics.newIllegalArgumentException(MethodHandleStatics.java:167)
at java.base/java.lang.invoke.MethodType.checkSlotCount(MethodType.java:223)
at java.base/java.lang.invoke.MethodType.insertParameterTypes(MethodType.java:437)
at java.base/java.lang.invoke.MethodType.appendParameterTypes(MethodType.java:461)
at java.base/java.lang.invoke.DirectMethodHandle.makePreparedLambdaForm(DirectMethodHandle.java:256)
at java.base/java.lang.invoke.DirectMethodHandle.preparedLambdaForm(DirectMethodHandle.java:233)
at java.base/java.lang.invoke.DirectMethodHandle.preparedLambdaForm(DirectMethodHandle.java:218)
at java.base/java.lang.invoke.DirectMethodHandle.preparedLambdaForm(DirectMethodHandle.java:227)
at java.base/java.lang.invoke.DirectMethodHandle.make(DirectMethodHandle.java:108)
at java.base/java.lang.invoke.MethodHandles$Lookup.getDirectMethodCommon(MethodHandles.java:4004)
at java.base/java.lang.invoke.MethodHandles$Lookup.getDirectMethodNoSecurityManager(MethodHandles.java:3960)
at java.base/java.lang.invoke.MethodHandles$Lookup.getDirectMethodForConstant(MethodHandles.java:4204)
at java.base/java.lang.invoke.MethodHandles$Lookup.linkMethodHandleConstant(MethodHandles.java:4152)
at java.base/java.lang.invoke.MethodHandleNatives.linkMethodHandleConstant(MethodHandleNatives.java:615)
... 1 more
java.lang.IllegalArgumentException: bad parameter count 256
とのことです。
これは、コンストラクタの最初の引数として暗黙的に自身のオブジェクトが追加されているため、引数の数が +1 されて256個になってしまったのが原因のようです。
ローカル変数を254個参照した場合
もう一個減らして、ラムダ式で参照する外側のローカル変数を254個にしてみました。
しかし、これも実行してみると BootstrapMethodError になりました。
(255個の時と微妙にスタックトレースが違います)
C:\Users\YujiSoftware\Desktop\java>java -Djdk.internal.lambda.dumpProxyClasses=. Main
Exception in thread "main" java.lang.BootstrapMethodError: bootstrap method initialization exception
at java.base/java.lang.invoke.BootstrapMethodInvoker.invoke(BootstrapMethodInvoker.java:188)
at java.base/java.lang.invoke.CallSite.makeSite(CallSite.java:315)
at java.base/java.lang.invoke.MethodHandleNatives.linkCallSiteImpl(MethodHandleNatives.java:281)
at java.base/java.lang.invoke.MethodHandleNatives.linkCallSite(MethodHandleNatives.java:271)
at Main.main(Main.java:259)
Caused by: java.lang.IllegalArgumentException: bad parameter count 256
at java.base/java.lang.invoke.MethodHandleStatics.newIllegalArgumentException(MethodHandleStatics.java:167)
at java.base/java.lang.invoke.MethodType.checkSlotCount(MethodType.java:223)
at java.base/java.lang.invoke.MethodType.insertParameterTypes(MethodType.java:437)
at java.base/java.lang.invoke.DirectMethodHandle.makePreparedLambdaForm(DirectMethodHandle.java:259)
at java.base/java.lang.invoke.DirectMethodHandle.preparedLambdaForm(DirectMethodHandle.java:233)
at java.base/java.lang.invoke.DirectMethodHandle.preparedLambdaForm(DirectMethodHandle.java:218)
at java.base/java.lang.invoke.DirectMethodHandle.preparedLambdaForm(DirectMethodHandle.java:227)
at java.base/java.lang.invoke.DirectMethodHandle.makeAllocator(DirectMethodHandle.java:142)
at java.base/java.lang.invoke.DirectMethodHandle.make(DirectMethodHandle.java:133)
at java.base/java.lang.invoke.MethodHandles$Lookup.getDirectConstructorCommon(MethodHandles.java:4122)
at java.base/java.lang.invoke.MethodHandles$Lookup.getDirectConstructor(MethodHandles.java:4106)
at java.base/java.lang.invoke.MethodHandles$Lookup.findConstructor(MethodHandles.java:2751)
at java.base/java.lang.invoke.InnerClassLambdaMetafactory.buildCallSite(InnerClassLambdaMetafactory.java:269)
at java.base/java.lang.invoke.LambdaMetafactory.metafactory(LambdaMetafactory.java:341)
at java.base/java.lang.invoke.BootstrapMethodInvoker.invoke(BootstrapMethodInvoker.java:134)
... 4 more
これは、原因が分かりませんでした。
ラムダ式ではなく、匿名クラスだと参照するローカル変数が254個でも大丈夫なんですが…。
ちなみに、参照するローカル変数を253個にまで減らせば、ラムダ式でもエラーにならずに実行できました。
最後に
Java Advent Calendar 2021 も、これにて終了です!
みなさん、お疲れ様でした。
メリークリスマス!!!
-
これ以降も、同様に javap の結果を見ながら @YujiSoftware が気合で Java のソースコードに変換しているので、間違えているところがあるかも…。 ↩
-
long や double は2個としてカウントします。「arg1 が long 型や double 型である場合、ローカル変数 1 と 2 が用いられる(Java仮想マシン仕様:6 Java仮想マシンの命令セット, invokespecial より)」 ↩