Java8 lambda の裏舞台は、きっとあなたが考えているより強かだ

f:id:Naotsugu:20171020224820p:plain

前回、Stream の裏舞台について見てみました。

blog1.mammb.com

ついでなので、今回は Lambda 式の裏舞台について見てみましょう。

はじめに

有名な話ではありますが、以下の匿名クラスを含むコードをコンパイルすると、2つのクラスファイルが生成されます。

public class Main {
    public static void main(String... args) {
        Logger.getGlobal().info(new Supplier<String>() {
            @Override
            public String get() {
                return "hello";
            }
        });
    }
}

以下のようなクラスファイルになります。

Main.class
Main$1.class

では、lambdaで書いた場合はどうなるでしょう。

public class Main {
    public static void main(String... args) {
        Logger.getGlobal().info(() -> "hello");
    }
}

こちらはクラスファイルが1つになります。

Main.class

つまり、lambda 式で書いた場合と匿名クラスで書いた場合にはコンパイル後のクラスファイルが異なる ということになります

ラムダ式は何処へ?

それぞれのクラスファイルの中身を見れば理由が分かります。

javap コマンドを使ってクラスファイルの中身をのぞいてみましょう。

$ javap -v Main.class

最初は普通の匿名クラスでコンパイルした方を見てみます。mainメソッドの中身だけ抜粋します。

 ...
    0: invokestatic  #2 // Method java/util/logging/Logger.getGlobal:()Ljava/util/logging/Logger;
    3: new           #3 // class Main$1
    6: dup
    7: invokespecial #4 // Method Main$1."<init>":()V
   10: invokevirtual #5 // Method java/util/logging/Logger.info:(Ljava/util/function/Supplier;)V
   13: return
 ...

普通にMain$1クラスをインスタンス化して info() を呼んでいるだけです。

(invokespecial がコンストラクタ呼び出し。invokevirtual がインスタンスメソッドの呼び出しです。 new でオブジェクト確保してスタックに積む。dup でスタックの最上位を複製してスタックに積む。invokespecial でスタックからPOPしてコンストラクタ呼び出しといった流れになります)


では、ラムダ式版はどうでしょう。

 ...
    0: invokestatic  #2      // Method java/util/logging/Logger.getGlobal:()Ljava/util/logging/Logger;
    3: invokedynamic #3,  0  // InvokeDynamic #0:get:()Ljava/util/function/Supplier;
    8: invokevirtual #4      // Method java/util/logging/Logger.info:(Ljava/util/function/Supplier;)V
   11: return
 ...

先程の匿名クラスのインスタンス化の箇所が、invokedynamic (略して indy と呼ばれます)に変わっています。

さらにインナークラスの項は以下のように定義されており、

InnerClasses:
     public static final #52= #51 of #55; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles

そして、BootstrapMethods の項は以下のようになっています。

BootstrapMethods:
  0: #27 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;
      #29 invokestatic Main.lambda$main$0:()Ljava/lang/String;
      #30 ()Ljava/lang/String;

indy は、BootstrapMethods で定義された内容によって呼び出し先を動的に変更します。

BootstrapMethod

indy で呼ばれる java.lang.invoke.LambdaMetafactory.metafactory() の中身がどうなっているかを見てみましょう。

    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();
    }

大まかに見ると InnerClassLambdaMetafactory をインスタンス化して buildCallSite() していることがわかります。


InnerClassLambdaMetafactory のインスタンス化部分、コンストラクタの中身を見てみましょう。

長いので大幅に省略すると以下のようになります。

    public InnerClassLambdaMetafactory(MethodHandles.Lookup caller,
                                       /*...*/) throws LambdaConversionException {
        super(caller, /*...*/);
        // ...
        cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        // ...
    }

ClassWriter というクラスに見覚えのある方がいるかと思います。cglib などでも使っている ASM(Java バイトコードを操作するライブラリ)のそれです。jdk.internal というパッケージの中に、そのまま ASM を取り込んでいます。

import jdk.internal.org.objectweb.asm.*;


では続いて buildCallSite() の中身の見ておきましょう。といっても長いので雰囲気だけ残して大幅に省略します。

    @Override
    CallSite buildCallSite() throws LambdaConversionException {
        final Class<?> innerClass = spinInnerClass();
        if (invokedType.parameterCount() == 0) {
            // ...
            Object inst = ctrs[0].newInstance();
            return new ConstantCallSite(MethodHandles.constant(samBase, inst));
        } else {
            UNSAFE.ensureClassInitialized(innerClass);
            return new ConstantCallSite(MethodHandles.Lookup.IMPL_LOOKUP
                         .findStatic(innerClass, NAME_FACTORY, invokedType));
        }
    }

