[译]Kotlin中内联类的自动装箱和高性能探索(二)

翻译说明:java

原标题: Inline Classes and Autoboxing in Kotlin安全

原文地址: typealias.com/guides/inli…性能优化

原文做者: Dave Leedsapp

在上一篇文章中,咱们知道了Kotlin的实验阶段的新特性内联类是如何让咱们"建立须要的数据类型可是不会损失咱们须要的性能"。咱们了解到:ide

  • 一、内联类包装了基础类型的值
  • 二、当代码被编译的时候,内联类的实例将会被替换成基础类型的值
  • 三、这能够大大提升咱们应用程序的性能,特别是当基础类型是一个基本数据类型时。

可是在某些状况下,内联类实际上比传统的普通类执行速度更慢! 在这篇文章中,咱们将去探索在不一样的场景下使用内联类编译代码中到底会发生什么- 由于若是咱们知道如何高效地使用他们,咱们才能从中得到更高的性能。 函数

请记住-内联类始终仍是一个实验性的特性。尽管我一直在写内联类系列的文章,而且内联类也会经历不少的迭代和修改。本文目前基于Kotlin 1.3 Release Candidate 146中实现的内联类。工具

此外,若是你尚未阅读过有关内联类的文章,那么你首先要阅读上一篇文章 [译]Kotlin中内联类(inline class)彻底解析(一)。那样你就会全身心投入并准备好阅读这篇文章。post

好的,让咱们如今开始吧!性能

高性能的奥秘

Alan被完全激怒了!在学习完内联类以后,他决定开始在他正在研究的游戏原型中使用内联类。为了看看内联类比传统的普通类到底有多好,他在他游戏评分系统中写了一些有关内联类的代码:学习

interface Amount { val value: Int }
inline class Points(override val value: Int) : Amount

private var totalScore = 0L

fun main() {
    repeat(1_000_000) {
        val points = Points(it)

        repeat(10_000) {
            addToScore(points)
        }
    }
}

fun addToScore(amount: Amount) {
    totalScore += amount.value
}
复制代码

Alan编写了这段代码的测试用例。而后,他删除第二行inline关键字,并再次运行这个测试用例。

令他惊讶的是,使用内联修饰符inline运行速度实际上明显比没有内联状况慢不少。

“到底发生了什么?”他想知道。

虽说内联类能够比传统的普通类更高性能运行,可是这一切都取决于咱们如何合理使用它们-由于咱们如何使用它们决定了值是否在编译代码中真的进行内联操做。

这是正确的 - 内联类的实例并不老是在编译的代码中内联。

何时内联类不会被内联

让咱们再一块儿看下Alan的代码,看看咱们是否能够弄明白为何他写的内联类可能没有被内联。

咱们先来看下这段代码:

interface Amount { val value: Int }
inline class Points(override val value: Int) : Amount
复制代码

在这段代码中,内联类Points实现了Amount接口。当咱们调用addToScore()函数时,会引起一个有趣的现象,尽管...

fun addToScore(amount: Amount) {
    totalScore += amount.value
}
复制代码

addToScore()函数能够接收任何Amount类型的对象。因为PointsAmount的子类型,因此咱们能够传入一个Points类型实例对象给这个函数。

这是基本的常识,没问题吧?

可是... 假设咱们的Points类的实例都是内联的-也就是说,在源码被编译的阶段,它们(Points类的实例)会被基础类型(这里是Int整数类型)给替换掉。-但是addToScore()函数怎么能接收一个基础类型(这里是Int整数类型)的实参呢?毕竟,基础类型Int并无去实现Amount的接口。

那么编译后的代码怎么可能会向addToScore函数发送一个Int类型(更确切的说是Java中的int类型)的实参,由于int类型是不会去实现Amount接口的。

答案固然是它不能啊!

所以,在这种场景下,Kotlin仍是继续使用为Points类型,而不是在编译代码中使用整数替换。咱们将这个Points类称为包装类型,而不是基础类型Int

最重要的是须要注意,这并不意味这该类永远不会被内联。它只意味着代码中某些地方没有被内联。例如,让咱们来看一下Alan中的代码,看看Points何时是内联的,何时不是内联的。

