lambda 和运行时级别的方法引用之间有什么区别?

2022-09-01 02:41:32

我遇到了一个问题,使用方法引用时发生了这个问题,但 lambdas 却没有。该代码如下:

(Comparator<ObjectNode> & Serializable) SOME_COMPARATOR::compare

或者,使用λ,

(Comparator<ObjectNode> & Serializable) (a, b) -> SOME_COMPARATOR.compare(a, b)

从语义上讲,它是严格相同的,但在实践中它是不同的,因为在第一种情况下,我在Java序列化类之一中得到了一个异常。我的问题不是关于这个异常,因为实际的代码是在更复杂的上下文中运行的,该上下文已被证明具有序列化的奇怪行为,因此,如果我提供更多详细信息,则很难回答。

我想了解的是创建 lambda 表达式的这两种方法之间的区别。


答案 1

开始

为了研究这个问题,我们从以下类开始:

import java.io.Serializable;
import java.util.Comparator;

public final class Generic {

    // Bad implementation, only used as an example.
    public static final Comparator<Integer> COMPARATOR = (a, b) -> (a > b) ? 1 : -1;

    public static Comparator<Integer> reference() {
        return (Comparator<Integer> & Serializable) COMPARATOR::compare;
    }

    public static Comparator<Integer> explicit() {
        return (Comparator<Integer> & Serializable) (a, b) -> COMPARATOR.compare(a, b);
    }

}

编译后,我们可以使用以下方法对其进行反汇编:

javap -c -p -s -v Generic.class

删除不相关的部分(以及其他一些混乱,例如完全限定的类型和初始化),我们只剩下COMPARATOR

  public static final Comparator<Integer> COMPARATOR;    

  public static Comparator<Integer> reference();
      0: getstatic     #2  // Field COMPARATOR:LComparator;    
      3: dup    
      4: invokevirtual #3   // Method Object.getClass:()LClass;    
      7: pop    
      8: invokedynamic #4,  0  // InvokeDynamic #0:compare:(LComparator;)LComparator;    
      13: checkcast     #5  // class Serializable    
      16: checkcast     #6  // class Comparator    
      19: areturn

  public static Comparator<Integer> explicit();
      0: invokedynamic #7,  0  // InvokeDynamic #1:compare:()LComparator;    
      5: checkcast     #5  // class Serializable    
      8: checkcast     #6  // class Comparator    
      11: areturn

  private static int lambda$explicit$d34e1a25$1(Integer, Integer);
     0: getstatic     #2  // Field COMPARATOR:LComparator;
     3: aload_0
     4: aload_1
     5: invokeinterface #44,  3  // InterfaceMethod Comparator.compare:(LObject;LObject;)I
    10: ireturn

BootstrapMethods:    
  0: #61 invokestatic invoke/LambdaMetafactory.altMetafactory:(Linvoke/MethodHandles$Lookup;LString;Linvoke/MethodType;[LObject;)Linvoke/CallSite;    
    Method arguments:    
      #62 (LObject;LObject;)I    
      #63 invokeinterface Comparator.compare:(LObject;LObject;)I    
      #64 (LInteger;LInteger;)I    
      #65 5    
      #66 0    

  1: #61 invokestatic invoke/LambdaMetafactory.altMetafactory:(Linvoke/MethodHandles$Lookup;LString;Linvoke/MethodType;[LObject;)Linvoke/CallSite;    
    Method arguments:    
      #62 (LObject;LObject;)I    
      #70 invokestatic Generic.lambda$explicit$df5d232f$1:(LInteger;LInteger;)I    
      #64 (LInteger;LInteger;)I    
      #65 5    
      #66 0

我们立即看到该方法的字节码与 的字节码不同。然而,显着的区别实际上并不相关,但引导方法很有趣。reference()explicit()

调用动态调用站点通过引导方法链接到方法,引导方法是编译器为动态类型语言指定的方法,JVM 调用该方法一次以链接站点。

(Java 虚拟机对非 Java 语言的支持,强调他们的)

这是负责创建 lambda 使用的 CallSite 的代码。下面列出的每个引导方法都是作为LambdaMetaFactory#altMetaFactory的可变参数(即)传递的值。Method argumentsargs

