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

3,910 阅读10分钟

翻译说明:

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

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

原文作者: Christophe Beyls

在2016年,Jake Wharton大神就Java中隐藏性能开销进行了一系列有趣的讨论。大概就在同一时期,他也开始提倡使用Kotlin语言进行Android开发,但除了推荐使用内联函数之外,几乎没有提到Kotlin这门语言的其他隐藏性能开销。既然Kotlin得到Google在AndroidStudio3中的正式支持,我认为通过研究生成的字节码来编写Kotlin程序是一个不错的方式。

Kotlin是一种现代编程语言,与Java相比它具有更多的语法糖,因此,在编译器的底层有更多的"黑魔法",然后其中一些操作所带来的性能开销是不可忽视的,特别是针对低版本低端的Android设备程序开发。

当然这并不是针对Kotlin这门语言: 相反,我非常喜欢这门语言,它提高了工作效率,但我也相信一个优秀的开发人员需要知道这门语言内部工作原理,以便于更加高效和明智地使用它的语法特性。Kotlin很强大,就像一句名言说得那样:

“With great power comes great responsibility.”

这些文章将仅关注Kotlin 1.1之后的JVM / Android实现,而不是Javascript实现。

Kotlin字节码检查器

这是查看Kotlin代码如何转化为字节码的首选工具。在AndroidStudio中安装好Kotlin插件后,选择"Show Kotlin Bytecode"来打开一个面板就会显示当前类的字节码。你还可以点击"Decompile"按钮来查看反编译后对应的Java代码

特别是,每次涉及到以下有关Kotlin语法特性,我都会使用到它:

  • 原始类型的装箱,如何分配短期对象
  • 实例化代码中不直接可见的额外对象
  • 生成额外的方法。正如你所知道的那样,在Android应用程序中,单个dex文件允许的方法数量是有限的,如果超过限制,一般就需要配置multidex, 但是该方式存在限制和性能的损失。

有关性能基准测试的说明

我特地选择不发布任何微基准测试,因为它们中的大多数都是没有意义的,存在缺陷,或者两者兼而有之,并且不能应用于所有代码变体和运行时环境。当相关代码用于循环或嵌套循环时,通常会造成很大性能开销。

此外,执行时间不是唯一测量的标准,还必须要考虑分配增加的执行内存使用量,因为最终必须回收所有分配的内存,垃圾收集的成本取决于许多因素,如可用内存和平台上使用的GC算法。

简而言之:如果你想知道Kotlin某些操作是否具有明显的速度或内存影响,请在自己的目标平台上测量代码

高阶函数和Lambda表达式

Kotlin支持将函数赋值给一个变量并把它们作为函数参数传递给其他函数。接受其他函数作为参数的函数称为高阶函数

Kotlin函数可以通过它的前缀::的声明引用,或者直接在代码块内部作为匿名函数声明,或者使用lambda表达式语法,这是描述函数最紧凑方法。

Kotlin中最具有吸引力语法特性之一就是为Java 6/7 JVM和Android提供lambda表达式的支持。

考虑以下函数实例,该函数在数据库事务中执行任意操作并返回受影响的行数:

fun transaction(db: Database, body: (Database) -> Int): Int {
    db.beginTransaction()
    try {
        val result = body(db)
        db.setTransactionSuccessful()
        return result
    } finally {
        db.endTransaction()
    }
}

我们可以通过使用类似于Groovy的语法将lambda表达式作为最后一个参数传递来调用此函数:

val deletedRows = transaction(db) {
    it.delete("Customers", null, null)
}

但是Java 6 JVM不直接支持lambda表达式。那么它们如何转换为字节码?正如你所预料的,lambdas和匿名函数被编译为Function对象。

Function对象

这是反编译上面的lambda表达式后的Java代码.

class MyClass$myMethod$1 implements Function1 {
   // $FF: synthetic method
   // $FF: bridge method
   public Object invoke(Object var1) {
      return Integer.valueOf(this.invoke((Database)var1));//被装箱成Integer对象,这个下一节会具体讲到
   }

   public final int invoke(@NotNull Database it) {
      Intrinsics.checkParameterIsNotNull(it, "it");
      return it.delete("Customers", null, null);
   }
}

在你的Android dex文件中,编译为Function对象的每个lambda表达式实际上会为总方法计数添加3或4个方法。

值得高兴的是这些Function对象的新实例并不是每种情况都会创建,仅在必要的时候创建。所以这就意味着你在实际使用中,需要知道什么情况下会创建Function对象的新实例以便于给你的程序带来更好的性能:

  • 对于捕获表达式情况,每次将lambda作为参数传递,然后执行后进行垃圾回收,就会每次创建一个新的Function实例;
  • 对于非捕获表达式(也即是纯函数)情况,将在下次调用期间创建并复用单例函数实例。

由于我们上述的例子调用者代码使用的是非捕获lambda,因此它会被编译为单例而不是内部类。

this.transaction(db, (Function1)MyClass$myMethod$1.INSTANCE);

