深入理解Java的Lambda原理

原文链接:

文章目录

  • Lambda表达式的背景
  • 传递一段代码
    • 匿名内部类写法
    • Lambda写法
  • 两种写法的实际操作
    • 匿名内部类的实际操作
    • Lambda写法的实际操作
  • Lambda表达式 VS 匿名内部类
  • 总结

Lambda表达式的背景

Lambda函数的概念其实有很久远的历史了,在Lisa,C#中早有实现。且近年来,开发者对语言的表现力有了更高的要求,Java也在JDK 1.8 中引入了Lambda函数这一概念。虽然截止到写下这段文字的一刻已经过去七年之久,但其底层的设计思想仍值得我们参考一番,以便我们更好地使用。
Lambda表达式的核心思想,就是将一段函数作为参数进行传递。那要怎么实现这个目的呢?我们慢慢来看一下。

传递一段代码

传递一个函数作为参数,这在C里面很好实现,函数指针嘛,一两个*号&号能解决的问题,C的语言特性具有先天的优势。
但是Java呢?我们都知道,Java的函数接收的参数都是对象(这个时候可以认为基本类型也是对象),可从来没听说过有函数对象这个说法啊。那要怎么整?
设想有以下场景,函数executeFunc,它接收一个函数,用这个接受到的函数打印另外一个传入的变量word。
文章知识点与官方知识档案匹配,可进一步学习相关知识

static void executeFunc(方法??,String word) {
    // todo 用传入的方法打印word变量
}

匿名内部类写法

先介绍一种曲线救国的方式,勤劳勇敢的程序员们想出来,直接传函数不行是吧,那我就用一个接口包裹住这个方法,传接口对象进去就可以啦!好方法,我们下面来看看代码。

package main;

interface Wrapper {
    
    
    void myPrint(String w);
}

class Solution {
    
    

    static void executeFunc(Wrapper w, String word) {
    
    
        w.myPrint(word);
    }


}

确实,问题解决了。那怎么在主函数调用呢?

public static void main(String[] args) {
    
    
        executeFunc(new Wrapper() {
    
    
            @Override
            public void myPrint(String w) {
    
    
                // 个性化拓展,例如在打印之前记录时间什么的
                System.out.println(w);
            }
        }, "Hello Lambda!");
    }

看起来有一点麻烦,如果需要用executeFunc的次数多起来的时候,显然就会造成很多“不太必要”的代码了。这里说的不太必要是因为,我们必须满足编译器的需求来进行规范的语法编写,但实际上我们关心的逻辑仅仅是里面那一小段打印的语句:

// 个性化拓展,例如在打印之前记录时间什么的
System.out.println(w);

这就是lambda派上用场的地方了。

Lambda写法

其实如果你在编译器编写以上代码时,你会收到一个灰色的智能提示,让你用lambda重写以上语句。

我们应用一下这个修改试试看。

public static void main(String[] args) {
    
    
    executeFunc(w -> {
    
    
        // 个性化拓展,例如在打印之前记录时间什么的
        System.out.println(w);
    }, "Hello Lambda!");
}

我们看到,一大串的new操作,@Override重写被替换成一个传入参数,一对大括号,代码量大大减少。(实际上可以通过方法引用来进一步化简,但是在这里暂不作讨论了)

两种写法的实际操作

终于介绍完两种常见的写法了。看了上面的写法,可能有人就会说,这看起来不是差不多嘛,lambda就是帮我们完成了那些override啊之类的东西的重写嘛,语法糖,甜甜的,就这?
非也非也,Lambda是Java 8的一个重要升级特性,为了自动生成这个语句,编译器装个插件啥的,写个宏定义啥的不就好了吗?为什么要费这么大功夫来折腾?下面就来细细分析两者的区别。下面会涉及到一些字节码的理解,不过不用担心,要用到的字节码指令我会讲清楚的(我也曾经深受其苦)。
完整代码如下,我们基于以下代码进行分析。

package main;

interface Wrapper {
    
    
    void myPrint(String w);
}

class Solution {
    
    

    static void executeFunc(Wrapper w, String word) {
    
    
        w.myPrint(word);
    }

    public static void main(String[] args) {
    
    
        // 匿名内部类写法
        executeFunc(new Wrapper() {
    
    
            @Override
            public void myPrint(String w) {
    
    
                // 个性化拓展,例如在打印之前记录时间什么的
                System.out.println(w);
            }
        }, "Hello Lambda!");
        
        // lambda写法
        executeFunc(w -> {
    
    
            // 个性化拓展,例如在打印之前记录时间什么的
            System.out.println(w);
        }, "Hello Lambda!");
 
    }
}