方法参数的格式

  1. samMethodType - 函数对象要实现的方法的签名和返回类型。
  2. implMethod - 一个直接方法句柄,描述在调用时应调用的实现方法(对参数类型,返回类型进行适当调整,并在调用参数前面附加捕获的参数)。
  3. 实例化方法类型 - 应在调用时动态强制实施的签名和返回类型。这可能与 samMethodType 相同,也可能是它的特化。
  4. 标志表示其他选项;这是所需标志的按位OR。定义的标志是FLAG_BRIDGES、FLAG_MARKERS和FLAG_SERIALIZABLE。
  5. bridgeCount 是函数对象应实现的附加方法签名数,当且仅当设置了 FLAG_BRIDGES 标志时才存在。

在这两种情况下,这里都是0,所以没有6,否则将是 - 要实现的其他方法签名的可变长度列表(给定是0,我不完全确定为什么设置FLAG_BRIDGES)。bridgeCountbridgesbridgeCount

将上述内容与我们的参数相匹配,我们得到:

  1. 函数签名和返回类型 ,这是比较器#compare的返回类型,因为泛型类型擦除。(Ljava/lang/Object;Ljava/lang/Object;)I
  2. 调用此 lambda 时调用的方法(这是不同的)。
  3. lambda 的签名和返回类型,在调用 lambda 时将检查该类型:(请注意,这些不会被擦除,因为这是 lambda 规范的一部分)。(LInteger;LInteger;)I
  4. 标志,在这两种情况下都是FLAG_BRIDGESFLAG_SERIALIZABLE(即5)的组成。
  5. 网桥方法签名的数量,0。

我们可以看到,FLAG_SERIALIZABLE是为两个 lambda 设置的,所以事实并非如此。

实现方法

该方法的实现方法是 引用 lambda 是 ,但对于显式 lambda,它是 。从反汇编来看,我们可以看到前者本质上是后者的内联版本。唯一的其他显着区别是方法参数类型(如前所述,这是因为泛型类型擦除)。Comparator.compare:(LObject;LObject;)IGeneric.lambda$explicit$df5d232f$1:(LInteger;LInteger;)I

Lambda 何时实际可序列化?

如果 lambda 表达式的目标类型和捕获的参数可序列化,则可以序列化该表达式。

Lambda Expressions (The Java™ Tutorials)

其中重要的部分是“捕获的论点”。回顾一下反汇编的字节码,方法引用的 invokedynamic 指令肯定看起来像是在捕获一个比较器(与显式 lambda 相反,)。#0:compare:(LComparator;)LComparator;#1:compare:()LComparator;

确认捕获是问题所在

ObjectOutputStream包含一个字段,我们可以使用 VM 参数设置该字段:extendedDebugInfo-Dsun.io.serialization.extendedDebugInfo=true

$ java -Dsun.io.serialization.extendedDebugInfo=true Generic

当我们尝试再次序列化 lambda 时,这给出了一个非常令人满意的结果

Exception in thread "main" java.io.NotSerializableException: Generic$$Lambda$1/321001045
        - element of array (index: 0)
        - array (class "[LObject;", size: 1)
/* ! */ - field (class "invoke.SerializedLambda", name: "capturedArgs", type: "class [LObject;") // <--- !!
        - root object (class "invoke.SerializedLambda", SerializedLambda[capturingClass=class Generic, functionalInterfaceMethod=Comparator.compare:(LObject;LObject;)I, implementation=invokeInterface Comparator.compare:(LObject;LObject;)I, instantiatedMethodType=(LInteger;LInteger;)I, numCaptured=1])
    at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1182)
    /* removed */
    at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:348)
    at Generic.main(Generic.java:27)

实际情况

从上面,我们可以看到显式 lambda 没有捕获任何内容,而方法引用 lambda 是。再次查看字节码可以清楚地看出这一点:

  public static Comparator<Integer> explicit();
      0: invokedynamic #7,  0  // InvokeDynamic #1:compare:()LComparator;    
      5: checkcast     #5  // class java/io/Serializable    
      8: checkcast     #6  // class Comparator    
      11: areturn

