探索Kotlin的隐性成本

作者: CyrusChan | 来源:发表于2017-06-15 09:51 被阅读255次

    原文

    2016年, Jake Wharton发表了一系列有趣的言论关于Java的隐性成本。同一时期他也开始拥护使用Kotlin开发Android,但是几乎不提Kotlin的隐性成本除了推荐使用内联函数。由于Kotlin被Google官方推荐。通过研究它产生的字节码,我认为写有关这门语言方面的东西将是一个好主意。
    同Java相比,Kotlin是一门提供了大量语法糖的现代编程语言。同样的,也有更多的“黑魔法”在幕后进行着,其中一部分是不可忽视的成本,特别是对于那些年代久远和低配的设备。
    这不是反对Kotlin:我非常喜欢这门语言并且它能提高生产率,但我也相信一个好的程序员需要了解语言内部的特性为了更明智的使用它们。Kotlin 很强大,古语有云:能力越大责任越大。这些文章将单单聚焦于在JVM/Android 上 Kotlin1.1的实现,而不是Javascript的实现。

    Kotlin Bytecode inspector

    选择这个工具是为了搞清Kotlin的代码如何转化成字节码。在装有Kotlin插件的Android Studio上,选择”Show Kotlin ByteCode”去展示当前类的字节码。接着可以点击“Decompile”按钮去看等效的Java代码

    Paste_Image.png

    实际上,我将会着重提及的Kotlin特性有:

    • 分配短生命周期的对象的原始类型装箱
    • 在代码中不直接可见的额外实例化的对象
    • 额外生成的方法(如你所知,在android应用中,单一的dex文件中、方法数是受限的,超过限制需要配置multidex,这也带来了限制和性能损耗,特别是在Lollipo5.0之前的Android版本中)

    高阶函数和Lambda表达式

    Kotlin支持为变量赋值函数并且作为参数传给其它函数。函数接受其他函数作为参数称作高阶函数。Kotlin函数能够被引用通过它的名字和::前缀或者作为匿名类直接声明。再或者使用最简洁描述函数的方式-lambda表达式
    在Java6/7 JVMS和Android平台上,Kotlin是提供lambdas支持的最好的方式。
    参看以下应用函数:在数据库事务中执行任意操作并且返回受影响的行。

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

    我们可以调用这个函数通过传入一个lambda表达式作为最后一个参数,使用语法同Groovy相似:

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

    但是Java6 JVMs 不直接支持lambda表达式。所以他们如何被转化为字节码?正如你可能期望的,lambdas和匿名函数被转化为函数对象。

    Function objects(函数对象)

    下面的Java代码代表上面的lambda表达式被编译后

    class MyClass$myMethod$1 implements Function1 {
       // $FF: synthetic method
       // $FF: bridge method
       public Object invoke(Object var1) {
          return Integer.valueOf(this.invoke((Database)var1));
       }
    
       public final int invoke(@NotNull Database it) {
          Intrinsics.checkParameterIsNotNull(it, "it");
          return db.delete("Customers", null, null);
       }
    }
    

    在你的Android dex文件中,每个lambda表达式被编译成一个函数并将添加3-4个方法
    好消息是这些函数对象的新实例仅仅在被需要的时候被创建。实际上也意味着:

    • 为了捕获表达式,每次lambda被当做参数传递将会生成一个新的函数实例并在执行完成后回收。
    • 对于非捕获函数(纯函数),一个单例函数实例将被创建并在下次调用中重用。

    由于我们的例子代码使用了非捕获lambda,它被编译成了一个单例并且非内部类。

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

    为了减少gc压力,避免重复调用(非内联)高阶函数如果他们正在执行捕获lambdas。

    Boxing overhead(装箱开销)

    与Java8相反,Kotlin有43种不同的特殊接口为了尽可能的避免装箱和拆箱,Kotlin编译的函数对象只实现了完全通用的接口,有效地使用了任何输入或输出值的对象类型。

    /** 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对象中。调用者代码接着将会为它拆箱。

    当写一个标准的(非内联)涉及到一个函数参数使用原始类型作为输入输出的高阶函数时需要小心。由于装箱和拆箱,重复的调用这个函数参数将会给gc带来更多压力。

    Inline functions to the rescue(用于救援的内联函数)

    值得欣慰的是,Kotlin中有一个很好的技巧避免造成任何损耗当使用lambda表达式:声明高阶函数为内联。这将使编译器内联函数体到调用者代码,避免彻底的调用。对于高阶函数的好处是更大的。因为作为参数的lambda表达式体也会被内联。实际效果如下:

    • 没有函数对象将会被实例化当lambda被声明。
    • 目标输入输出原始类型的装箱和拆箱将不会应用到lambda中
    • 没有方法将被增加
    • 没有实际的函数调用将被执行。占用CPU时间较多的代码将可以显著提高性能。

    在我们声明transation()功能为内联之后,表示我们调用代码的java代码为:

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

    这个杀手锏特性有一些需要注意的地方:

    • 内联函数不能直接被另一个内联函数调用
    • 一个类中声明的内联函数只能只能访问这个类的公共函数和字段。
    • 代码将会变多。内联一个引用多次的长函数将会使生成的代码明显的变大,如果这个长函数本身引用了其他的长内嵌函数

    如果可能,声明高阶函数为内联并使它们短。如果需要的话移动大块代码到非内联函数,你可以内联那些性能关键部分被调用的代码。

    我们将会讨论其他内联函数的性能好处在未来的文章当中。

    Companion objects(伴随实体)

    Kotlin类没有静态字段和函数。反而,非实例相关的字段和函数能被声明到类中的伴随实体中。

    Accessing private class fields from its companion object(从它的伴随实体中访问私有字段)

    参考如下例子:

    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中我们使用包可见性为了避免生成这些方法。然而对于Kotlin,这里没有包可见性。使用公共或者内部可见性替代将会造成Kotlin生成默认的getter和setter方法去使这些字段可以被外部世界访问。调用实例的方法理论上要比静态方法更耗费时间。所以不要厌烦去改变这些字段的可见性为了优化的理由。

    如果你需要重复的读或者写去访问一个来自于伴随对像的类字段,你最好缓存它的值到一个局部变量为了避免重复的读写操作。

    Accessing constants declared in a companion object(访问伴随体中的常量)

    在Kotlin中我们一般声明为“static”在伴随体的类中使用的常量。

    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生成的实例方法。

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

    当常量被声明为public而不是private,这个getter方法是public并且能够被直接调用,所以之前合成方法的那一步是不需要的。但是Kotlin仍然需要调用一个getter方法去读取一个常量。
    那么,这样就完了么?不!。事实上为了存储常量,Kotlin在主级别的类上生成一个实际的private static final 字段而不是在伴随体中。但是,由于字段被声明为静态在类中,从伴随体中访问这个类需要另一个合成方法。

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

    并且这个合成方法读取实际的值,最后:

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

    换句话说,当访问一个伴随体中的的常量字段时,不像java那样直接读取一个静态字段,你的代码实际上将会:

    • 调用一个伴随体中的静态方法
    • 接着调用伴随体中的实例方法
    • 接着调用类中的静态方法
    • 最后读取静态字段并返回它的值

    下面是相等的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的兼容性。我绝不会推荐用模糊的交互注释杂乱你美丽的Kotlin代码如果你不需要你的常量被Java代码访问。还有,它只能被公共字段使用。在Android开发的Context中,你可能使用这个注解实现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相比,读取一个伴随体中的静态常量将增加2-3个额外的方法。并且这些方法会为每个这类字段生成。
    对于这篇文章来说,这是所有内容。希望这能让你更好的理解使用这些Kotlin特性的隐喻。记住这点:为了写出更聪明的代码不要牺牲可读性和性能
    继续阅读请前往第二篇:局部函数,空指针安全和可变参数

    相关文章

      网友评论

        本文标题:探索Kotlin的隐性成本

        本文链接:https://www.haomeiwen.com/subject/kheeqxtx.html