Administrator
发布于 2019-04-02 / 416 阅读
4

Lambda 表达式与函数式接口:从会用到了解实现

同事说"Lambda 就是匿名内部类的语法糖"

三月底做代码评审,我提了个建议说"这里可以用 Lambda 简化"。同事回了句:"Lambda 不就是匿名内部类的语法糖吗,性能还差一点,没必要改。"

我说不上来哪里不对,但记得在哪看过说 JDK 8 的 Lambda 底层用的是 invokedynamic,不是匿名内部类。为了不继续丢人,我回去把字节码翻了一遍。

反编译看看

写两个功能完全一样的类:

// A:匿名内部类
public class AnonymousDemo {
    public static void main(String[] args) {
        Runnable r = new Runnable() {
            @Override
            public void run() {
                System.out.println("hello");
            }
        };
        r.run();
    }
}

// B:Lambda
public class LambdaDemo {
    public static void main(String[] args) {
        Runnable r = () -> System.out.println("hello");
        r.run();
    }
}

编译之后先看产物文件:

$ javac AnonymousDemo.java LambdaDemo.java
$ ls *.class
AnonymousDemo$1.class      ← 匿名内部类编译期就生成了独立的 class 文件
AnonymousDemo.class
LambdaDemo.class           ← 没有 LambdaDemo$1.class

第一处差别就出现了。再看 LambdaDemo 的字节码:

$ javap -p -v LambdaDemo
public class LambdaDemo
  ...
{
  public static void main(java.lang.String[]);
    Code:
       0: invokedynamic #2,  0     // InvokeDynamic #0:run:()Ljava/lang/Runnable;
       5: astore_1
       6: aload_1
       7: invokeinterface #3,  1   // InterfaceMethod java/lang/Runnable.run:()V
      12: return

  private static void lambda$main$0();
    Code:
       0: getstatic     #4    // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #5    // String hello
       5: invokevirtual #6    // Method java/io/PrintStream.println:(...)V
       8: return

  BootstrapMethods:
    0: #27 REF_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;
      Method arguments:
        #28 ()V
        #29 REF_invokeStatic LambdaDemo.lambda$main$0:()V
        #30 ()V
}

三点结论:

  • Lambda 的方法体被编译成了所在类的一个私有静态方法 lambda$main$0(如果是实例方法则是私有实例方法)
  • main 方法里出现的是 invokedynamic 指令,不是 new
  • BootstrapMethods 里记录了引导方法 LambdaMetafactory.metafactory 以及三个参数:接口方法签名、指向 lambda$main$0 的句柄、实际的方法签名

所以"匿名内部类的语法糖"这个说法是不准确的。匿名内部类在编译期就确定了类结构,Lambda 把"生成什么类"这个决定推迟到了运行时。

invokedynamic 到底做了什么

invokedynamic 是 JDK 7 引入的字节码指令,最初是为了在 JVM 上跑 Ruby、Groovy 这类动态语言。它的思路是:这条指令第一次执行的时候,调用一个"引导方法"(Bootstrap Method)来决定它到底要干什么,之后把这个决定缓存下来,后续执行直接用。

用在 Lambda 上,引导方法就是 LambdaMetafactory.metafactory,它在运行时用 ASM 生成一个实现了目标接口的类,这个类的方法体就是调用 lambda$main$0。生成完之后用 Unsafe.defineAnonymousClass(JDK 8 的做法)加载,并返回一个 CallSite 绑定到这条 invokedynamic 指令上。

JDK 8 有个参数可以把这些运行时生成的类 dump 出来:

$ java -Djdk.internal.lambda.dumpProxyClasses=/tmp/lambda LambdaDemo
$ ls /tmp/lambda
LambdaDemo$$Lambda$1.class  LambdaDemo$$Lambda$2.class

反编译其中一个,结构非常朴素:

final class LambdaDemo$$Lambda$1 implements Runnable {
    private LambdaDemo$$Lambda$1() { }

    public void run() {
        LambdaDemo.lambda$main$0();      // 直接调用编译期生成的静态方法
    }
}

这个设计的实际好处有两个:一是生成的类不落盘、不进 classpath,类加载的开销小;二是 JVM 有机会把 invokedynamic 和后面的调用一起做 JIT 内联优化。

性能实测

我跑了个循环调用 1000 万次的对比(JMH,预热后测量):

写法平均单次调用首次调用(含链接)
Lambda2.3 ns约 11 ms
匿名内部类2.5 ns0.9 ms

稳态下两者没有实质差别,所谓"Lambda 性能差"主要指第一次执行要跑引导方法(生成类、定义类),一次约 11 毫秒。这个开销在启动阶段,对长期运行的服务端程序基本无所谓,但如果写在冷启动很敏感的路径上要留意。

变量捕获为什么要求 effectively final

这个限制我一开始觉得莫名其妙,直到想清楚 Lambda 捕获的实现方式。

public void demo() {
    int count = 0;
    Runnable r = () -> System.out.println(count);   // 编译通过
    count++;                                         // 加上这句就报错
    r.run();
}
error: local variables referenced from a lambda expression
       must be final or effectively final