匿名内部类的实际操作

我们先把lambda部分注释掉,仅保留匿名内部类写法的语句。利用IDEA的ShowByteCode功能进行查看。字节码(ByteCode)是JVM运行时读取执行的,一切的语法特性,都会在这里暴露无遗。下面先贴一段字节码。

// class version 55.0 (55)
// access flags 0x20
class main/Solution {
    
    

  // compiled from: Solution.java
  NESTMEMBER main/Solution$1
  // access flags 0x0
  INNERCLASS main/Solution$1 null null

  // access flags 0x0
  <init>()V
   L0
    LINENUMBER 7 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this Lmain/Solution; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x8
  static executeFunc(Lmain/Wrapper;Ljava/lang/String;)V
   L0
    LINENUMBER 10 L0
    ALOAD 0
    ALOAD 1
    INVOKEINTERFACE main/Wrapper.myPrint (Ljava/lang/String;)V (itf)
   L1
    LINENUMBER 11 L1
    RETURN
   L2
    LOCALVARIABLE w Lmain/Wrapper; L0 L2 0
    LOCALVARIABLE word Ljava/lang/String; L0 L2 1
    MAXSTACK = 2
    MAXLOCALS = 2

  // access flags 0x9
  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 15 L0
    NEW main/Solution$1
    DUP
    INVOKESPECIAL main/Solution$1.<init> ()V
    LDC "Hello Lambda!"
    INVOKESTATIC main/Solution.executeFunc (Lmain/Wrapper;Ljava/lang/String;)V
   L1
    LINENUMBER 29 L1
    RETURN
   L2
    LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1
}


JVM里是不管你内部内部类的,它只认类和类实例化的对象。众所周知,接口是不能实例化的,也就是说,是不能通过直接new的方式新建一个纯接口对象,而是要编写一个类来实现接口,进而实例化这个类。那注意到我们上面的语句

//*****
new Wrapper() {
    
    
            @Override
            public void myPrint(String w) {
    
    
                // 个性化拓展,例如在打印之前记录时间什么的
                System.out.println(w);
            }
        }
//*****

这不就违背了这一规则了吗?其实不然。它看起来是这样写的,实际上还是通过类来实例化对象,只不过这个工作JVM替我们完成了。
回到字节码,在第六行,我们发现编译器新建了一个类,叫Solution$1。在42行进行了new操作,新建了一个Solution 1 的对象以供后续操作。在 44 行进行了这个对象的初始化工作, i n i t 嘛,大家都懂。本来内部类的名字应该是类似 S o l u t i o n 1的对象以供后续操作。在44行进行了这个对象的初始化工作,init嘛,大家都懂。 本来内部类的名字应该是类似Solution 1的对象以供后续操作。在44行进行了这个对象的初始化工作,init嘛,大家都懂。本来内部类的名字应该是类似SolutionMyInnerClass这样的命名的,$符号之前其所在的父类Solution,MyInnerClass是内部类的名字。但是由于我们创建的是实现了Wrapper接口的匿名内部类,匿名没有名字,编译器就想,干脆就叫1吧,没错,就简简单单用个1来命名了。什么?不是说不能用数字命名类吗?注意,我们平时编程就是写给老板(JVM)看的,所以有一大串语法规则,老板看了说OK,我再加一个东西,这个东西我就要这样子,你也不能说什么对吧。
不信你就在匿名内部类的实现里加一个语句,通过反射获取当前匿名内部类的类名,就会发现人家这个内部类的的确确叫 1,完整名字是main.Solution$1

// 匿名内部类写法
executeFunc(new Wrapper() {
    
    
    @Override
    public void myPrint(String w) {
    
    
        // this指向当前匿名内部类的对象,通过对象获取类名
        System.out.println(this.getClass().getName());
        // 个性化拓展,例如在打印之前记录时间什么的
        System.out.println(w);
    }
}, "Hello Lambda!");

输出结果

C:\jdk11\bin\java.exe....
main.Solution$1
Hello Lambda!

Process finished with exit code 0

通过上面的例子,这样也可以理解什么是匿名内部类的“匿名”二字是什么意思了。

Lambda写法的实际操作

