字节码之 Lambda 表达式底层原理

0.前言

今天我们从字节码指令层面聊聊Lambda表达式的底层原理。
在这里插入图片描述


首先我们写一个简单的lambda表达式,并用javap 命令反编译成字节码指令。

0. lambda程序示例

import java.util.function.Consumer;

public class LambdaExample {
    public static void main(String[] args) {
        Consumer<String> consumer = (s) -> System.out.println(s);
        consumer.accept("Hello, Lambda!");
    }
}

定义一个 Consumer,它接受一个字符串参数,并将其打印到控制台。然后,我们调用了 accept 方法,传入字符串 “Hello, Lambda!”。接下来,我们将使用 javac 编译这个程序,并使用 javap 分析编译后的字节码。

1. 编译程序:

javac LambdaExample.java

2. 使用 javap 分析字节码

javap -v -p LambdaExample

这将生成包含字节码的输出。我们只关注 main 方法部分的字节码指令,把这部分拷出来单独分析。

3. 输出字节码

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: invokedynamic #2,  0    //可以看到invokedynamic 指令,这是我们本章节的重点
         5: astore_1
         6: aload_1
         7: ldc           #3            // String Hello, Lambda 
         9: invokeinterface #4,  2     // InterfaceMethod java/util/function/Consumer.accept:(Ljava/lang/Object;)V
        14: return

4. 分析指令

  • 0: invokedynamic #2, 0:这里是 invokedynamic 指令。它用于生成 Lambda 表达式的实例。引导方法在运行时确定 Lambda 的实际实现,并返回一个指向该实现的方法句柄的 CallSite
  • 5: astore_1:将 invokedynamic 返回的 Consumer 实例存储在局部变量表中(索引为1)。
  • 6: aload_1:将局部变量表中的 Consumer 实例加载到操作数栈。
  • 7: ldc #3:将字符串 “Hello, Lambda!” 加载到操作数栈。
  • 9: invokeinterface #4, 2:调用 Consumer 接口的 accept 方法,将字符串传递给 Lambda 表达式。
  • 14: return:返回,结束 main 方法。

从字节码中,可以看到 invokedynamic 指令用于初始化 Lambda 表达式,并创建 Consumer 实例。这里的 invokedynamic 指令使得 Lambda 表达式的实现可以在运行时动态生成,而不是在编译时生成匿名内部类。

所以本章节我们着重了解 invokedynamic 和bootstrap method(引导方法)。

1. Lambda 表达式的字节码实现

在深入探讨关于“字节码之Lambda表达式底层原理”之前,我们需要先了解一下invokedynamic指令和引导方法(bootstrap method),我觉得这个是相对比较关键的两个知识点, 因为它们在Lambda表达式的底层实现中发挥着不可或缺的角色。下面我们将深入讨论这两者

1.1 什么是invokedynamic 指令

invokedynamic(动态方法调用)是Java 7引入的一个新的字节码指令,目的是为了支持动态类型语言在Java平台上的执行。这个指令特别适用于执行那些在编译时类型未知的方法调用。在Lambda表达式中,invokedynamic用于动态地生成和调用lambda表达式的实现。

与其他方法调用指令(如invokevirtualinvokestatic等)不同,invokedynamic不是固定地调用一个预定义的方法。相反,它依赖于引导方法(bootstrap method)来确定实际被调用的方法。

invokedynamic 的工作原理
  1. 引导方法的调用

    • 当 JVM 首次遇到某个特定的 invokedynamic 指令时,它会调用一个预先定义好的引导方法(bootstrap method)。
    • 这个引导方法返回一个方法句柄,指向真正要被调用的方法。
  2. 动态解析方法

    • 不同于其他指令在编译时确定要调用的方法,invokedynamic 依赖于引导方法在运行时动态解析并确定要调用的方法。
    • 一旦方法被确定,invokedynamic 指令将会调用这个方法,而不是固定地调用一个预定义的方法。
  3. 方法句柄的缓存

    • 为了提高性能,一旦引导方法返回了一个方法句柄,这个句柄将会被 JVM 缓存起来。这意味着,同一个 invokedynamic 指令的后续调用会直接使用这个缓存的方法句柄,而不需要再次触发引导方法。