如果要调用 捕获lambda 来减少对垃圾收集器的压力,请避免重复调用标准(非内联)高阶函数

装箱带来的性能开销

与Java8相反的是,Java8大约有43中不同的特殊函数接口,以尽可能避免装箱和拆箱, 而Kotlin编译的Function对象仅仅实现完全通用的接口,有效地将Object类型用于任何的输入或输出值。

/** A function that takes 1 argument. */
public interface Function1<in P1, out R> : Function<R> {
    /** Invokes the function with the specified argument. */
    public operator fun invoke(p1: P1): R
}

这就意味着当函数涉及输入值或返回值是基本类型(如Int或Long)时,调用在高阶函数中作为参数传递的函数实际上将涉及系统的装箱和拆箱。这可能会对性能上产生不可忽视的影响,特别是在Android上。

在上面例子编译的lambda中,你可以看到结果被装箱到Integer对象。然后,调用者代码将立即将其拆箱。

在编写涉及使用基本类型作为输入或输出值的参数函数的标准(非内联)高阶函数时要小心。反复调用此参数函数将通过装箱和拆箱操作对垃圾收集器施加更大的压力

内联函数来拯救

值得庆幸的是,Kotlin提供了一个很好的语法技巧,可以避免在使用lambda表达式时带来的额外性能开销: 将高阶函数声明成 内联. 这讲使得编译器直接执行调用者代码中内联函数体中代码,完全避免了调用带来的开销。对于高阶函数,其好处甚至更大,因为作为参数传递的lambda表达式的主体也将被内联。实际效果如下:

  • 声明lambda表达式时,不会实例化Function对象
  • 没有装箱或拆箱操作将应用于基于原始类型的lambda输入和输出值
  • 没有方法将添加到总方法计数中
  • 不会执行实际的函数调用,这可以提高对此使用该函数带来的CPU占用性能

在将transaction()函数声明为内联之后,我们的调用者代码的Java高效实现如下:

db.beginTransaction();
try {
   int result$iv = db.delete("Customers", null, null);
   db.setTransactionSuccessful();
} finally {
   db.endTransaction();
}

然后使用这个杀手锏级别功能时,有一些地方需要注意:

  • 内联函数不能直接调用自身或通过其他内联函数调用自身
  • 在类中声明公有的内联函数只能访问该类的公有函数和成员变量
  • 代码的大小会增加。内联多次引用代码较长的函数可以使生成的代码更大,如果这个代码较长的函数本身引用其他代码较长的内联函数,则会更多。

如果可能,将高阶函数声明为内联函数。保持简短,如果需要,将大段代码移动到非内联函数。 你还可以内联从代码的性能关键部分调用的函数。

我们将在以后的文章中讨论内联函数的其他性能优势

伴生对象

Kotlin类中已经没有了静态字段或方法。取而代之的是在类的伴生对象中声明与实例无关的字段和方法.

从其伴生对象访问私有类字段

不妨看下这个例子:

class MyClass private constructor() {

    private var hello = 0

    companion object {
        fun newInstance() = MyClass()
    }
}

编译时,伴生对象会被实现为单例类。这意味着就像任何需要从其他类访问其私有字段的Java类一样,从伴随对象访问外部类的私有字段(或构造函数)将生成其他合成getter和setter方法。对类字段的每次读取或写入访问都将导致伴随对象中的静态方法调用。

ALOAD 1
INVOKESTATIC be/myapplication/MyClass.access$getHello$p (Lbe/myapplication/MyClass;)I
ISTORE 2

在Java中,我们可以通过使用package可见性来避免生成这些方法。然后在Kotlin中的没有package可见性。使用publicinternal可见性将会导致Kotlin生成默认的getter和setter实例方法,以致于外部可以访问到这些字段,然而调用实例方法在技术上往往比调用静态方法更为昂贵。

如果需要从伴生对象重复读取或写入类字段,则可以将其值缓存在局部变量中,以避免重复的隐藏方法调用。

访问伴生对象中声明的常量

在Kotlin中,您通常会在伴生对象内声明您在类中使用的“静态”常量。

class MyClass {

    companion object {
        private val TAG = "TAG"
    }

    fun helloWorld() {
        println(TAG)
    }
}

上述代码虽然看起来简洁明了,但是底层实现执行操作就十分的难看。

出于同样的原因,访问伴生对象中声明的私有常量实际上会在伴生对象实现类中生成一个额外的合成getter方法

GETSTATIC be/myapplication/MyClass.Companion : Lbe/myapplication/MyClass$Companion;
INVOKESTATIC be/myapplication/MyClass$Companion.access$getTAG$p (Lbe/myapplication/MyClass$Companion;)Ljava/lang/String;
ASTORE 1

但是更糟糕的是,合成方法实际上不会返回值,它调用的是一个Kotlin生成的getter实例方法.

ALOAD 0
INVOKESPECIAL be/myapplication/MyClass$Companion.getTAG ()Ljava/lang/String;
ARETURN