今回見たいのは、先頭行にある spinInnerClass() にあります。

ASM でクラス生成

spinInnerClass() の中身です。ちょっと長いですが、そのまま載せちゃいます。

    private Class<?> spinInnerClass() throws LambdaConversionException {
        String[] interfaces;
        String samIntf = samBase.getName().replace('.', '/');
        boolean accidentallySerializable = !isSerializable && Serializable.class.isAssignableFrom(samBase);
        if (markerInterfaces.length == 0) {
            interfaces = new String[]{samIntf};
        } else {
            // Assure no duplicate interfaces (ClassFormatError)
            Set<String> itfs = new LinkedHashSet<>(markerInterfaces.length + 1);
            itfs.add(samIntf);
            for (Class<?> markerInterface : markerInterfaces) {
                itfs.add(markerInterface.getName().replace('.', '/'));
                accidentallySerializable |= !isSerializable && Serializable.class.isAssignableFrom(markerInterface);
            }
            interfaces = itfs.toArray(new String[itfs.size()]);
        }

        cw.visit(CLASSFILE_VERSION, ACC_SUPER + ACC_FINAL + ACC_SYNTHETIC,
                 lambdaClassName, null,
                 JAVA_LANG_OBJECT, interfaces);

        // Generate final fields to be filled in by constructor
        for (int i = 0; i < argDescs.length; i++) {
            FieldVisitor fv = cw.visitField(ACC_PRIVATE + ACC_FINAL,
                                            argNames[i],
                                            argDescs[i],
                                            null, null);
            fv.visitEnd();
        }

        generateConstructor();

        if (invokedType.parameterCount() != 0) {
            generateFactory();
        }

        // Forward the SAM method
        MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, samMethodName,
                                          samMethodType.toMethodDescriptorString(), null, null);
        mv.visitAnnotation("Ljava/lang/invoke/LambdaForm$Hidden;", true);
        new ForwardingMethodGenerator(mv).generate(samMethodType);

        // Forward the bridges
        if (additionalBridges != null) {
            for (MethodType mt : additionalBridges) {
                mv = cw.visitMethod(ACC_PUBLIC|ACC_BRIDGE, samMethodName,
                                    mt.toMethodDescriptorString(), null, null);
                mv.visitAnnotation("Ljava/lang/invoke/LambdaForm$Hidden;", true);
                new ForwardingMethodGenerator(mv).generate(mt);
            }
        }

        if (isSerializable)
            generateSerializationFriendlyMethods();
        else if (accidentallySerializable)
            generateSerializationHostileMethods();

        cw.visitEnd();

        // Define the generated class in this VM.
        final byte[] classBytes = cw.toByteArray();

        // If requested, dump out to a file for debugging purposes
        if (dumper != null) {
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                @Override
                public Void run() {
                    dumper.dumpClass(lambdaClassName, classBytes);
                    return null;
                }
            }, null,
            new FilePermission("<<ALL FILES>>", "read, write"),
            // createDirectories may need it
            new PropertyPermission("user.dir", "read"));
        }

        return UNSAFE.defineAnonymousClass(targetClass, classBytes, null);
    }

細かい解説は省略しますが、ASM でランタイム時にクラスを生成し、cw.toByteArray() で得たバイトコードをUNSAFE.defineAnonymousClass() でクラス定義しています(defineClass ではなく defineAnonymousClass を使った方がパフォーマンスで6%有利ということで匿名クラス定義にしているようです)(UNSAFE を JDK から無くそうといった提案もありますが、また使われちゃってますね)。

FieldVisitorMethodVisitor などは ASM 使ったことあれば馴染みですよね。


旧来の匿名クラスだと、コンパイルにより生成されるクラスファイルが大量になりすぎ、かつ利用しないものもコンパイルタイムに必要ということで、ランタイム時にクラス生成という結論になったようです。たしか。

なお、これらのクラス生成はオンデマンドで初回のみとなるので、大幅なパフォーマンス劣化などは発生しないはずです。

ASMで生成したラムダ式のクラスを見る

