阅读 383

[译]Effective Kotlin系列之考虑使用原始类型的数组优化性能(五)

翻译说明:

原标题: Effective Kotlin: Consider Arrays with primitives for performance critical processing

原文地址: blog.kotlin-academy.com/effective-k…

原文作者: Marcin Moskala

Kotlin底层实现是非常智能的。在Kotlin中我们不能直接声明原始类型(也称原语类型)的,但是当我们不像使用对象实例那样操作一个变量时,那么这个变量在底层将转换成原始类型处理。例如,请看以下示例:

var i = 10
i = i * 2
println(i)
复制代码

上述的变量声明在Kotlin底层是使用了原始类型int.下面这是上述例子在Java中的内部表达:

// Java
int i = 10;
i = i * 2;
System.out.println(i);
复制代码

上述使用int的实现到底比使用Integer的实现要快多少呢? 让我们来看看。我们需要在Java中定义两种方式函数声明:

public class PrimitivesJavaBenchmark {

    public int primitiveCount() {
        int a = 1;
        for (int i = 0; i < 1_000_000; i++) {
            a = a + i * 2;
        }
        return a;
    }

    public Integer objectCount() {
        Integer a = 1;
        for (Integer i = 0; i < 1_000_000; i++) {
            a = a + i * 2;
        }
        return a;
    }
}
复制代码

当你测试这两种方法的性能时,您会发现一个巨大的差异。在我的机器中,使用Integer需要4905603ns, 而使用原始类型需要316954ns(这里是源码,自己检查运行测试)这少了15倍!这是一个巨大的差异!

怎么会产生如此之大的差异呢? 原始类型比对象类型更加轻量级。在内存中原始类型的变量仅仅存储是一个数值而已,它们没有面向对象那一整套的内存分配过程。当你看到这种差异时,你应该感到庆幸,因为在Kotlin底层实现会尽可能使用原始类型,而且这种底层的优化我们甚至毫无察觉。但是你也应该知道有些情况底层编译器是不会转化成原始类型来做优化处理的:

  • 可空类型不能是原始类型。编译器是很智能的,尽管是可空类型,可是当它检测到你没有对可空类型变量设置null值时,然后它还是会使用原始类型处理的。如果编译不能确定最终检测结果,那么它将默认使用非原始类型。请记住,这是代码性能关键部分因可空性引入的额外成本。
  • 原始类型不能用于泛型类型参数。

第二个问题显得尤为重要,因为我们在大部分场景下很少会对代码中数值做处理,但是我们经常会对集合中的元素做操作。可是问题来了,泛型类型参数不能使用原始类型,但是每个泛型集合都只能使用非原始类型了。例如:

  • Kotlin中的List<Int>等价于Java中的List<Integer>(注意下: 这个地方有点问题,纠正下原文作者的一个小错误,实际上是Kotlin中的MutableList<Int>等价于Java中的List<Integer>,但是作者这里主要想表明在Kotlin中作为泛型类型参数Int类型情况下等同于Java中的包装器类型Integer而不是原始类型int)
  • Kotlin中的Set<Double>等价于Java中的Set<Double>(注意下: 这个地方有点问题,纠正下原文作者的一个小错误,实际上是Kotlin中的MutableSet<Double>等价于Java中的Set<Double>,但是作者这里主要想表明在Kotlin中作为泛型类型参数Double类型情况下等同于Java中的包装器类型Double而不是原始类型double)

当我们需要操作数据集合,这将是一笔很大的性能开销。但是也是有解决方案的, 因为Java集合允许使用原始类型。

// Java
int[] a = { 1,2,3,4 };
复制代码

如果在Java中可以使用原始类型的数组,那么在Kotlin也是可以使用原始类型的数组的。为此,我们需要使用一种特殊的数组类型来表示具有不同原始类型的数组: IntArrayLongArrayShortArrayDoubleArrayFloatArray或者CharArray. 让我们使用IntArray,看看与List <Int>相比对代码的性能影响:

open class InlineFilterBenchmark {

    lateinit var list: List<Int>
    lateinit var array: IntArray

    @Setup
    fun init() {
        list = List(1_000_000) { it }
        array = IntArray(1_000_000) { it }
    }

    @Benchmark
    fun averageOnIntList(): Double {
        return list.average()
    }

    @Benchmark
    fun averageOnIntArray(): Double {
        return array.average()
    }
}
复制代码

尽管差异不是特别大,但是也是差异也是非常明显的。例如,因为在底层实现上IntArray是使用原始类型的,所以IntArray数组的average()函数会比List<Int>集合运行效率高了约25%左右。(这里是源码,自己检查运行测试)

