Java中普通lambda表达式和方法引用本质上有什么区别?

在Spark平台上使用Java时遇到了一个有趣的问题,在本人固有的印象中,一直认为以下两行代码是完全等价的: 但匪夷所思的是,运行时,第二行(采用方法引用的打印)会抛出PrintStream没有序列化的异常(即System.out对象没有被序列化),而第一行(普通的lambda表达式打印的)则正常打印出了1-4。 上面异常中第36行代码就是filter中使用方法引用打印的那一行。 为了探究这个问题,本人做了测试,写了如下的两个类: 和 在Main中这…
关注者
272
被浏览
11780
哈哈,题主掉到serializable lambda的坑里了。Java 8的lambda与method reference这坑埋得确实深。

题主的问题可以用下面的代码来演示并讲解。其实主要差别不在于lambda与method reference,而是在于捕获非捕获,而捕获的东西是否可以序列化:
(完整代码放在Github gist上了,请参考 gist.github.com/rednaxe ,源码是 Demo.java)
    VoidFunction<String> c1 = s -> System.out.println(s); // non-capturing lambda
    VoidFunction<String> c2 = System.out::println;        // instance method reference
    PrintStream sysout = System.out; // PrintStream doesn't implement Serializable
    VoidFunction<String> c3 = s -> sysout.println(s);     // capturing lambda

这里我们创建了3个 org.apache.spark.api.java.function.VoidFunction<T> 实例,分别用Java 8的lambda与method reference来创建。题主的例子里的 JavaRDD<T>.foreach() 方法接收的参数类型就是这个 VoidFunction<T> ,所以这里的代码跟题主实验的情况是完全一致的,只是最精简化方便讲解而已。
  def foreach(f: VoidFunction[T]) {
    rdd.foreach(x => f.call(x))
  }
(传入的这个 VoidFunction<T> 会进一步被包装成一个可序列化的Scala closure(scala.Function1<T, Unit>类型)然后传给底下的 RDD<T>.foreach(Function1<T, Unit>) 方法。但这里我们不必关心Scala那边,只要关心Java 8这边的状况就可以解答题主的问题了。)

这个 VoidFunction<T> 接口继承了 java.io.Serializable 接口,所以它的实现类都会是可序列化的。
Java 8的可序列化lambda / method reference有些大坑,实现得颇不完美。下面就讲讲这些坑中题主遇到的具体问题。

Java 8的lambda与method reference的创建位置是通过invokedynamic指令,提供若干静态参数给对应的bootstrap method来实现的。 <- 这些术语看不懂的话没关系。可以另外找资料来学习一下 invokedynamic 与 bootstrap method。

在Oracle JDK 8 / OpenJDK 8的实现中,javac在编译Java源码的时候会看看一个lambda表达式或method reference的目标SAM(Single Abstract Method)类型是否是Serializable的,并为这个invokedynamic指令选择相应的bootstrap method。
所以这里我们要关注的是后者,LambdaMetafactory.altMetafactory()。可以看到其实两个版本背后的实现都是 InnerClassLambdaMetafactory

就目前的JDK8实现而言,这个 InnerClassLambdaMetafactory 会在运行时生成出跟Java的内部类相似的类去实现SAM类型接口,然后我们在运行时得到的lambda或method reference的实例其实就是这些类的实例。
这些运行时生成的类使用了HotSpot VM的“VM anonymous class”功能。请跳这个传送门:JVM crashes at libjvm.so? - RednaxelaFX 的回答

那么上面的3种写法,分别生成了怎样的类呢?简单说说。

=========================================

第一种情况,s -> System.out.println(s) 是一个没有任何“自由变量”(free variable)的lambda表达式,不需要从外围环境中捕获任何变量。这种lambda表达式也叫做non-capturing lambda。它对应的由altMetafactory在运行时生成的类是这样的:
import java.lang.invoke.LambdaForm;
import java.lang.invoke.SerializedLambda;
import org.apache.spark.api.java.function.VoidFunction;

