[译]探索Kotlin中隐藏的性能开销-Part 2

3,576 阅读12分钟

翻译说明:

原标题: Exploring Kotlin’s hidden costs — Part 2

原文地址: medium.com/@BladeCoder…

原文作者: Christophe Beyls

这是关于探索Kotlin中隐藏的性能开销的第2部分,如果你还没有看到第1部分,不要忘记阅读第1部分。

让我们一起从底层重新探索和发现更多有关Kotlin语法实现细节。

局部函数

这是我们之前第一篇文章中没有介绍过的一种函数: 就是像正常定义普通函数的语法一样,在其他函数体内部声明该函数。这些被称为局部函数,它们能访问到外部函数的作用域。

fun someMath(a: Int): Int {
    fun sumSquare(b: Int) = (a + b) * (a + b)

    return sumSquare(1) + sumSquare(2)
}

我们首先来说下局部函数最大的局限性: 局部函数不能被声明成内联的(inline)并且函数体内含有局部函数的函数也不能被声明成内联的(inline). 在这种情况下没有任何有效的方法可以帮助你避免函数调用的开销。

经过编译后,这些局部函数会将被转化成Function对象, 就类似lambda表达式一样,并且同样具有上篇文章part1中讲到的关于非内联函数存在很多的限制。反编译后的java代码:

public static final int someMath(final int a) {
   Function1 sumSquare$ = new Function1(1) {
      // $FF: synthetic method
      // $FF: bridge method
      //注: 这是Function1接口生成的泛型合成方法invoke
      public Object invoke(Object var1) {
         return Integer.valueOf(this.invoke(((Number)var1).intValue()));
      }

      //注: 实例的特定方法invoke
      public final int invoke(int b) {
         return (a + b) * (a + b);
      }
   };
   return sumSquare$.invoke(1) + sumSquare$.invoke(2);
}

但是与lambda表达式相比,它对性能的影响要小得多: 由于该函数的实例对象是从调用方就知道的,所以它将直接调用该实例的特定方法invoke而不是从Function接口直接调用其泛型合成方法invoke。这就意味着从外部函数调用局部函数时,不会进行基本类型的转换或装箱操作. 我们可以通过看下字节码来验证一下:

   ALOAD 1
   ICONST_1
   INVOKEVIRTUAL be/myapplication/MyClassKt$someMath$1.invoke    (I)I
   ALOAD 1
   ICONST_2
   INVOKEVIRTUAL be/myapplication/MyClassKt$someMath$1.invoke    (I)I
   IADD //加法操作
   IRETURN

我们可以看到被调用两次的函数是接收一个 Int 类型的参数并且返回一个 Int 类型的函数,并且加法操作是立即执行的,而无需任何中间的装箱、拆箱操作。

当然,在每次方法被调用期间仍会创建一个新的Function对象。但是这个可以通过将局部函数改写为非捕获的方式来避免这种情况:

fun someMath(a: Int): Int {
    fun sumSquare(a: Int, b: Int) = (a + b) * (a + b)

    return sumSquare(a, 1) + sumSquare(a, 2)
}

现在相同的Function实例将会被复用,仍然不会进行强制的转换或装箱操作。与普通的私有函数相比,此局部函数的唯一劣势就是使用一些方法生成额外的类。

局部函数是私有函数的替代品,其附加好处是能够访问外部函数的局部变量。然而这种好处会伴随着为外部函数每次调用创建Function对象的隐性成本,因此首选使用非捕获的局部函数。

空安全

Kotlin语言的最好特性之一就是,它在可空类型和非空类型之间做出了明显清晰的界限区分。这使得编译可以通过在运行时禁止将非null或可为null的值分配给非null变量的任何代码来有效防止意外的NullPointerException.

非空参数的运行时检查

下面我们来声明一个使用非null字符串作为采纳数的公有函数:

fun sayHello(who: String) {
    println("Hello $who")
}

现在来看下对应的反编译后Java代码:

public static final void sayHello(@NotNull String who) {
   Intrinsics.checkParameterIsNotNull(who, "who");//执行静态函数进行非空检查
   String var1 = "Hello " + who;
   System.out.println(var1);
}

请注意,Kotlin编译器对Java是非常友好的,可以看到在函数参数上自动添加了@NotNull注解,因此Java工具可以使用此注解在传递空值的时候显示警告。