为何 invokedynamic 如此特殊?
  • 动态语言的支持invokedynamic 是在 Java 7 中为了更好地支持 JVM 上的动态语言而引入的。传统的方法调用指令不够灵活,不能满足动态语言的需求,而 invokedynamic 提供了一种高效且灵活的方法调用机制。

  • Lambda 表达式的实现:在 Java 8 中引入的 Lambda 表达式,在底层也使用了 invokedynamic 来实现。通过这种方式,JVM 可以在运行时为 Lambda 表达式动态生成匿名类,而不是在编译时生成。

  • 性能优化:与传统的反射方式相比,invokedynamic 提供了一种更加高效的动态方法调用方式。反射在每次调用时都需要进行一系列的检查和解析,而 invokedynamic 通过方法句柄的缓存机制,减少了这些额外的开销。


1.2 bootstrap method 详解

引导方法是Java 7中引入的一个概念,用于支持invokedynamic指令。当Java虚拟机首次遇到某个特定的invokedynamic指令时,它会调用相应的引导方法。引导方法的任务是为invokedynamic指令提供一个方法句柄(MethodHandle),该方法句柄指向真正应该被invokedynamic指令调用的方法。

引导方法是由编译器在编译时生成的,常常放在类文件的方法表中。但是,它们与普通的Java方法有所不同,因为它们可以接受额外的参数,并且可以返回MethodHandle或其它调用站点特定的数据类型。


1.1 Lambda 表达式在字节码层面的呈现

当编译包含lambda表达式的Java代码时,编译器不会为lambda表达式生成常规的方法。相反,它将生成一个invokedynamic指令和一个引导方法。当该invokedynamic指令被执行时,相应的引导方法被调用,用于生成lambda表达式的实际方法实现并返回一个指向它的方法句柄。

字节码示例分析

考虑以下Java代码:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.forEach(name -> System.out.println(name));

当上述代码被编译时,对于lambda表达式name -> System.out.println(name),编译器不会生成一个常规的方法。而是生成如下的伪字节码:

invokedynamic Lambda$0:()Ljava/util/function/Consumer; 

这里的Lambda$0是由编译器生成的引导方法。当invokedynamic指令被执行时,Lambda$0会被调用,它返回一个方法句柄,该句柄指向一个实现了该lambda表达式的方法。

此外,编译器还会在类文件中生成一个Lambda$0的实际方法实现。这个方法包含lambda表达式的代码,即System.out.println(name)

这只是一个简化的示例,实际的字节码可能会更复杂,但它给出了lambda表达式如何在字节码层面上被表示的基本概念。

lambda表达式在字节码层面的实现充分利用了Java 7引入的invokedynamic指令和引导方法的能力,为动态语言和新的Java特性提供了强大的支持。


2. Lambda 表达式的动态生成过程

2.1. 调用动态引导方法生成 Lambda 对象

  • 背景:与传统的 Java 方法调用不同(如 invokevirtual 或 invokestatic),invokedynamic 指令并不直接调用一个特定的方法。而是依靠所谓的引导方法来动态确定应该调用的方法。

  • 过程

    1. 当 JVM 首次遇到某个特定的 invokedynamic 指令,它会触发引导方法。
    2. 引导方法使用 MethodHandles.Lookup 和其他参数来创建并返回一个 MethodHandle 对象,这个对象指向真正要被调用的方法。
    3. JVM 会缓存这个 MethodHandle,以便后续调用,从而避免多次触发引导方法。