fun main() {
    repeat(1_000_000) {
        val points = Points(it) // <-- Points is inlined as an Int here(Points类在这是内联的,并被当作Int替换)

        repeat(10_000) {
            addToScore(points)  // <-- Can't pass Int here, so sends it
                                // as an instance of Points instead.(由于这里不能被传入Int,因此这里必须传入Points实例)
        }
    }
}
复制代码

编译器将尽量使用基础类型(例如,Int,编译为int),可是当它不能被当作基础类型使用时,它会自动实例化包装类型的实例(例如,Points)并把它传递出去。能够想象下这是编译后的代码(在Java中)大体以下:

public static void main(String[] arg) {
  for(int i = 0; i < 1000000; i++) {
     int points = i;                     // <--- Inlined here(此处内联)

     for(short k = 0; k < 10000; k++) {
        addToScore(new Points(points));  // <--- Automatic instantiation!(自动实例化)
     }
  }
}
复制代码

您能够将Points类想象为包装基础Int值的箱子。

由于编译器会自动将值放入箱子中,因此咱们把这个过程叫作自动装箱

如今咱们知道了为何Alan的代码在使用内联类的时候运行速度会比普通类要慢。每次调用addToScore()函数时,都会自动实例化一个新的Points类的实例。因此在内部循环迭代过程当中总共发生100亿次堆分配过程,这就是速度减慢的缘由。 (相比之下,使用传统的普通类,而堆分配过程只发生在外层for循环中,总共也只有100万次).

这种自动装箱过程通常仍是颇有用的-它是保证类型安全所必需的操做,固然,它同时也带来了性能开销成本,每次建立一个堆上新对象时就会存在这样性能开销。因此这就意味着做为开发者,了解哪一种场景下会发生Kotlin进行自动装箱操做是很是重要的,这样咱们就能够更明智地决定如何去使用内联类了。

那么,接下来让咱们一块儿来看看自动装箱过程可能会在哪些场景被触发!

引用超类型时会触发自动装箱操做

正如咱们所看到的那样,当咱们将Points对象传递给接收Amount类型做为形参的函数式,就触发了自动装箱操做。

即便你的内联类没有去实现接口,可是必须记住一点,内联类和普通类同样,全部内联类都是Any的子类型。因此当你将内联类的实例赋值给Any类型的变量或者传递给Any类型做为形参的函数时,都会触发预期中的自动装箱操做。

例如,假设咱们有一个能够记录日志的服务接口:

interface LogService {
    fun log(any: Any)
}
复制代码

因为这个log()函数能够接收一个Any类型的实参,一旦你传入一个Points的实例给这个函数,那么这个实例就会触发自动装箱操做。

val points = Points(5)
logService.log(points) // <--- Autoboxing happens here(此处发生自动装箱操做)
复制代码

总之一句话 - 当你使用内联类的实例(其中须要超类型)时,可能会触发自动装箱。

自动装箱与泛型

当您使用具备泛型的内联类时,也会发生自动装箱。例如:

val points = Points(5)

val scoreAudit = listOf(points)      // <-- Autoboxing here(此处发生自动装箱操做)

fun <T> log(item: T) {
    println(item)
}

log(points)                          // <-- Autoboxing here(此处发生自动装箱操做)
复制代码

在使用泛型时,Kotlin为咱们自动装箱是件好事,不然咱们会在编译代码中会遇到类型安全的问题。例如,相似于咱们以前的场景,将整数类型的值插入到MutableList<Amount>集合类型中是不安全的,由于整数类型并无去实现Amount的接口。

并且,一旦考虑到与Java互操做时,它就会变得更加复杂,例如:

  • 若是Java将List<Points>保存为List<Integer>,它是否应该能够将该类型的集合传递给以下这个Kotlin函数呢?
fun receive(list: List<Int>)
复制代码
  • Java将它传递给下面这个Kotlin函数又会怎么样呢?
fun receive(list: List<Amount>)
复制代码
  • Java可否能够构建本身的整数集合并把它传递给下面这个Kotlin函数?
fun receive(list: List<Points>)
复制代码

相反,Kotlin经过自动装箱的操做来避免了内联类和泛型一块儿使用时的问题。

咱们已经看到超类型和泛型两种场景下如何触发自动装箱操做。其实咱们还有一个值得去深究的场景 - 那就是可空性的场景!

自动装箱和可空性

当涉及到可空类型的值时,也可能会触发自动装箱操做。这个规则有点不一样,主要取决于基础类型是引用类型仍是基本数据类型。因此让咱们一次性来搞定它们。

引用类型

当咱们讨论内联类的可空性时,有两种场景能够为空:

  • 一、内联类本身的基础类型存在可空和非空的状况
  • 二、使用内联类的地方存在可空和非空的状况

例如:

// 1. The underlying type itself can be nullable (`String?`)
// 1. 基础类型本身存在可空
inline class Nickname(val value: String?)

// 2. The usage can be nullable (`Nickname?`)
//使用内联类时存在可空
fun logNickname(nickname: Nickname?) {
    // ...
}
复制代码

因为咱们有两种场景,而且每一个场景下又存在非空与可空两种状况,由于总共须要考虑四种状况。因此咱们为以下四种场景制做一张真值表!

对于每一种状况,咱们将考虑:

  • 一、基础类型的可空和非空
  • 二、使用内联类地方的可空和非空
  • 三、以及每种状况编译后的是否触发自动装箱操做

好消息的是,当基础类型是引用类型时,大多数的状况下,使用的内联类都将被编译成基础类型。这就意味着基础类型的值能够被使用且不会触发自动装箱操做。

这里只有一种状况会触发自动装箱操做,咱们须要注意 - 当基础类型和使用类型都为可空类型时。

为何在这种状况下会触发自动装箱操做?

由于当这两种场景都存在值可空状况下,你最终获得的将是不一样的代码分支,具体取决于这两种场景哪种是空的。例如,看看这段代码:

inline class Nickname(val value: String?)

fun greet(name: Nickname?) {
    if (name == null) {
        println("Who's there?")
    } else if (name.value == null) {
        println("Hello, there.")
    } else {
        println("Greetings, ${name.value}")
    }
}

fun main() {
    greet(Nickname("T-Bone"))
    greet(Nickname(null))
    greet(null)
}
复制代码

若是name形参是使用了基础类型的值-换句话说,若是编译的代码是void greet(String name)-那么它就不可能出现下面三个判断分支。那就不清楚name是否为空是应该打印Who's There仍是Hello There.

相反,函数若是编译成这样void greet(NickName name)将是有效的.这意味着只要咱们调用该函数,Kotlin就会根据须要自动触发装箱操做来包装基础类型的值。

嗯,这是能够为空的引用类型!可是能够为空的基本数据类型呢?

基本数据类型

当内联类、基本数据类型和可空性这三种因素碰在一块儿,咱们会获得一些有趣的自动装箱的场景。正如咱们在上面的引用类型中看到的那样,可空性出现场景取决于基础类型可空或非空以及使用内联类地方的可空或非空。

// 1. The underlying type itself can be nullable (`Int?`)
// 1. 基础类型本身存在可空
inline class Anniversary(val value: Int?)

// 2. The usage can be nullable (`Anniversary?`)
//使用内联类时存在可空
fun celebrate(anniversary: Anniversary?) {
    // ...
}
复制代码

让咱们构建一个真值表,就像对上面的引用类型同样作出的总结

正如你所看到的那样,上面表格中对于基本数据类型的结果除了场景B不同,其余的场景都和引用类型分析结果同样。可是这里面仍是涉及到了其余不少知识,因此让咱们花点时间一一分析下每一种状况。

对于场景A. 很容易就能分析出来。由于这里根本就没有可空类型(都是非空类型),因此类型是内联的,正如咱们所指望的那样。

对于场景B. 这是一种彻底不一样于上一个真值表中的场景,不知道你是否还记得,JVM上的intboolean等其余基本数据类型其实是不能为null的。所以,为了更好兼容null,Kotlin在此使用了包装类型(也就触发了自动装箱操做)

