【译】探索Kotlin带来的隐性成本(一)

1,943 阅读7分钟

注:来自Medium上的一位Android工程师所写,作者从字节码的层面分析了kotlin一些隐性的性能成本,以及如果避免这些。这个文章有3个部分,这是第一部分。英文原版 medium.com/@BladeCoder…

Lambda表达式和伴随对象

在2016年, Jake Wharton针对Java的隐性成本进行了一系列有趣的谈话。在同一时期,他也开始倡导使用Kotlin语言进行Android开发,但几乎没有提到该语言在开发中的隐藏成本,除了推荐使用内联函数。现在,Kotlin在Android Studio 3.0中得到Google的正式支持,我认为通过研究它产生的字节码来写一些这方面的文章是一个不错的主意。

Kotlin是一种现代化的编程语言,与Java相比,它具有更多的语法糖,而且在后台还有更多的“黑魔法”,但是其中有一些性能的成本是不可忽略的,尤其是针对一些低端的Android设备。

这里并不是反对Kotlin,我非常喜欢这个语言因为它极大的提高了开发效率。但我也认为一个好的开发人员需要知道语言的内部工作原理,以便更明智地使用它。kotlin是非常强大的,有这样一种名言:“能力越大,责任也越大”。

这些文章尽基于Kotlin 1.1的JVM / Android实现,而不是Javascript实现。

Kotlin字节码分析工具

这个工具会为你将kotlin代码转换为字节码文件,在Android Studio中安装了Kotlin插件后,选择“Show Kotlin Bytecode”打开当前类的字节码文件,然后,可以点击“Decompile”按钮阅读等效的Java代码。
(译者注:在Android studio中 首先选中你要打开的类,然后 tools->kotlin->Show Kotlin Bytecod 即可查看当前类编译的字节码文件。)

高阶函数和Lambda表达式

Kotlin可以为变量分配函数,并可以将这些函数作为参数传递给其他函数。接受其他函数作为参数的函数被称为高阶函数。
kotlin函数可用通过带有::符号的函数名来引用(译者注:通俗点讲Kotlin 中双冒号操作符 表示把一个方法当做一个参数,传递到另一个方法中进行使用),或者直接声明为匿名函数,或使用lambda表达式语法,lambda表达式是描述函数的最简洁的方式。

Kotlin是为Java 6/7和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不能直接支持lambda表达式,那么他们如何翻译成字节码呢?如你所料,lambda和匿名函数会被编译为函数对象。

函数对象

下面这段代码是上述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));
   }

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

在Android dex文件中,每个lambda表达式编译为函数后,应用的方法总数会增加3到4个。

这样做的好处是这些函数对象的实例只有在使用时才会被创建,这意味着:

  • 对于捕获lambda表达式,每次将lambda作为参数传递时,都将创建一个新函数实例,执行完毕后被当做垃圾回收;
  • 对于非捕获lambda表达式(纯函数),将会创建一个单例的函数实例以便以后复用。

(译者注:当Lambda表达式访问一个定义在表达式体外的非静态变量或者对象时,这个Lambda表达式称为“捕获的”。比如,下面这个lambda表达式捕捉了变量x:
int x = 5; return y -> x + y; 为了保证这个lambda表达式声明是正确的,被它捕获的变量必须是“有效final”的。所以要么它们需要用final修饰符号标记,要么保证它们在赋值后不能被改变。)

由于示例中的调用者代码使用的是非捕获lambda表达式的形式,因此它会被编译为单例,而不是内部类。

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

建议:如果使用捕获lambda表达式,避免重复调用高阶函数以便减少垃圾收集器的压力。

装箱开销

在java8 中大约有43个特殊的函数接口用来最大限度的避免装箱和拆箱操作,而kotlin编译的函数对象仅实现了通用接口,同时使用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上这会对性能造成不可忽视的影响。

在上面被编译过的代码中,你可以看到结果被包装成了Integer对象,但是最后调用代码又立即对齐进行了拆箱操作(译者注:调用代码最后返回的是Int)。

建议:当作为参数的函数中的输入输出值涉及到基本数据类型时,请谨慎调用高阶函数,频繁的调用将会给系统带来更大的压力。

内联函数(Inline functions)

幸运的是Kotlin使用了一个很棒的技巧,以避免在使用lambda表达式时造成的额外开销,那就是将高阶函数声明为内联。声明为内联的函数会被编译器直接插入到调用者内部。而这对于高阶函数的好处是作为其参数的lambda表达式也将会被内联,实际效果是这样的:

  • 创建lambda时不会再创建函数对象;
  • 对于输入输出为原始数据类型的lambda不在涉及到装箱和拆箱操作;
  • 应用的方法总数不会再增加;
  • 不会执行实际的函数调用,提高了cpu多次处理沉重事务的能力。

当把我们上面的transaction()函数声明为内联类型时,调用者的代码就会变成下面的形式:

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

注意事项:

  • 内联函数中不能直接调用它本身或者通过其他的内联函数(译者注:本身确实不能够调用,但是通过其他内联貌似可以啊,难道是理解有误?,有明白的请在评论区留言指点);
  • public类型的内联函数只能访问本类中的public 函数及字段;
  • 代码体积会变大,多次内联一个长功能的函数将会使代码体积显著增大,如果这个长代码段中又引入的其他长功能代码,结果会更糟。

建议:可能的话,尽量将高阶函数声明为内联,保持代码行数为一个较小的数字,将大块代码移动到非内联函数中。

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

伴随对象

Kotlin类中没有静态字段或者方法,这些字段和方法可以在类中的伴随对象中声明。

伴随对象访问私有类字段

思考以下代码:

class MyClass private constructor() {
    private var hello = 0
    companion object {
        fun newInstance() = MyClass()
    }
}

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

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

在Java中我们可以通过这些字段在包中的可见性来避免生成这些setter或getter方法,但是在kotlin中不存在包的可见性。使用public 或者internal 都会导致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方法也是public的,可以直接调用,所以不需要上一步的合成方法。但是Kotlin仍然需要调用getter方法来读取一个常量。

事实证明,为了存储常量kotlin编译器会在主类中生成一个私有静态常量字段而不是在伴随对象中,但是因为静态字段在类中被声明为私有的这就需要需要另一种合成方法来从伴随对象访问它。

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

最后,该合成方法读取字段的实际值

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

换句话说,当你访问伴随对象中的私有常量字段时,代码的执行流程是这样的:

  • 调用伴随对象中的静态方法;
  • 调用伴随对象中的实例方法;
  • 调用类中的静态方法;
  • 读取静态字段并返回其值。

等效的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)
    }
}

其次,可以在伴随对象的public字段上使用@JvmField注解来指示编译器不生成任何getter或setter方法,并将其作为类中的静态字段公开,就像纯Java常量。实际上,这个注解就是为了兼容Java的原因而创建的。此外,它只能用于public字段。

最后,你还可以使用ProGuard工具优化字节码,但这种方式的兼容性较差。

建议:合理使用const关键字来声明原始数据类型和String常量避免读取这些常量带来的额外开销
对于其他类型的常量如果你需要频繁的访问它,请将它缓存在局部变量中
此外,全局公共常量最好存储在本类对象中而不是伴随对象中

这就是第一篇文章,希望可以帮助你更好地理解这些Kotlin功能,理解这一点你才会在写出更智能的代码的同时不会牺牲代码的可读性及软件性能。