原因和匿名内部类一致:局部变量存在栈帧里,而 Lambda 生成的对象可能在另一个线程、另一个时间点执行,那时候栈帧早就没了。所以 JVM 做的是复制一份值,存进生成类的字段里。

看捕获了变量的 Lambda 生成的类就明白了:

final class LambdaDemo$$Lambda$1 implements Runnable {
    private final int arg$1;                    // 捕获的变量变成了实例字段
    private LambdaDemo$$Lambda$1(int var1) {
        this.arg$1 = var1;
    }
    public void run() {
        LambdaDemo.lambda$main$0(this.arg$1);
    }
}

如果允许在 Lambda 外面修改 count,外面改的是栈上的原变量,Lambda 里看到的是自己的副本,两边不一致,比禁止修改更让人困惑。所以干脆要求它事实上的 final(effectively final),也就是"声明之后没有再赋值"。

注意是"没有再赋值",不是"必须用 final 修饰"。JDK 8 之后只要不改就行,不用显式写 final。而且数组和对象的引用不能改,但内容可以改:

int[] counter = {0};
Runnable r = () -> counter[0]++;    // 合法,改的是数组内容不是引用

这个技巧我不推荐用,多线程下 counter[0]++ 本身也不是原子的。

一个容易踩的坑:this 的指向变了

在匿名内部类里,this 指向内部类实例自己;在 Lambda 里,this 指向的是外层对象,因为 Lambda 编译出来就是外层类的一个方法。

public class ThisDemo {
    private String name = "outer";

    public void anonymous() {
        Runnable r = new Runnable() {
            private String name = "inner";
            @Override
            public void run() {
                System.out.println(this.name);        // 输出 inner
            }
        };
        r.run();
    }

    public void lambda() {
        Runnable r = () -> System.out.println(this.name);   // 输出 outer
        r.run();
    }
}

把匿名内部类改成 Lambda 时,如果代码里用到了 this,行为会变。我们项目改造时踩过一次,一个回调里用 this 引用了内部类的字段,改成 Lambda 之后空指针。

常用函数式接口和方法引用

java.util.function 包里 40 多个接口,日常用的就这几个:

接口方法用途
Predicate<T>boolean test(T)判断,filter 用
Function<T,R>R apply(T)转换,map 用
Consumer<T>void accept(T)消费,forEach 用
Supplier<T>T get()提供,延迟创建
UnaryOperator<T>T apply(T)同类型转换,继承 Function
BiFunction<T,U,R>R apply(T,U)两个入参,reduce 用

方法引用有四种形式,本质是"把已有方法包装成函数式接口的实例":

// 1. 静态方法
Function<String, Long> f1 = Long::valueOf;

// 2. 特定对象的实例方法
List<String> list = new ArrayList<>();
Consumer<String> f2 = list::add;

// 3. 任意对象的实例方法(第一个参数作为调用者)
Function<String, String> f3 = String::toUpperCase;     // 等价 s -> s.toUpperCase()

// 4. 构造器
Supplier<Order> f4 = Order::new;
Function<Long, Order> f5 = Order::new;                 // 按参数匹配构造器

第 3 种最容易看不懂。String::toUpperCase 作为 Function<String, String> 时,第一个参数 String 就是调用者。写成 Lambda 是 s -> s.toUpperCase()

重构前后

最后贴一下那段被评审的代码,改动本身没什么技术含量,但可读性差距很明显:

// 改之前:34 行
List<OrderVO> vos = new ArrayList<>();
for (Order order : orders) {
    if (order.getStatus() == OrderStatus.PAID
            && order.getAmount().compareTo(MIN_AMOUNT) > 0) {
        OrderVO vo = new OrderVO();
        vo.setOrderNo(order.getOrderNo());
        vo.setAmount(order.getAmount());
        vo.setCreateTime(format(order.getCreateTime()));
        vos.add(vo);
    }
}

// 改之后:12 行
List<OrderVO> vos = orders.stream()
        .filter(o -> o.getStatus() == OrderStatus.PAID)
        .filter(o -> o.getAmount().compareTo(MIN_AMOUNT) > 0)
        .map(this::toVO)
        .collect(Collectors.toList());

小结

  • Lambda 不是匿名内部类的语法糖。它在编译期把方法体变成所在类的私有方法,运行时通过 invokedynamic + LambdaMetafactory 动态生成实现类。
  • 变量捕获的是值副本,所以要求 effectively final。想改外面的变量本身就是错误的设计,应该绕开而不是用数组 hack。
  • Lambda 里的 this 指向外层对象,从匿名内部类迁移过来时要检查。
  • 性能上稳态几乎没差别,代价是第一次执行的一次性链接开销(约 10 毫秒量级)。

还有一点我后来才知道:Lambda 默认是不可序列化的。如果要把 Lambda 传到消息队列或者存到缓存里(比如一些分布式计算框架会这么做),得显式转成带 Serializable 的交集类型:

Runnable r = (Runnable & Serializable) () -> System.out.println("hi");

写业务代码基本用不上,但看到别人这么写的时候别觉得奇怪。

参考