但是,注解不足以强制外部调用者传入非null的值。因此,编译器还在函数的开头添加一个静态方法调用,该方法将检查参数,如果为null,则抛出IllegalArgumentException. 为了使不安全的调用者代码更易于修复,该函数将尽早且持续抛出异常,而不是将它置后抛出运行时的NullPointerException.

实际上,每个公有的函数都有一个对Intrinsics.checkParameterIsNotNull()的静态调用,该调用为每个非null引用参数添加。这些检查不会被添加到私有函数中,因为编译器保证了Kotlin类中的代码为null安全的。

这些静态调用对性能的影响几乎可以忽略不计,并且在调试和测试应用程序的时候非常有帮助。话虽如此,如果对于release版本来说你可能认为这是没必要的额外开销。在这种情况下,可以使用-Xno-param-assertions编译器选项或添加以下Proguard规则来禁止运行时的空检查:

-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
    static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
}

可空的原生类型

有一点似乎众所周知,但还是在这里提醒下: 可空类型始终是引用类型。将原生类型的变量声明成可空类型可以防止Kotlin使用Java基本数据类型(例如intfloat), 而是使用装箱的引用类型(例如IntegerFloat),这会避免装箱和拆想操作带来的额外开销。

与Java相反的是它允许你草率地使用几乎像int变量的Integer变量,这都要归功于自动装箱和忽略了null的安全性,可是Kotlin则会强制你在使用可null的类型时编写空安全的代码,因次使用非null类型的好处就变得更显而易见了:

fun add(a: Int, b: Int): Int {
    return a + b
}
fun add(a: Int?, b: Int?): Int {
    return (a ?: 0) + (b ?: 0)
}

尽可能使用非null的原生类型,以此来提高代码可读性和性能。

关于数组

在Kotlin中存在3种类型的数组:

  • IntArray,FloatArray以及其他原生类型的数组。
    最终会编译成 int[],float[]以及其他对应基本数据类型的数组

  • Array<T>: 非空对象引用类型的数组
    这里会涉及到原生类型的装箱过程

  • Array<T?>: 可空对象引用类型的数组
    很明显,这里也会涉及到原生类型的装箱过程

如果你需要一个非null原生类型的数组,最好使用IntArray而不是Array<Int>以避免装箱过程带来性能开销

可变数量的参数(Varargs)

类似Java, Kotlin允许使用可变数量的参数声明函数。只是声明的语法有点不一样而已:

fun printDouble(vararg values: Int) {
    values.forEach { println(it * 2) }
}

就像在Java中一样,vararg参数实际上被编译为给定类型的数组参数。然后,可以通过三种不同的方式调用这些函数:

1.传递多个参数

printDouble(1, 2, 3)

Kotlin编译器将将此代码转换为新数组的创建和初始化,就像Java编译器一样:

printDouble(new int[]{1, 2, 3});

所以,创建新数组会产生开销,但是与Java相比,这并不是什么新鲜事。

2.传递单个数组

这里不同之处就是,在Java中,可以直接将现有的数组引用作为vararg参数传递。在Kotlin中,则需要使用伸展(spread)操作符:

val values = intArrayOf(1, 2, 3)
printDouble(*values)

在Java中,数组引用按原样传递给函数,而无需分配额外的数组空间。然而,如你在反编译后java代码中所见,Kotlin伸展(spread)操作符的编译方式有所不同:

int[] values = new int[]{1, 2, 3};
printDouble(Arrays.copyOf(values, values.length));

调用函数时,始终会复制现有数组。好处是代码更安全:它允许函数修改数组而不影响调用者代码。但是它会分配额外的内存

请注意,使用Kotlin代码中可变数量的参数调用Java方法具有相同的效果。

3.传递数组和参数的混合

Kotlin伸展(spread)运算符的主要好处是它还允许在同一调用中将数组与其他参数混合在一起。

val values = intArrayOf(1, 2, 3)
printDouble(0, *values, 42)

上述代码将会怎样编译呢?生成代码会十分有趣:

int[] values = new int[]{1, 2, 3};
IntSpreadBuilder var10000 = new IntSpreadBuilder(3);
var10000.add(0);
var10000.addSpread(values);
var10000.add(42);
printDouble(var10000.toArray());

除了创建新数组之外,还使用一个临时生成器对象来计算最终数组大小并填充它。这给方法调用又增加了另一笔小开销。