那么使用Lambda表达式的时候又发生了什么呢?还是之前的代码,我们注释掉匿名内部类的实现,查看其字节码。

// class version 55.0 (55)
// access flags 0x20
class main/Solution {
    
    

  // compiled from: Solution.java
  // access flags 0x19
  public final static INNERCLASS java/lang/invoke/MethodHandles$Lookup java/lang/invoke/MethodHandles Lookup

  // access flags 0x0
  <init>()V
   L0
    LINENUMBER 8 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this Lmain/Solution; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x8
  static executeFunc(Lmain/Wrapper;Ljava/lang/String;)V
   L0
    LINENUMBER 11 L0
    ALOAD 0
    ALOAD 1
    INVOKEINTERFACE main/Wrapper.myPrint (Ljava/lang/String;)V (itf)
   L1
    LINENUMBER 12 L1
    RETURN
   L2
    LOCALVARIABLE w Lmain/Wrapper; L0 L2 0
    LOCALVARIABLE word Ljava/lang/String; L0 L2 1
    MAXSTACK = 2
    MAXLOCALS = 2

  // access flags 0x9
  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 26 L0
    INVOKEDYNAMIC myPrint()Lmain/Wrapper; [
      // handle kind 0x6 : 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;
      // arguments:
      (Ljava/lang/String;)V, 
      // handle kind 0x6 : INVOKESTATIC
      main/Solution.lambda$main$0(Ljava/lang/String;)V, 
      (Ljava/lang/String;)V
    ]
    LDC "Hello Lambda!"
    INVOKESTATIC main/Solution.executeFunc (Lmain/Wrapper;Ljava/lang/String;)V
   L1
    LINENUMBER 31 L1
    RETURN
   L2
    LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1

  // access flags 0x100A
  private static synthetic lambda$main$0(Ljava/lang/String;)V
   L0
    LINENUMBER 28 L0
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ALOAD 0
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
   L1
    LINENUMBER 29 L1
    RETURN
   L2
    LOCALVARIABLE w Ljava/lang/String; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1
}


字节码有点长,但是关键的地方就那么几个,我们仔细看看。
首先看到61行的这个地方。

private static synthetic lambda$main$0(Ljava/lang/String;)V

这个看起来很像一个函数的东西,其实就是一个函数,synthetic这个词的意思本来就是“人造的; (人工)合成的; 综合(型)的;”,作为关键字,它表示该方法由编译器自动生成。简而言之,你把它当做void,就很好阅读了。这是一个无返回值的静态方法,名称都定好啦,叫lambda$main$0,接受一个String类型的参数。里面又有一系列操作,println啥的。
诶?仔细想想,是不是很像我们之前写的那个东西?没错,就是那个lambda表达式。

w -> {
    
    
    // 个性化拓展,例如在打印之前记录时间什么的
    System.out.println(w);
}
// 就是上面这货

编译器把我们写的lambda表达式转换成了一个静态的私有函数,通过调用这个函数来解决传递一段代码的问题。一切都已经明了~
我们可以尝试在lambda表达式内部使用this关键字,试图像之前匿名内部类那样,获取当前类的类名,其实这个时候通过IDE你也能发现一些端倪了,它会提示你

不能把this放到一个静态的语境之下,通过上面的分析得知,这个“语境”指的就是静态函数了。
等等还有个问题,JVM是怎么知道,当需要运行这个lambda函数时就执行的?这就是JDK在底层做的处理了,加入了INVOKEDYNAMIC这个指令。
我们看到41行,有一个INVOKEDYNAMIC指令,这正是我们编写的lambda表达式进行初始换转换的地方。我们肯定不希望,在我们写lambda表达式的地方马上发生调用,因为这就没“传递代码”这个意义了,因此,jdk帮我们插入了这个指令,告诉JVM跑到这里的时候不要多事去跑一下这个函数,有需要的时候才跑。
INVOKEDYNAMIC指令,顾名思义,就是动态激活。那这个动态是怎么理解的?这里的"动态"意思是,在运行时才确定,这个调用lambda函数的下一步指向哪里。在初始状态(程序编译成字节码后但未运行时),这个标记是空的,不会执行任何动作。当程序开始执行时,具体一点就是发生字节码的43行,初始化就开始了。在运行时确定,无外乎反射,仔细看43行的一大串包,也确实是通过反射实现的。更多的细节我就不再深入,免得大伙看腻了,回到我们的代码层。说个题外话,我一直觉得完全脱离了现有的知识体系去学东西是比较困难且枯燥的,在现有知识体系的基础上扩展却往往是快乐的。
(想了解更多关于invokedynamic的知识,可以参考Oracle官方文档的The invokedynamic Instruction章节)
至此,就完成了上面两种“传递一段代码”的解析,我们终于知道上面两种方法是怎么实现传递代码功能的了。那么JDK为什么选择了Lambda这种实现方式呢?为什么不劳其烦地引入新的指令?下面就来分析一下两种实现方法的区别。

Lambda表达式 VS 匿名内部类

先说匿名内部类。通过上面的分析我们知道,匿名内部类对于传递一段代码的实现,实际上是通过创建一个实现接口的内部类,这个工作我们交给了编译器完成,所以是匿名的,我们只是新建了一个对象,传给了需要这段代码的函数。那么其优劣点就和内部类有密切的关系了。
由内部类产生的对象,默认会持有外部对象的引用。其实也不难理解,都说了是内部内部,你不说是,怎么知道是谁的内部呢?这种实现传递代码的方法,带来了一定的好处,按照Oracle官方的说法

  • It is a way of logically grouping classes that are only used in one place: If a class is useful to only one other class, then it is logical to embed it in that class and keep the two together. Nesting such “helper classes” makes their package more streamlined.
  • It increases encapsulation: Consider two top-level classes, A and B, where B needs access to members of A that would otherwise be declared private. By hiding class B within class A, A’s members can be declared private and B can access them. In addition, B itself can be hidden from the outside world.
  • It can lead to more readable and maintainable code: Nesting small classes within top-level classes places the code closer to where it is used.

大意就是设计方面更科学,逻辑方面更合理,可读性更强之类的。
在实践中大伙好像对这些特性不怎么重视,很正常,因为,"传递一段代码"这么简单工作,你跟我讲这些?
另外,由于Java的权限机制,内部类可以很方便地访问到外部类的变量,如下代码,在InnerClass的myPrint方法中使用outerPhone不会产生任何错误。

// 内部类很方便
class Solution {
    
    
    String outerPhone = "13721211111";
    
    private class InnerClass implements Wrapper {
    
    
        
        @Override
        public void myPrint(String w) {
    
    
            System.out.println("正在打给"+outerPhone);
        }
    }
}

但是它带来的缺点却是让我们有点难以接受的。前面提到的,匿名内部类经过编译器编译之后会在字节码中生成一个实际的类,那这个类就要遵循JVM的一系列规定,执行包括加载,验证,准备,初始化的那一套流程。这个过程往往是代价比较昂贵的。设想,像Android 开发里大量使用lambda的情况,如果用内部类来实现,就会在编译时产生大量的类,这个开销是不可忽略的。
其次,也如前面所提到,匿名内部类默认会持有外部对象的引用。这里可能就会产生由生命周期引发的内存泄漏问题了,注意是“可能”。熟悉Android开发的同学应该了解过handler的内存泄漏问题,这就是用匿名内部类实现传递代码逻辑的弊端突出体现了。没了解过Android开发的可以设想这样一个情况:匿名内部类的方法中做了一个耗时的操作,因为耗时,所以这个匿名对象迟迟不能被回收,而它的外部类对象已经被废弃掉了,系统本应回收这个东西,可正因为它被那个耗时的匿名对象持有了,不能被回收,由此就会导致内存泄漏。这是非常致命的。
而Lambda写法就不会有这个问题,因为它是静态的,不会持有外部对象。实际上,Lambda写法除了上面提到的,转化为静态方法执行之外,还有一种转化为静态内部类的实现。可以看到这个加粗的静态二字,我认为这就是Lambda和匿名内部类最大的区别所在。

总结

说了一大堆两者的区别,总结就是能用lambda就尽量用lambda。不仅好看,而且高效好用。笔者看来,lambda带来的动态表现是革命性的,它让很多开发工作变得简单,可以有更高的抽象程度,Android开发里已经有越来越多的应用例子了。如果你真的爱上了Lambda,那我建议你使用Kotlin作为首选的编程语言,因为Kotlin的Lambda是真真正正地把函数视作了一个对象,底层支持也更完善。


原文链接:https://blog.csdn.net/weixin_43687181/article/details/116053262

猜你喜欢

转载自blog.csdn.net/riding_horse/article/details/130060186