具有原始类型的数组也会比集合更加轻量级。进行测量时,您会发现IntArray上面分配了400000016个字节,而List<Int>分配了2000006944个字节。大概是5倍的差距。

正如你所看到那样,使用具有原始类型的变量或者数组都是优化性能关键部分一种手段。它们需要分配的内存更少,并且处理的速度更快。尽管原始类型数组在大多数情况下作了优化,但是默认情况下可能更多是使用集合而不是数组。因为集合相比数据更加直观和更经常使用。但是你也必须记住原始类型的变量和原始类型数组带来的性能优化,并且在合适的场景中使用它们。

译者有话说

这篇Effective Kotlin系列的文章比较简单,但是也很重要。它指出了我们经常会忽略的原始类型数组。相信很多人都习惯于使用集合,甚至有的人估计都没怎么用过Kotlin中的IntArray、LongArray、FloatArray等,平时不管是什么场景都使用集合一梭哈。这也很正常,因为集合基本上可以替代数组出现所有场景,而且集合使用起来更加直观和方便。但是之前的你可能不知道原来原始类型的数组可以在某些场景替代集合反而可以优化性能。所以原始类型的数组是有一定应用场景的,那么从读了这篇文章起,请一定要记住这个优化点。关于这篇文章我还想再补充几点哈:

  • 1、解释下文章中的原始类型

请注意: 文章中的原始类型(原语类型或基本数据类型)实际上不是Kotlin中的Int、Float、Double、Long等这些类型,原始类型实际上它不对应一个类,就像我们常在Java中说的String不是原始类型,而是引用类型。实际这里原始类型就是指Java中的int、double、float、long等非引用类型。为什么说Kotlin中的Int不是原始类型,实际上它更是一种引用类型,一起来看Int的源码:

public class Int private constructor() : Number(), Comparable<Int> {
    companion object {
        public const val MIN_VALUE: Int = -2147483648
        public const val MAX_VALUE: Int = 2147483647
        @SinceKotlin("1.3")
        public const val SIZE_BYTES: Int = 4
        @SinceKotlin("1.3")
        public const val SIZE_BITS: Int = 32
    }
复制代码

可以明显看出实际上Int是在Kotlin中定义的一个类,它属于引用类型,不是原始类型。所以我们平时在Kotlin中是不能直接声明原始类型的,而所谓原始类型是Kotlin编译器在底层做的一层内部表达。在Kotlin中声明Int类型,实际上底层编译器会根据具体使用情况,智能推测出是将Int表达为包装器Integer还是原始类型int。如果不信,请看下面这个解释的源码论证。

  • 2、解释下文章中的这句话 "尽管是可空类型,可是当它检测到你没有对可空类型变量设置null值时,然后它还是会使用原始类型处理的,如果设置null就当做非原始类型处理"

把上面那句话说的通俗就是,声明一个可空类型Int?变量,如果没有对它做赋值null的操作,那么编译器在底层实现会把这个Int?类型使用原始类型int,如果有赋值null操作就会使用包装器类型Integer.一起来看个例子

//kotlin定义的源码
fun main(args: Array<String>) {
    var number: Int?
    number = 2
    println(number)
}
//反编译后的Java代码
  public static final void main(@NotNull String[] args) {
      Intrinsics.checkParameterIsNotNull(args, "args");
      int number = 2;//可以明显看到number变量使用的是int原始类型
      System.out.println(number);
 }
复制代码

如果把上述例子改为赋值为null

//kotlin定义的源码
fun main(args: Array<String>) {
    var number: Int? = null
    number = 2
    println(number)
}
//反编译后的Java代码
  public static final void main(@NotNull String[] args) {
      Intrinsics.checkParameterIsNotNull(args, "args");
      Integer number = (Integer)null;//这里number变量是使用了Integer包装器类型
      number = 2;
      int var2 = number;
      System.out.println(var2);
   }
复制代码

通过上述代码的对比,可以发现Kotlin编译器是非常智能的,这也就是解释了虽然在Kotlin定义的是Int,但是会根据不同的使用情况,最终转换成结果也不一样的,所以使用的时候一定要做到心里有数。

  • 关于使用原始类型数组的建议

其实我们大多数情况下还是使用集合的,因为数组使用具有局限性。那么什么时候使用原始类型数组呢? 元素的类型应该是Int、Float、Double、Long等这些类型,并且长度还是固定的,这种情况更多考虑是原始类型数组来替代集合的使用,因为它效率更高。其他非这种场景还是建议使用集合。

Kotlin系列文章,欢迎查看:

Effective Kotlin翻译系列

原创系列:

翻译系列:

实战系列:

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

关注下面的标签,发现更多相似文章
评论