重学 Kotlin —— inline,包治百病的性能良药?

19,370 阅读10分钟

本文永久更新地址: https://xiaozhuanlan.com/topic/3458207169

重学 Kotlin 已经来到了第三期,前面已经介绍了:

object,史上最 “快” 单例 ?

typealias ,穿了马甲,我就不认识你了?

今天的主角是 inline ,这不是一个 Kotlin 特有的概念,大多数编程语言都支持内联。

内联函数 的语义很简单: 把函数体复制粘贴到函数调用处 。使用起来也毫无困难,用 inline关键字修饰函数即可。

然而问题的关键并不是如何使用 inline ,而是什么时候使用 inline ? 既然 Kotlin 提供了内联,它肯定是为了性能优化而存在的,那么,它又真的是包治百病的性能良药吗?

今天,我们就一起来刨根挖底,寻找一下答案。

目录

  1. inline 的本质
  2. 建议我不要用 inline ?
  3. Java 支持内联吗?
  4. 拯救 Lambda
  5. Java 是如何优化 Lambda 的?
  6. 不想内联怎么办?
  7. 如何从 Lambda 返回?
  8. 最后

inline 的本质

前面已经说过 inline 就是 把函数体复制粘贴到函数调用处 ,完全是编译器的小把戏。本着严谨科学的态度,我们还是来反编译验证一下。

inline fun test() {
    println("I'm a inline function")
}

fun run() { test() }

run() 函数中调用了内联函数 test()。反编译查看对应的 java 代码:

public static final void test() {
    String var1 = "I'm a inline function";
    System.out.println(var1);
}

public static final void run() {
    String var1 = "I'm a inline function";
    System.out.println(var1);
}

可以看到 run() 函数中并没有直接调用 test() 函数,而是把 test() 函数的代码直接放入自己的函数体中。这就是 inline 的功效。

那么,问题就来了。这样就可以提高运行效率吗?如果可以,为什么?

我们先从 JVM 的方法执行机制说起。

JVM 进行方法调用和方法执行依赖 栈帧,每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

线程的栈帧是存储在虚拟机栈中,以上面示例代码的 未内联 版本为例,对应的方法执行过程和对应的栈帧结构如下所示:

未内联的情况下,整个执行过程中会产生两个方法栈帧,每一个方法栈帧都包括了 局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息 。

使用内联的情况下,只需要一个方法栈帧,降低了方法调用的成本。

乍一看,的确的提高了运行效率,毕竟少用一个栈帧嘛。

然而?

建议不要使用 inline ?

一切看起来都很美好,除了 IDE 给我的刺眼提示。

Expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of functional types

大致意思是在这里使用内联对性能的影响微乎其微,或者说没有什么意义。Kotlin 的内联最好用在函数参数类型中。

不急着解释,首先来一发灵魂拷问。

Java 支持内联吗?

你可以说不支持,因为 Java 并没有提供类似 inline 的显示声明内联函数的方法。

但是 JVM 是支持的。Java 把内联优化交给虚拟机来进行,从而避免开发者的滥用。

典型的一种滥用, 内联超长方法 ,极大的增大字节码长度,反而得不偿失。你可以注意 Kotlin 标准库中的内联函数,基本都是简短的函数。

对于普通的函数调用,JVM 已经提供了足够的内联支持。因此,在 Kotlin 中,没有必要为普通函数使用内联,交给 JVM 就行了。

另外,Java 代码是由 javac 编译的,Kotlin 代码是由 kotlinc 编译的,而 JVM 可以对字节码做统一的内联优化。所以,可以推断出,不管是 javac ,还是 kotlinc,在编译期是没有内联优化的。

至于 JVM 具体的内联优化机制,我了解的并不多,这里就不做过多介绍了。后续如果我看到相关资料,会到这里进行补充。

所以,上一节中 IDE 给开发者的提示就很明了了。

JVM 已经提供了内联支持,所以没有必要在 Kotlin 中内联普通函数。

那么问题又来了。 既然 JVM 已经支持内联优化,Kotlin 的内联存在的意义是什么 ? 答案就是 Lambda  。

拯救 Lambda

为什么要拯救 Lambda,我们首先得知道Kotlin 的 Lambda 对于 JVM 而言究竟是什么。