先程の spinInnerClass() の中には以下のような箇所があります。

    // If requested, dump out to a file for debugging purposes
    if (dumper != null) {
        // ...
    }

生成したクラスをダンプできる仕組みですね。

JDK のチケットでいうと以下になります。

[JDK-8023524] Mechanism to dump generated lambda classes / log lambda code generation - Java Bug System

システムプロパティで jdk.internal.lambda.dumpProxyClasses=<dir> と指定してあげればランタイム時に生成したクラスファイルがファイル出力されます。

ダンプして中身を確認してみましょう。


java 起動時に指定するか、ソースの先頭にでも以下のように指定します。

System.setProperty("jdk.internal.lambda.dumpProxyClasses", ".");

今回の例だと以下のようなクラスファイルが生成されるので、javap してみましょう(必要に応じて$はエスケープしてください)。

Main$$Lambda$1.class

$ javap -v Main$$Lambda$1.class

以下のような出力となります。短めなので全文載せます。

Classfile Main$$Lambda$1.class
  Last modified 20XX/XX/XX; size 361 bytes
  MD5 checksum 1e319f55e80e26bc8b090ea1fd2c8c1d
final class Main$$Lambda$1 implements java.util.function.Supplier
  minor version: 0
  major version: 52
  flags: ACC_FINAL, ACC_SUPER, ACC_SYNTHETIC
Constant pool:
   #1 = Utf8        Main$$Lambda$1
   #2 = Class       #1             // Main$$Lambda$1
   #3 = Utf8        java/lang/Object
   #4 = Class       #3             // java/lang/Object
   #5 = Utf8        java/util/function/Supplier
   #6 = Class       #5             // java/util/function/Supplier
   #7 = Utf8        <init>
   #8 = Utf8        ()V
   #9 = NameAndType #7:#8          // "<init>":()V
  #10 = Methodref   #4.#9          // java/lang/Object."<init>":()V
  #11 = Utf8        get
  #12 = Utf8        ()Ljava/lang/Object;
  #13 = Utf8        Ljava/lang/invoke/LambdaForm$Hidden;
  #14 = Utf8        Main
  #15 = Class       #14            // Main
  #16 = Utf8        lambda$main$0
  #17 = Utf8        ()Ljava/lang/String;
  #18 = NameAndType #16:#17        // lambda$main$0:()Ljava/lang/String;
  #19 = Methodref   #15.#18        // Main.lambda$main$0:()Ljava/lang/String;
  #20 = Utf8        Code
  #21 = Utf8        RuntimeVisibleAnnotations
{
  public java.lang.Object get();
    descriptor: ()Ljava/lang/Object;
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: invokestatic  #19      // Method Main.lambda$main$0:()Ljava/lang/String;
         3: areturn
    RuntimeVisibleAnnotations:
      0: #13()
}

0: invokestatic #19 とあり、Main.lambda$main$0() というstaticメソッドを呼び出していることがわかります。このメソッドは何かと言えば、Main.class の中にあるプライベートメソッドです。

なので、javap コマンドに -p オプションを付けてプライベートメソッドを見てみましょう。

$ javap -p -v Main.class

当該箇所を抜き出すと以下のようになっています。

  private static java.lang.String lambda$main$0();
    descriptor: ()Ljava/lang/String;
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=1, locals=0, args_size=0
         0: ldc        #5   // String hello
         2: areturn
      LineNumberTable:
        line 6: 0

ldc でコンスタントプールから得た hello という文字をスタックに Push して戻るとなっています。

というわけで、擬似的にコードとして表現すると以下のようになります。

public class Main {

  // ランタイム時に作成された内部クラス
  final class Main$$Lambda$1 implements Supplier {
    @Hidden 
    public Object get() {
      return Main.lambda$main$0();
    }
  }

  // コンパイル時に作成された static メソッド
  private static String lambda$main$0() {
    return "hello";
  }

  public static void main(String... args) {
    Logger.getGlobal().info(new Main$$Lambda$1());
  }

}

これで、消えた lambda 式の行方が分かりました。

まとめ

  • lambda 式は、匿名クラスとは異なり、コンパイルによるクラス生成が行われない
  • lambda 式は、実行時に indy の仕組みを使って動的にバイトコードを生成する
  • 作成されたバイトコードは匿名クラスとしてロードされるため、匿名クラスとして定義したのと同じように動く