当常量声明为public而不是private时,此getter方法是公共的并且可以直接调用,因此不需要上一步的合成方法。但是Kotlin仍然需要调用getter方法来读取常量.

那么,我们结束了吗?没有! 事实证明,为了存储常量值,Kotlin编译器是在主类级别中而不是在伴生对象内生成实际的private static final的字段。但是,因为静态字段在类中声明为private,所以需要另一种合成方法来从伴生对象中访问它

INVOKESTATIC be/myapplication/MyClass.access$getTAG$cp ()Ljava/lang/String;
ARETURN

并且该合成方法最后读取实际值:

GETSTATIC be/myapplication/MyClass.TAG : Ljava/lang/String;
ARETURN

换句话说,当你从Kotlin类访问伴生对象中的私有常量字段时,而不是像Java那样直接读取静态字段,代码实际上是:

  • 在伴生对象中调用静态方法
  • 它将依次调用伴随对象中的实例方法
  • 然后反过来调用类中的静态方法
  • 读取静态字段并返回其值
public final class MyClass {
    private static final String TAG = "TAG";
    public static final Companion companion = new Companion();

    // synthetic 
    public static final String access$getTAG$cp() {
        return TAG;
    }

    public static final class Companion {
        private final String getTAG() {
            return MyClass.access$getTAG$cp();
        }

        // synthetic
        public static final String access$getTAG$p(Companion c) {
            return c.getTAG();
        }
    }

    public final void helloWorld() {
        System.out.println(Companion.access$getTAG$p(companion));
    }
}

那么我们可以获得更轻量级的字节码吗?是的,但不是在所有情况下。

首先,通过使用const关键字将值声明为编译时常量,可以完全避免任何方法调用。这将直接在调用代码中有效地内联值,但是只能将它用于原始类型和字符串

class MyClass {

    companion object {
        private const val TAG = "TAG"
    }

    fun helloWorld() {
        println(TAG)
    }
}

其次,可以在伴生对象的公共字段上使用@JvmField注解,以指示编译器不生成任何getter或setter,并将其作为类中的静态字段公开,就像纯Java常量一样。事实上,这个注解是出于Java兼容性问题才创建的,如果你不需要从Java代码中访问你这个常量,我不建议你使用这个互操作注解来混乱优雅的Kotlin代码。此外,它只能用于公有字段。在Android开发环境中,可能只会使用此注解来实现Parcelable对象:

class MyClass() : Parcelable {

    companion object {
        @JvmField
        val CREATOR = creator { MyClass(it) }
    }

    private constructor(parcel: Parcel) : this()

    override fun writeToParcel(dest: Parcel, flags: Int) {}

    override fun describeContents() = 0
}

最后,你还可以使用ProGuard工具优化字节码,并希望它将这些链式方法调用合并在一起,但是无法绝对保证这起到理想中的作用。

从伴随对象中读取“静态”常量,与Java相比,在Kotlin中增加了两到三个额外的间接级别,并且将为这些常量中的每一个生成两到三个额外的方法。

1、始终使用const关键字声明基本类型和字符串常量以避免这种情况

2、对于其他类型的常量,不能使用const,因此如果需要重复访问常量,可能需要将值缓存在局部变量中

3、此外,更推荐将公有的全局常量存储在它们自己的对象中而不是伴随对象中。

这就是第一篇文章的全部内容。希望这能让你更好地理解使用这些Kotlin功能的含义。请记住这一点,以便编写更智能高效的代码,而不会牺牲可读性和性能。

欢迎继续阅读本系列的Part 2: local functions, null safety and varargs.

译者有话说

关于探索Kotlin中隐藏的性能开销这一系列文章,早在去年就拜读过了,一直小心收藏着,希望有时间能把它翻译出来分享给大家,然后一直没有时间去做这件事,但是却一直在TODO List清单中。

关于这个系列文章不管你是Kotlin初学小白,还是有一定基础的Kotlin开发者都是对你有好处的,因为它在教你如何写出更优雅性能更高效的Kotlin代码,并从原理上带你分析某些操作不当会导致额外的性能开销。这篇文章原文还有两篇,后面会继续翻译出来,分享出来给大家。

前方高能预警,即将迎来一波赠书福利

说真的,最近工作真的特别忙,本没有时间写文章。但是上周华章主编郭老师找到我,能否再写篇文章搞个赠书活动,一想到上次给大家的赠书名额本就不多,很多人都没拿到,所以挤出这周末的时间来写写,并把之前压箱底TODO List这一系列文章翻译出来分享给大家,二来又能给大家带来赠书福利。

如何参与赠书福利

欢迎关注Kotlin开发者联盟,这里有最新Kotlin技术文章和Kotlin相关书籍赠书活动

老规矩在公众号本篇文章中留言点赞数最多排名作为赠书对象,这次赠书书籍还是上次我推荐的《Kotlin核心编程》,赠书活动截止时间是7月10号晚上8点公布名单