final class Demo$$Lambda$1 implements VoidFunction {
  private Demo$$Lambda$1() {
    // empty private constructor
  }

  @LambdaForm.Hidden
  public void call(Object arg) {
    Demo.lambda$main$28d50090$1((String) arg);
  }

  private final Object writeReplace() {
    return new SerializedLambda(
             Demo.class,
             "org/apache/spark/api/java/function/VoidFunction",
             "call",
             "(Ljava/lang/Object;)V",
             6,
             "Demo",
             "lambda$main$28d50090$1",
             "(Ljava/lang/String;)V",
             "(Ljava/lang/String;)V",
             new Object[0]);
  }
}
writeReplace()是序列化用的,我们先不管它。
但是这个 Demo.lambda$main$28d50090$1() 方法是个啥?
这就是 javac 为这个lambda表达式里的代码逻辑找的安放位置。它长这样:
  private static void lambda$main$28d50090$1(String s) throws Exception {
    return System.out.println(s);
  }
可以看到它就是这个lambda表达式的方法体没错。

留意到:这个类没有任何字段,不包含任何可变状态。JDK给它实现了一套序列化机制,只写出它的一些符号信息,用SerializedLambda对象来包装起这些符号信息。它的构造器的参数都是什么意思,可以参考:jdk8u/jdk8u/jdk: 8282bb42fc96 src/share/classes/java/lang/invoke/SerializedLambda.java

=========================================

第二种情况,System.out::println ,这是一个实例方法的method reference,不但会指定要调用的方法是哪个(java.io.PrintStream.println()),还会捕获这个被调用的实例(由System.out静态变量所引用的实例)。那么来看看它对应的运行时生成的类是什么样子的:
import java.lang.invoke.LambdaForm;
import java.lang.invoke.SerializedLambda;
import java.io.PrintStream;
import org.apache.spark.api.java.function.VoidFunction;

final class Demo$$Lambda$2 implements VoidFunction {
  private final PrintStream arg$1;
  private Demo$$Lambda$2(PrintStream arg) {
    this.arg$1 = arg;
  }

  private static VoidFunction get$Lambda(PrintStream arg) {
    return new Demo$$Lambda$2(arg);
  }

  @LambdaForm.Hidden
  public void call(Object arg) {
    this.arg$1.println((String) arg);
  }

  private final Object writeReplace() {
    return new SerializedLambda(
             Demo.class,
             "org/apache/spark/api/java/function/VoidFunction",
             "call",
             "(Ljava/lang/Object;)V",
             5,
             "java/io/PrintStream",
             "println",
             "(Ljava/lang/String;)V",
             "(Ljava/lang/String;)V",
             new Object[] { this.arg$1 });
  }
}
可以看到,Java 8里lambda与method reference对变量的“捕获”的实质是capture-by-value,把被捕获的变量拷贝一份存在闭包里。这个类里有一个实例变量 arg$1 就是用来保存被捕获的变量的值用的。
然后我们看到它的 call() 方法就是直接对被捕获的PrintStream引用调用其 println() 方法。这method reference的实体就在此。

接下来就到题主遇到的问题的根源了:在用于序列化的 writeReplace() 方法中,SerializedLambda 对象不但包含了符号信息,还把这个method reference所捕获的引用也写进去了。可是 PrintStream 类并没有实现 Serializable 接口,于是Java序列化机制在看到它的时候就会报出 NotSerializableException 异常。悲催!

这里举的例子是个实例方法的method reference。那么如果是静态方法的method reference呢?那它就不需要捕获被调用对象的引用,于是对应的运行时生成的类就会跟前面的non-capturing lambda类似,序列化就不会遇到问题。

=========================================

第三种情况,s -> sysout.println(s) 。这是一个带有一个自由变量“sysout”的lambda表达式。它需要从环境中捕获sysout局部变量的值才可以正确运行。所以这种lambda也叫做capturing lambda。

想必有了上面两种情况的讲解,大家也可以猜到它对应的运行时生成的类是什么样子的了。具体分析就留作课后作业吧 ^_^