Kotlin 标准库中有一个叫 runCatching  的函数,我在这里实现一个简化版本 runCatch ,参数是一个函数类型。

fun runCatch(block: () -> Unit){
    try {
        block()
    }catch (e:Exception){
        e.printStackTrace()
    }
}

fun run(){
    runCatch { println("xxx") }
}

反编译生成的 Java 代码如下所示:

public final class InlineKt {
    public static final void runCatch(@NotNull Function0<Unit> block) {
        Intrinsics.checkParameterIsNotNull(block, (String)"block");
        try {
            block.invoke();
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static final void run() {
        InlineKt.runCatch((Function0<Unit>)((Function0)run.1.INSTANCE));
    }
}

static final class InlineKt.run.1 extends Lambda implements Function0<Unit> {
    public static final InlineKt.run.1 INSTANCE = new /* invalid duplicate definition of identical inner class */;

    public final void invoke() {
        String string = "xxx";
        boolean bl = false;
        System.out.println((Object)string);
    }

    InlineKt.run.1() {
    }
}

Kotlin 自诞生之初,就以 兼容 Java 为首要目标。因此,Kotlin 对于 Lambda 表达式的处理是编译生成匿名类。

经过编译器编译之后, runCatch() 方法中的 Lambda 参数被替换为 Function0<Unit> 类型,在 run() 方法中实际调用 runCatch()  时传入的参数是实现了 Function0<Unit>  接口的 InlineKt.run.1  ,并重写 了 invoke() 方法。

所以,调用 runCatch() 的时候,会创建一个额外的类 InlineKt.run.1。这是 Lambda 没有捕捉变量的场景。如果捕捉了变量,表现会怎么样?

fun runCatch(block: () -> Unit){
    try {
        block()
    }catch (e:Exception){
        e.printStackTrace()
    }
}

fun run(){
    var message = "xxx"
    runCatch { println(message) }
}

在 Lambda 内部捕捉了外部变量 message  ,其对应的 java 代码如下所示:

public final class InlineKt {
    public static final void runCatch(@NotNull Function0<Unit> block) {
        Intrinsics.checkParameterIsNotNull(block, (String)"block");
        try {
            block.invoke();
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static final void run() {
        void message;
        Ref.ObjectRef objectRef = new Ref.ObjectRef();
        objectRef.element = "xxx";
        // 这里每次运行都会 new 一个对象
        InlineKt.runCatch((Function0<Unit>)((Function0)new Function0<Unit>((Ref.ObjectRef)message){
            final /* synthetic */ Ref.ObjectRef $message;

            public final void invoke() {
                String string = (String)this.$message.element;
                boolean bl = false;
                System.out.println((Object)string);
            }
            {
                this.$message = objectRef;
                super(0);
            }
        }));
    }
}

如果 Lambda 捕捉了外部变量,那么每次运行都会 new 一个持有外部变量值的 Function0<Unit>  对象。这比未发生捕捉变量的情况更加糟糕。

总而言之,Kotlin 的 Lambda 为了完全兼容到 Java6,不仅增大了编译代码的体积,也带来了额外的运行时开销。为了解决这个问题,Kotlin 提供了 inline 关键字。

Kotlin 内联函数的作用是消除 lambda 带来的额外开销

runCatch()  加持 inline :

inline fun runCatch(block: () -> Unit){
    try {
        block()
    }catch (e:Exception){
        e.printStackTrace()
    }
}

fun run(){
    var message = "xxx"
    runCatch { println(message) }
}

反编译查看 java 代码:

public static final void run() {
      Object message = "xxx";
      boolean var1 = false;
      try {
         int var2 = false;
         System.out.println(message);
      } catch (Exception var5) {
         var5.printStackTrace();
      }
}

runCatch() 的代码被直接内联到 run() 方法中,没有额外生成其他类,消除了 Lambda 带来的额外开销。

Java 是如何优化 Lambda 的?

既然 Kotlin 的 Lambda 存在性能问题,那旁边的 Java 大兄弟肯定也逃脱不了。

从 Java8 开始,Java 借助 invokedynamic 来完成的 Lambda 的优化。

invokedynamic  用于支持动态语言调用。在首次调用时,它会生成一个调用点,并绑定该调用点对应的方法句柄。后续调用时,直接运行该调用点对应的方法句柄即可。说直白一点,第一次调用 invokeddynamic 时,会找到此处应该运行的方法并绑定, 后续运行时就直接告诉你这里应该执行哪个方法。

关于 invokedynamic 的详细介绍,可以阅读极客时间专栏 《深入拆解Java虚拟机》的第 8,9 两讲。

JVM 是怎么实现 invokedynamic 的?(上)

JVM 是怎么实现 invokedynamic 的?(下)

不想内联怎么办?

一个高阶函数一旦被标记为内联,它的方法体和所有 Lambda 参数都会被内联。

inline fun test(block1: () -> Unit, block2: () -> Unit) {
    block1()
    println("xxx")
    block2()
}

test() 函数被标记为了 inline  ,所以它的函数体以及两个 Lambda 参数都会被内联。但是由于我要传入的 block1  代码块巨长(或者其他原因),我并不想将其内联,这时候就要使用 noinline  。

inline fun test(noinline block1: () -> Unit, block2: () -> Unit) {
    block1()
    println("xxx")
    block2()
}

这样, block1 就不会被内联了。篇幅原因,这里就不展示 Java 代码了,相信你也能很容易理解 noinline

如何从 Lambda 返回?

首先,普通的 lambda 是不允许直接使用 return 的 。

fun runCatch(block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    // 普通 lambda 不允许 return
    runCatch { return }
}

上面的代码没有办法通过编译,IDE 会提示你 return is not allowed here 。 而 inline  可以让我们突破这个限制。

// 标记为 inline
inline fun runCatch(block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    runCatch { return }
}

上面的代码是可以正常编译运行的。和之前的例子唯一的区别就是多了 inline 。

既然允许 return ,那么这里究竟是从 Lambda 中返回,继续运行后面的代码?还是直接结束外层函数的运行呢?看一下 run() 方法的执行结果。

before lambda

从运行结果来看,是直接结束外层函数的运行。其实不难理解,这个 return 是直接内联到 run() 方法内部的,相当于在 run() 方法中直接调用 return。从反编译的 java 代码看,一目了然。

   public static final void run() {
      boolean var0 = false;

      try {
         String var1 = "before lambda";
         System.out.print(var1);
         int var2 = false;
      } catch (Exception var3) {
         var3.printStackTrace();
      }
   }

编译器直接把 return 之后的代码优化掉了。这样的场景叫做 non-local return (非局部返回) 。

但是有些时候我并不想直接退出外层函数,而是仅仅退出 Lambda 的运行,就可以这样写。

inline fun runCatch(block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    // 从 lambda 中返回
    runCatch { return@runCatch }
}

return@label,这样就会继续执行 Lambda 之后的代码了。这样的场景叫做 局部返回  。

还有一种场景,我是 API 的设计者,我不想 API 使用者进行非局部返回 ,改变我的代码流程。同时我又想使用 inline ,这样其实是冲突的。前面介绍过,内联会让 Lambda 允许非局部返回。

crossinline  就是为了解决这一冲突而生。它可以在保持内联的情况下,禁止 lambda 从外层函数直接返回。

inline fun runCatch(crossinline block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    runCatch { return }
}

添加 crossinline 之后,上面的代码将无法编译。但下面的代码仍然是可以编译运行的。

inline fun runCatch(crossinline block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    runCatch { return@runCatch }
}

crossinline 可以阻止非局部返回,但并不能阻止局部返回,其实也没有必要。

最后

关于内联函数,一口气说了这么多,总结一下。

在 Kotlin 中,内联函数是用来弥补高阶函数中 Lambda 带来的额外运行开销的。对于普通函数,没有必要使用内联,因为 JVM 已经提供了一定的内联支持。

对指定的 Lambda 参数使用 noinline ,可以避免该 Lambda 被内联。

普通的 Lambda 不支持非局部返回,内联之后允许非局部返回。既要内联,又要禁止非局部返回,请使用 crossinline

除了内联函数之外,Kotlin 1.3 开始支持 inline class ,但这是一个实验性 API,需要手动开启编译器支持。不知道大家对内联类有什么独特的看法,欢迎在评论区交流。

本文使用 mdnice 排版

这里是秉心说,关注我,不迷路!