2.2. MethodHandle 与 Lambda 的关系

  1. MethodHandle 简介
    MethodHandle 是 Java 7 引入的一个功能,位于 java.lang.invoke 包中。它可以看作是对 Java 中的方法或构造函数的直接引用,可以对任何方法进行高效的、类型安全的、动态的方法调用。方法句柄是一个强类型的,类似于指针的对象。它可以直接引用底层方法、构造函数或字段,无需使用反射。

    如之前所述,Lambda 表达式在 JVM 中使用 invokedynamic 指令来实现。invokedynamic 指令在首次执行时会调用一个特定的引导方法,这个引导方法通常会返回一个 MethodHandle,指向 Lambda 表达式的实际实现。

  2. 与 Lambda 的关系

    1. 当引导方法被触发,它为特定的 lambda 表达式生成一个 MethodHandle
    2. 这个 MethodHandle 直接引用了 lambda 表达式的实体代码。
    3. 当 invokedynamic 指令再次执行时,它直接使用这个方法句柄来调用 lambda 表达式,从而实现快速和高效的调用。
  3. 与反射的比较
    与 Java 的反射相比,MethodHandle 提供了更好的性能和更多的功能。它可以轻易地适应方法的签名变化,并且可以组合、转化和适应其他的 MethodHandle

  4. LambdaMetafactory 的主要作用
    LambdaMetafactory 是 Java 8 引入的,位于 java.lang.invoke 包中,它在 JVM 内部起到了关键的作用,负责将 lambda 表达式转化为具体的方法调用。以下是 LambdaMetafactory 的核心功能和它如何与 lambda 表达式的实现有关:

  5. Lambda 表达式转化:在 Java 8 之前,当创建一个匿名内部类来实现一个接口时,JVM 会为这个匿名内部类生成一个独立的 .class 文件。但在 Java 8 中,使用 lambda 表达式时,并不是通过传统的匿名内部类来实现的。而是通过 LambdaMetafactory 在运行时动态地生成这些实现。

  6. invokedynamic 指令支持:Java 7 引入了 invokedynamic 指令,这是一个支持动态类型语言的字节码指令。在 Java 8 中,lambda 表达式的实现也利用了这个指令。当首次调用一个 lambda 表达式时,invokedynamic 指令会触发引导方法,而这个引导方法通常会使用 LambdaMetafactory 来生成一个方法句柄(MethodHandle),这个方法句柄指向 lambda 表达式的实际实现。

LambdaMetafactory如何工作

当在代码中写下一个 lambda 表达式时,编译器并不会直接生成对应的方法实现。相反,它会为这个 lambda 生成一个 invokedynamic 调用。在运行时,当这个 invokedynamic 调用被执行的时候,它会触发一个特定的引导方法。这个引导方法使用 LambdaMetafactory 来为该 lambda 生成一个具体的方法句柄。

例如,考虑下面的 lambda 表达式:

Runnable r = () -> System.out.println("Hello World");

对于上面的 lambda,LambdaMetafactory 将会为这个 lambda 动态生成一个 Runnable 的实例。当这个实例的 run 方法被调用时,它实际上会执行 System.out.println("Hello World")

举例说明
通过一个简单的例子来深入了解 MethodHandleLambda 和 LambdaMetafactory 之间的关系。

这个简单的函数式接口 应该不用多说了。

@FunctionalInterface
interface Greeting {
    void sayHello(String name);
}

在主代码中,可能会基于这个接口创建一个 lambda 表达式

public class LambdaMetafactoryExample {
    public static void main(String[] args) {
        Greeting greeting = (name) -> System.out.println("Hello, " + name);
        greeting.sayHello("World");
    }
}

当这段代码被编译和运行时,JVM 不会为 lambda 表达式生成一个传统的匿名内部类,如我们可能预期的那样。相反,它使用 invokedynamic 指令,而 lambda 的动态创建是在运行时处理的。

在幕后,LambdaMetafactory 在将的 lambda 表达式转化为 Greeting 接口的具体实例中起到了重要作用。大致的过程如下:

  1. 引导方法调用invokedynamic 指令首先会调用引导方法。在 lambdas 的情况下,引导方法通常是 LambdaMetafactory.metafactory

  2. MethodHandle 创建LambdaMetafactory.metafactory 方法将接受几个参数,其中之一是一个 MethodHandle,指向当 lambda 被调用时应该执行的实际方法。在我们的例子中,这个方法句柄会指向实际执行 System.out.println("Hello, " + name) 的方法。

  3. Lambda 实例生成:使用上述信息,LambdaMetafactory 会生成一个新的 Greeting 接口的实例,当该实例的 sayHello 方法被调用时,它将实际上执行 System.out.println("Hello, " + name)

此过程是在 JVM 的底层发生的,对于 Java 开发者来说,这一切都是透明的。但了解这些底层细节可以帮助我们理解 lambda 在 JVM 中是如何工作的,以及它如何与 MethodHandle 和 invokedynamic 指令相关联的。

2.3. 动态生成过程的优势和意义

2.3.1 优化

由于引导方法只在首次遇到 invokedynamic 指令时被调用,后续的 lambda 表达式调用将直接使用方法句柄,这提高了效率。

LambdaMetafactory 和 invokedynamic 指令的合作使用,优化了 lambda 表达式的调用。引导方法仅在首次遇到 invokedynamic 指令时被调用。一旦方法句柄被创建并链接,后续的 lambda 表达式调用会直接使用方法句柄,这极大提高了效率。

举例:

考虑如下的 lambda 表达式:

List<String> items = Arrays.asList("A", "B", "C");
items.forEach(item -> System.out.println(item));

在这个例子中,lambda (item -> System.out.println(item)) 在首次执行时通过 invokedynamic 指令调用引导方法。引导方法通过 LambdaMetafactory 创建一个方法句柄。在后续的迭代中,已经创建的方法句柄将被直接使用。

2.3.2 灵活性

动态生成 lambda 表达式允许 JVM 在运行时做出决策,这为即时编译器(JIT)提供了优化的机会。
由于 LambdaMetafactory 和 invokedynamic 指令允许在运行时动态生成 lambda 表达式的方法句柄,JVM 可以在运行时做出优化决策。这种动态性给即时编译器(JIT)留下了优化的空间,可以根据实际运行情况进行特定优化。

如果某个 lambda 表达式在应用程序中被频繁使用,JIT 编译器可以选择为其生成优化的机器代码,提高运行效率。

2.3.3 减少膨胀

在编译时,不需要为每一个 lambda 表达式生成一个新的匿名内部类。这减少了类文件的大小和数量,进而也降低了加载这些类的开销。

在使用 lambda 表达式时,由于不会为每一个 lambda 生成一个新的 .class 文件(即不会产生匿名内部类),因此减少了类文件的数量和大小,降低了加载这些类的开销。

举例:
// 使用 Lambda 表达式
Runnable r = () -> System.out.println("Hello");

// 在 Java 8 之前,我们可能会这样做
Runnable r = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello");
    }
};

在第二个例子中,会生成一个额外的 .class 文件,而使用 lambda 表达式则不会。

2.3.4 支持动态语言

invokedynamic 指令和引导方法的设计不仅仅是为了支持 Java 的 lambda 表达式,它也可以支持其他在 JVM 上运行的动态语言,增强了 JVM 作为多语言平台的能力。

除了 Java,像 GroovyKotlin 等其他 JVM 语言也可以利用 invokedynamic 和 LambdaMetafactory 的设施,优化其在 JVM 上的执行。

3. Lambda 表达式的性能考虑

Java 8 引入的 lambda 表达式不仅简化了代码,还为性能优化提供了新的机会。在考虑 lambda 表达式的性能时,我们需要考虑以下几点:

1. JIT编译器的优化

当 lambda 表达式在运行时被频繁调用,即时编译器 (JIT) 可以对其生成高度优化的机器代码。这是由于 lambda 的实现使用 invokedynamic 和 LambdaMetafactory,允许 JIT 进行更多的运行时优化。

举例

如果你的应用有一个经常被调用的 lambda 表达式,如一个常见的数据处理管道中的操作,JIT 可以为这个特定的 lambda 生成高效的机器代码,使其运行得更快。

2. 内存使用分析

使用 lambda 表达式代替匿名内部类可以减少内存的使用。由于 lambda 不会生成传统的 .class 文件,并且其运行时的动态生成也比匿名内部类更高效,因此它们通常使用更少的内存。

举例

考虑一个常用的场景,你可能在一个集合上使用一个过滤器:

list.stream().filter(item -> item.startsWith("A")).collect(Collectors.toList());

这里的 lambda 表达式会比一个相应的匿名内部类使用更少的内存。

3. 性能对比与实践建议

尽管 lambda 表达式在许多情况下具有性能优势,但这并不意味着它们在所有场合都是最佳选择。性能测试和基准测试是确定实际性能差异的关键。

例如在某些计算密集型任务中,传统的方法可能比使用流和 lambda 更快。例如,对于简单的循环,传统的 for 循环可能比使用 Stream API 和 lambda 更高效。

我们依然是举例说明