如上所示,其实现方法为:

  private static int lambda$explicit$d34e1a25$1(java.lang.Integer, java.lang.Integer);
     0: getstatic     #2  // Field COMPARATOR:Ljava/util/Comparator;
     3: aload_0
     4: aload_1
     5: invokeinterface #44,  3  // InterfaceMethod java/util/Comparator.compare:(Ljava/lang/Object;Ljava/lang/Object;)I
    10: ireturn

显式 lambda 实际上正在调用 ,而后者又调用 .此间接层意味着它不会捕获任何不是(或者根本无法捕获任何内容,确切地说,是任何东西),因此可以安全地进行序列化。引用表达式直接使用的方法(其值随后传递给引导方法):lambda$explicit$d34e1a25$1COMPARATOR#compareSerializableCOMPARATOR

  public static Comparator<Integer> reference();
      0: getstatic     #2  // Field COMPARATOR:LComparator;    
      3: dup    
      4: invokevirtual #3   // Method Object.getClass:()LClass;    
      7: pop    
      8: invokedynamic #4,  0  // InvokeDynamic #0:compare:(LComparator;)LComparator;    
      13: checkcast     #5  // class java/io/Serializable    
      16: checkcast     #6  // class Comparator    
      19: areturn

缺少间接寻址意味着必须与 lambda 一起序列化。由于不引用值,因此此操作将失败。COMPARATORCOMPARATORSerializable

修复

我犹豫是否要将其称为编译器错误(我希望缺少间接寻址可以作为优化),尽管它非常奇怪。修复是微不足道的,但丑陋;添加 at 声明的显式强制转换:COMPARATOR

public static final Comparator<Integer> COMPARATOR = (Serializable & Comparator<Integer>) (a, b) -> a > b ? 1 : -1;

这使得一切都在Java 1.8.0_45上正确执行。同样值得注意的是,eclipse 编译器在方法参考案例中也生成了该间接层,因此本文中的原始代码不需要修改即可正确执行。


答案 2

我想补充一个事实,即 lambda 和对实例方法的方法引用之间实际上存在语义差异(即使它们具有与您案例中相同的内容,并且忽略序列化):

SOME_COMPARATOR::compare

此表单的计算结果为 lambda 对象,该对象在评估时的值上闭合(即,它包含对该对象的引用)。它将在评估时检查是否为 null,然后已经引发 null 指针异常。它不会选取在创建字段后对字段所做的更改。SOME_COMPARATORSOME_COMPARATOR

(a,b) -> SOME_COMPARATOR.compare(a,b)

此表单的计算结果为 lambda 对象,该对象将在调用时访问字段的值。它是闭合的,因为它是一个实例字段。调用时,它将访问的当前值并使用该值,此时可能会引发空指针异常。SOME_COMPARATORthisSOME_COMPARATORSOME_COMPARATOR

示范

从下面的小示例中可以看出此行为。通过在调试器中停止代码并检查lambda的字段,可以验证它们被关闭的内容。

Object o = "First";

void run() {
    Supplier<String> ref = o::toString; 
    Supplier<String> lambda = () -> o.toString();
    o = "Second";
    System.out.println("Ref: " + ref.get()); // Prints "First"
    System.out.println("Lambda: " + lambda.get()); // Prints "Second"
}

Java 语言规范

JLS 在 15.13.3 中描述了方法引用的这种行为:

目标引用是表达式名称或主值,在计算方法引用表达式时确定。

和:

首先,如果方法引用表达式以 ExpressionName 或 Primary 开头,则计算此子表达式。如果子表达式的计算结果为 ,则引发 anullNullPointerException

在托比斯代码中

这可以在 Tobys 的代码列表中看到,其中调用的值将为 null,则将触发异常:referencegetClassSOME_COMPARATOR

4: invokevirtual #3   // Method Object.getClass:()LClass;

(或者我认为,我真的不是字节码方面的专家。

但是,在这种情况下,符合 Eclipse 4.4.1 的代码中的方法引用不会引发异常。Eclipse似乎在这里有一个错误。