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

翻译说明:html

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

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

原文做者: Christophe Beyls算法

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

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

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

“With great power comes great responsibility.”api

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

Kotlin字节码检查器bash

这是查看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中增长了两到三个额外的间接级别,而且将为这些常量中的每个生成两到三个额外的方法。

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

二、对于其余类型的常量,不能使用const,所以若是须要重复访问常量,可能须要将值缓存在局部变量中

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

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

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

译者有话说

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

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

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

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

如何参与赠书福利

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

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

相关文章
相关标签/搜索