考虑我们有一个 List<Integer>,我们想找出其中所有偶数的和。我们可以使用传统的 for 循环,也可以使用 Stream API 和 lambda 表达式来实现这个功能。

1. 使用传统的 for 循环
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int sum = 0;
for (int num : numbers) {
    if (num % 2 == 0) {
        sum += num;
    }
}
System.out.println(sum);

在这个例子中,我们使用了单个的 for 循环来遍历列表并计算所有偶数的和。

2. 使用 Stream API 和 lambda 表达式
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int sum = numbers.stream()
                 .filter(num -> num % 2 == 0)
                 .mapToInt(Integer::intValue)
                 .sum();
System.out.println(sum);

在这个例子中,我们使用了 Stream API 和 lambda 表达式来完成同样的任务。虽然代码看起来更简洁,但由于涉及到多个中间操作(过滤、映射和汇总),可能会稍微影响性能。

为什么上面使用 Stream API 和 Lambda 表达式带来的性能影响呢
  1. 多个中间操作

    在 Stream API 中,每一个中间操作(例如 filtermap 等)都会产生一个新的 Stream,这意味着在执行后续的操作时,每一个元素都需要穿越整个操作链。对于大量数据的操作,这些额外的遍历和函数调用有可能引入额外的开销。

    例如:

   numbers.stream()
          .filter(num -> num % 2 == 0)  // 遍历1
          .map(num -> num * 2)          // 遍历2
          .sum();                       // 遍历3

在上面的代码中,每个元素需要经过三次遍历:一次过滤、一次映射和一次求和。

  • 装箱和拆箱

    使用 Stream API 时,我们常常使用对象类型(例如 IntegerDouble 等)进行操作,这可能会引入自动装箱和拆箱的额外成本。

   numbers.stream()
          .filter(num -> num % 2 == 0)
          .mapToInt(Integer::intValue)  // 可能涉及拆箱操作
          .sum();
  • Lambda 表达式的创建成本

    尽管 Lambda 表达式的创建和执行经过了优化(例如通过 invokedynamic 和 LambdaMetafactory),在某些极端情况下(例如超高性能场景或大数据处理),与传统的方法调用相比,这些成本还是值得关注的。

虽然上述的几点在一些场景下可能影响性能,但在大多数情况下,这些影响相对较小,并且 Stream API 和 Lambda 表达式带来的可读性和表达能力的提升往往比这些微小的性能损耗更重要。

对于性能敏感的场景,如果有必要 我们可以通过基准测试来验证不同方法的性能表现,并据此做出合适的选择。

性能对比

所以通过上面的示例,虽然在大多数情况下,这两种方法的性能差异可以忽略不计,但在处理大量数据时,传统的 for 循环可能会稍微快一点。这是因为 for 循环的所有操作都在一个循环中完成,而 Stream API 在每个中间操作中都会遍历整个流。

然而,这并不意味着我们应该避免使用 Stream API 和 lambda 表达式。它们提供了更加简洁和函数式的编程方式,对编写可读和易维护的代码很有帮助。并且,如果我们使用 parallelStream,还可以很方便地利用多核处理器进行并行计算。

所以我觉得在选择使用哪种方法时,我们应该根据具体的情况来考虑,例如代码的可读性、简洁性,以及性能需求等因素。

实践建议

  • 代码可读性:首先,应考虑代码的可读性和维护性。Lambda 表达式和 Stream API 可以使代码更加简洁和易读。

  • 性能测试:在决定使用 lambda 还是传统方法之前,进行性能测试和基准测试。这有助于确定哪种方法更适合你的特定情况。

  • 避免过度优化:除非性能是一个关键问题,否则不要过度优化。通常,简洁、可读和可维护的代码更为重要。

4. 参考文档

  1. https://blog.51cto.com/u_15346609/5646012

  2. https://docs.oracle.com/javase/8/docs/api/java/lang/invoke/LambdaMetafactory.html#metafactory-java.lang.invoke.MethodHandles.Lookup-java.lang.String-java.lang.invoke.MethodType-java.lang.invoke.MethodType-java.lang.invoke.MethodHandle-java.lang.invoke.MethodType-

  3. https://www.oracle.com/technetwork/java/jvmls2013kuksen-2014088.pdf

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值