对于场景C. 这种场景就更有意思了。通常来讲,当你有一个相似Int能够为空的基本数据类型时,在Kotlin中,这种基本数据类型会在编译的时候转换成Java中的基本数据类型对应的包装器类型-例如Integer,它(不像int)能够兼容null值。对于场景C而言,实际上在使用内联类地方编译时候却使用基础类型,由于它自己刚好是一个Java中基本包装器类型。因此在某种层面上,你能够说基础类型被自动装箱了,可是这种自动装箱操做和内联类根本就没有任何关系。

对于场景D. 相似于上面引用类型看到的那样,当基本类型自身为可空以及使用内联类地方为可空时,Kotlin将在编译时使用包装器类型。具体缘由和引用类型同理。

其余须要牢记的点

咱们已经介绍了可能致使自动装箱的主要场景。在使用内联类时,你可能会发现对Kotlin源码编译后的字节码进行反编译,而后根据反编译的Java代码来分析是否出现自动装箱有很大的帮助。

要在IntelliJ或Android Studio中执行此操做,只需转到Tools - > Kotlin - >Show Kotlin Bytecode,而后单击Decompile按钮。

此外,请记住还有不少其余层面上都有可能影响内联类的性能。即便你对自动装箱有了充分的了解,编译器优化(Kotlin编译器和JIT编译器)之类的东西也会致使与咱们的预期性能相差很大。若是须要真正了解编码决策对性能的影响,惟一的办法就是使用基准测试工具(好比JMH)实际运行测试。

总结

在本文中,咱们探讨了使用内联类会出现一些性能影响,并了解到哪些场景下会进行自动装箱。咱们已经看到如何使用内联类并会对其性能产生影响,包括涉及到一些具体的使用场景:

  • 超类型
  • 泛型
  • 可空性

如今咱们知道这一点,咱们能够作出更加明智的选择,来高效使用内联类。

你准备好本身开始使用内联类了吗? 你无需等待-你如今就能够在IDE中尝试使用它!

译者有话说

这篇文章能够说得上是我看过最好的一篇有关Kotlin内联类性能优化的文章了,感受很是不错,做者分析得很全面也很深刻。就连官方也没有给出过如此详细介绍。关于译文中有几点我须要补充一下:

  • 对于Alan那段糟糕的代码使用inline class和普通class代码比较,粗略算了下时间,对比了真的比较惊人:

能够看到inline class看似是个性能优化操做,可是使用不当性能反而比普通类更加差。

  • 有关译文中的基础类型、基本数据类型、引用类型作一个对比解释,怕有人发蒙。

基础类型: 其实是针对内联类中包装的那个值的类型,它和基础数据类型不是一个东西。这么说吧,基础类型既能够是基本数据类型也能够是引用类型

基本数据类型: 实际上就是经常使用的Int、Float、Double、Short、Long等类型,注意String是引用类型

引用类型: 实际上就是除了基本数据类型就是引用类型,String和咱们平时自定义的类的类型都属于引用类型。

  • 关于上述基本数据类型中的场景B,可能你们仍是有点不能理解。这里给你们具体再分析下。

对于基础数据类型场景B,为何会出现自动装箱操做? 这是由于在Kotlin中使用内联类的时候用了可空类型,咱们能够用反证法来理解下,假设使用可空类型的内联类地方被编译成Java中的int等基本数据类型,在Kotlin中相似以下代码:

inline class Age(val value: Int)

fun howOld(age: Age?) {
    if(age == null){
        ...
    }
}
复制代码

编译成相似以下代码:

void howOld(int age){
    if(age == null){//这样的代码是会报错的
        ...
    }
}
复制代码

因此原假设不成立,Kotlin为了兼容null,不得不把它自动装箱使用包装器类型。

到这里有关内联类的知识文章就彻底结束了,因为内联类仍是一个实验性的特性,后期正式版本的API可能会有变更,固然我也紧跟官方最新动态,若是变更会尽快以文章形式总结出来。若是你这一期内联类知识掌握了,后面在怎么变更,你都能很快掌握它,并也会获得更多本身的体会。欢迎继续关注~~~

Kotlin系列文章,欢迎查看:

原创系列:

翻译系列:

实战系列:

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

相关文章
相关标签/搜索