即使在使用现有数组中的值时,在Kotlin中调用具有可变数量参数的函数也会增加创建新临时数组的成本。对于重复调用该函数的性能至关重要的代码,请考虑添加具有实际数组参数而不是vararg的方法

感谢您的阅读,如果喜欢,请分享这篇文章。

继续阅读第3部分委托的属性范围

读者有话说

大概隔了很久很久之前,我好像写了一篇探索Kotlin中隐藏的性能开销系列的Part1. 如果没有读过第1篇建议也去读下第1篇,因为这个系列确实对你写出高效的Kotlin代码十分有帮助,也能帮助你从源码,编译层面认清Kotlin语法背后的原理。我更喜欢把这些写Kotlin代码技巧称为Effective Kotlin, 这也是我最初翻译这个系列文章的初衷。关于这篇文章,有几点我需要补充下:

1、为什么非捕获局部函数可以减少开销

其实关于捕获和非捕获的概念,在之前文章中也有所提及,比如在讲变量的捕获,lambda的捕获和非捕获。

这里就以上述局部函数举例,下面对比下这两个函数:

//改写前的捕获局部函数
fun someMath(a: Int): Int {
    fun sumSquare(b: Int) = (a + b) * (a + b)//注意:局部函数这里的a是直接引用外部函数的参数a, 
    //因为局部函数特性可以访问外部函数的作用域,这里实际上就存在了变量的捕获,所以这里sumSquare称为捕获局部函数

    return sumSquare(1) + sumSquare(2)
}
//改写前反编译后代码
 public static final int someMath(final int a) {
      //创建Function1对象$fun$sumSquare$1,所以每调用一次someMath都会创建一个Function1对象
      <undefinedtype> $fun$sumSquare$1 = new Function1() {
         // $FF: synthetic method
         // $FF: bridge method
         public Object invoke(Object var1) {
            return this.invoke(((Number)var1).intValue());
         }

         public final int invoke(int b) {
            return (a + b) * (a + b);
         }
      };
      return $fun$sumSquare$1.invoke(1) + $fun$sumSquare$1.invoke(2);
   }

捕获局部函数会生成额外的Function对象,所以我们为了减少性能的开销尽量使用非捕获局部函数。

//改写后的非捕获局部函数
fun someMath(a: Int): Int {
    //注意: 可以明显发现改写后a参数,直接由函数参数传入,而不是在局部函数直接引用外部函数的参数变量,这就是非捕获局部函数
    fun sumSquare(a: Int, b: Int) = (a + b) * (a + b)
    return sumSquare(a,1) + sumSquare(a,2)
}

//改写后反编译后代码
public static final int someMath(int a) {
    //注意:可以看到非捕获的局部函数实例是一个单例,多次调用都只会复用之前的实例不会重新创建。
    <undefinedtype> $fun$sumSquare$1 = null.INSTANCE;
    return $fun$sumSquare$1.invoke(a, 1) $fun$sumSquare$1.invoke(a, 2);
}

通过上述对比,应该很清楚知道了什么是捕获什么是非捕获以及为什么非捕获局部函数会减少性能的开销。

2、总结下提高Kotlin代码性能开销几个点

  • 局部函数是私有函数的替代品,其附加好处是能够访问外部函数的局部变量。然而这种好处会伴随着为外部函数每次调用创建Function对象的隐性成本,因此首选使用非捕获的局部函数。
  • 对于release版本应用来说,特别是Android应用,可以使用-Xno-param-assertions编译器选项或添加以下Proguard规则来禁止运行时的空检查:
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
    static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
}
  • 需要使用非null原生类型的数组时,最好使用IntArray而不是Array<Int>以避免装箱过程带来性能开销

最后

首先想和一直关注我公众号和技术博客的老铁们说声抱歉,因为中间已经很久没更新技术文章,因此有很多人也离开了,但也有人一直默默支持。所以从今天起我又准备开始更新了文章。近期研究dart和flutter也有一段时间了,沉淀了一些技术心得,所以会不定期更新有关一些Dart和Flutter的文章,感谢关注,感谢理解。

欢迎关注Kotlin开发者联盟,这里有最新Kotlin技术文章,每周会不定期翻译一篇Kotlin国外技术文章。如果你也喜欢Kotlin,欢迎加入我们~~~

Kotlin系列文章,欢迎查看:

Kotlin邂逅设计模式系列:

数据结构与算法系列:

翻译系列:

原创系列:

Effective Kotlin翻译系列

实战系列: