3年前 (2021-09-14)  Java系列 |   抢沙发  522 
文章评分 0 次,平均分 0.0
[收起] 文章目录

Java8于2014年3月发布,并引入了lambda表达式作为其旗舰功能。您可能已经在代码库中使用它们来编写更简洁、更灵活的代码。例如,您可以将lambda表达式与新的Streams API结合起来,以表达丰富的数据处理查询:

int total = invoices.stream()
                    .filter(inv -> inv.getMonth() == Month.JULY)
                    .mapToInt(Invoice::getAmount)
                    .sum();

此示例显示如何从发票集合中计算7月份到期的总金额。传递lambda表达式以查找月份为7月的发票,并传递方法引用以从发票中提取金额。

您可能想知道Java编译器如何在幕后实现lambda表达式和方法引用,以及Java虚拟机(JVM)如何处理它们。例如,lambda表达式只是匿名内部类的语法糖吗?毕竟,可以通过将lambda表达式的主体复制到匿名类的相应方法的主体中来翻译上面的代码(我们不鼓励您这样做!):

int total = invoices.stream()
                    .filter(new Predicate<Invoice>() {
                        @Override
                        public boolean test(Invoice inv) {
                            return inv.getMonth() == Month.JULY;
                        }
                    })
                    .mapToInt(new ToIntFunction<Invoice>() {
                        @Override
                        public int applyAsInt(Invoice inv) {
                            return inv.getAmount();
                        }
                    })
                    .sum();

本文将解释为什么Java编译器不遵循这种机制,并将阐明lambda表达式和方法引用是如何实现的。我们将研究字节码生成,并在实验室中简要分析lambda性能。最后,我们将讨论现实世界中的性能影响。

为什么匿名内部类不能令人满意?

匿名内部类具有可能影响应用程序性能的不良特征。

首先,编译器为每个匿名内部类生成一个新的类文件。文件名通常看起来像ClassName$1,其中ClassName是定义匿名内部类的类的名称,后跟一个美元符号和一个数字。生成许多类文件是不可取的,因为每个类文件在使用之前都需要加载和验证,这会影响应用程序的启动性能。加载可能是一项昂贵的操作,包括磁盘I/O和解压缩JAR文件本身。

如果将lambda转换为匿名内部类,则每个lambda都会有一个新的类文件。由于每个匿名内部类都将被加载,因此它将占用JVM元空间的空间(这是永久生成的Java8替代品)。如果JVM将每个匿名内部类中的代码编译成机器代码,那么它将存储在代码缓存中。此外,这些匿名内部类将被实例化为单独的对象。因此,匿名内部类会增加应用程序的内存消耗。引入缓存机制以减少所有这些内存开销可能会有所帮助,这促使引入某种抽象层。

最重要的是,从第一天起选择使用匿名内部类实现lambda将限制未来lambda实现更改的范围,以及它们根据未来JVM改进而发展的能力。

让我们看一下以下代码:

import java.util.function.Function;
public class AnonymousClassExample {
    Function<String, String> format = new Function<String, String>() {
        public String apply(String input){
            return Character.toUpperCase(input.charAt(0)) + input.substring(1);
        }
    };
}

您可以使用命令检查为任何类文件生成的字节码

javap -c -v ClassName 

为作为匿名内部类创建的函数生成的相应字节码如下所示:

0: aload_0       
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0       
5: new           #2 // class AnonymousClassExample$1
8: dup           
9: aload_0       
10: invokespecial #3 // Method AnonymousClass$1."<init>":(LAnonymousClassExample;)V
13: putfield      #4 // Field format:Ljava/util/function/Function;
16: return  

此代码显示以下内容:

  • 5:使用字节码操作new实例化匿名类示例$1类型的对象。同时在堆栈上推送对新创建对象的引用。
  • 8:dup操作在堆栈上复制该引用。
  • 10:然后,该值由invokespecial指令使用,该指令初始化匿名内部类实例。
  • 13:堆栈顶部现在仍然包含对对象的引用,该引用使用putfield指令存储在AnonymousClassExample类的format字段中。

AnonymousClassExample$1是编译器为匿名内部类生成的名称。如果您想让自己放心,还可以检查AnonymousClassExample$1类文件,您将找到函数接口实现的代码。

将lambda表达式转换为匿名内部类将限制未来可能的优化(例如缓存),因为它们与匿名内部类字节码生成机制相关联。因此,语言和JVM工程师需要一个稳定的二进制表示,该表示提供了足够的信息,同时允许JVM在将来使用其他可能的实现策略。下一节将解释这是如何实现的!

Lambdas和invokedynamic

为了解决上一节中解释的问题,Java语言和JVM工程师决定将转换策略的选择推迟到运行时。Java7引入的新invokedynamic字节码指令为他们提供了一种高效实现这一点的机制。lambda表达式到字节码的转换分两步执行:

1. 生成一个invokedynamic调用站点(称为lambda工厂),调用该站点时,该站点返回lambda正在转换到的功能接口的实例;

2. 将lambda表达式体转换为将通过invokedynamic指令调用的方法。

为了说明第一步,让我们检查编译包含lambda表达式的简单类时生成的字节码,例如:

import java.util.function.Function;

public class Lambda {
    Function<String, Integer> f = s -> Integer.parseInt(s);
}

这将转换为以下字节码:

0: aload_0
 1: invokespecial #1 // Method java/lang/Object."<init>":()V
 4: aload_0
 5: invokedynamic #2, 0 // InvokeDynamic
                  #0:apply:()Ljava/util/function/Function;
10: putfield #3 // Field f:Ljava/util/function/Function;
13: return

请注意,方法引用的编译方式略有不同,因为javac不需要生成合成方法,可以直接引用该方法。

第二步的执行方式取决于lambda表达式是非捕获(lambda不访问在其主体外部定义的任何变量)还是捕获(lambda访问在其主体外部定义的变量)。

非捕获lambda被简单地分解为一个静态方法,该方法具有与lambda表达式完全相同的签名,并在使用lambda表达式的同一类中声明。例如,可以将上面lambda类中声明的lambda表达式分解为如下方法:

static Integer lambda$1(String s) {
    return Integer.parseInt(s);
}

注意:$1不是一个内部类,它只是我们表示编译器生成代码的方式

捕获lambda表达式的情况稍微复杂一些,因为捕获的变量必须与lambda的形式参数一起传递给实现lambda表达式主体的方法。在这种情况下,常见的转换策略是在lambda表达式的参数前面加上每个捕获变量的附加参数。让我们看一个实际的例子:

int offset = 100;
Function<String, Integer> f = s -> Integer.parseInt(s) + offset; 

相应的方法实现可以通过asy生成:

static Integer lambda$1(int offset, String s) {
    return Integer.parseInt(s) + offset;
}

然而,这种转换策略并不是一成不变的,因为invokedynamic指令的使用使编译器能够灵活地在将来选择不同的实现策略。例如,捕获的值可以装箱到数组中,或者,如果lambda表达式读取使用它的类的某些字段,则生成的方法可以是实例方法,而不是声明为静态的,从而避免将这些字段作为附加参数传递。

性能表现

这种方法的主要优点是性能特性。如果把它们看作是可以简化为一个数字,那就太好了,但实际上这里涉及到多个操作。

第一部分是联动步骤,与上述lambda工厂步骤相对应。如果我们将性能与匿名内部类进行比较,那么等效的操作将是匿名内部类的类加载。Oracle已经发布了Sergey Kuksenko对这一权衡的性能分析,您可以看到Kuksenko在2013年JVM语言峰会上就这一主题发表了演讲[3]。分析表明,需要时间来预热lambda工厂方法,在此过程中,初始速度较慢。当有足够多的调用站点链接时,如果代码位于热路径上(即调用频率足以编译JIT的路径),则性能与类加载一致。另一方面,如果是冷路径,lambda工厂方法可以快100倍。

第二步是从周围范围捕获变量。正如我们已经提到的,如果没有要捕获的变量,那么可以自动优化此步骤,以避免使用基于lambda工厂的实现分配新对象。在匿名内部类方法中,我们将实例化一个新对象。为了优化等效情况,您必须通过创建单个对象并将其提升到静态字段来手动优化代码。例如:

// Hoisted Function
public static final Function<String, Integer> parseInt = new Function<String, Integer>() {
    public Integer apply(String arg) {
        return Integer.parseInt(arg);
    }
}; 

// Usage:
int result = parseInt.apply(“123”);

第三步是调用实际方法。目前,匿名内部类和lambda表达式都执行完全相同的操作,因此这里的性能没有差异。非捕获lambda表达式的开箱即用性能已经领先于提升的匿名内部类。捕获lambda表达式的实现与分配匿名内部类以捕获这些字段的性能类似。

我们在本节中看到,lambda表达式的实现大体上表现良好。虽然匿名内部类需要手动优化以避免分配,但JVM已经为我们优化了最常见的情况(一个不捕获其参数的lambda表达式)。

当然,理解整体性能模型是很好的,但是在实践中,事情是如何叠加的呢?我们已经在一些软件项目中使用了Java8,并取得了积极的成果。自动优化非捕获lambda可以提供很好的好处。这里有一个特别的例子,它提出了一些关于未来优化方向的有趣问题。

所讨论的示例发生在处理某些代码以供系统使用时,该系统需要特别低的GC暂停,理想情况下没有。因此,希望避免分配太多的对象。该项目广泛使用lambdas来实现回调处理程序。不幸的是,我们仍然有相当多的回调,其中我们没有捕获局部变量,但希望引用当前类的字段,甚至只调用当前类上的方法。目前,这似乎仍然需要分配。下面是一个代码示例,旨在阐明我们所讨论的内容:

public MessageProcessor() {} 

public int processMessages() {
    return queue.read(obj -> {
        if (obj instanceof NewClient) {
            this.processNewClient((NewClient) obj);
        } 
        ...
    });
}

这个问题有一个简单的解决办法。我们将代码提升到构造函数中,并将其分配给一个字段,然后在调用站点直接引用该字段。下面是我们之前重写的代码示例:

private final Consumer<Msg> handler; 

public MessageProcessor() {
    handler = obj -> {
        if (obj instanceof NewClient) {
            this.processNewClient((NewClient) obj);
        }
        ...
    };
} 

public int processMessages() {
    return queue.read(handler);
}

在所讨论的项目中,这是一个严重的问题:内存分析显示,此模式负责前八个对象分配站点中的六个,以及应用程序总分配的60%以上。

与任何潜在的优化一样,无论环境如何,应用这种方法都可能会带来其他问题。

您选择编写非惯用代码纯粹是出于性能原因。因此有一个可读性权衡

这也关系到分配的权衡。您正在向MessageProcessor添加一个字段,使其更大,以便分配。相关lambda的创建和捕获也会减慢对MessageProcessor的构造函数调用。

我们不是通过寻找场景,而是通过内存分析发现了这种情况,并且有一个很好的业务用例证明了优化的合理性。我们还处于这样一个位置:对象只分配一次,大量重用lambda表达式,因此缓存非常有益。与任何性能调整练习一样,通常推荐使用科学方法。

这也是任何其他最终用户寻求优化其lambda表达式使用的方法。尝试编写干净、简单且功能强大的代码始终是最好的第一步。任何优化,如本次吊装,应仅针对真正的问题进行。编写捕获分配对象的lambda表达式本身并不坏——正如编写调用'new Foo()'的Java代码本身也不坏一样。

这一经验也确实表明,要充分利用lambda表达式,重要的是要习惯地使用它们。如果lambda表达式用于表示小的纯函数,则它们几乎不需要从其周围范围捕获任何内容。和大多数事情一样,如果你保持简单,事情就会表现得很好。

结论

在本文中,我们解释了lambda不仅仅是隐藏的匿名内部类,以及为什么匿名内部类不是lambda表达式的合适实现方法。通过lambda表达式实现方法,已经进行了大量的工作。目前,对于大多数任务,它们都比匿名内部类快,但当前的状态并不完美;测量驱动的手动优化仍有一定的空间。

Java8中使用的方法不仅仅局限于Java本身。Scala历来通过生成匿名内部类来实现其lambda表达式。在Scala2.12中,我们已经开始使用Java8中引入的lambda元工厂机制。随着时间的推移,JVM上的其他语言也可能采用这种机制。

原文地址:https://www.infoq.com/articles/Java-8-Lambdas-A-Peek-Under-the-Hood/

 

除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/2339.html

关于

发表评论

表情 格式

暂无评论

登录

忘记密码 ?

切换登录

注册