作者:Petterp
引言
Kotlin
是一个非常 yes 的语言,从 null安全 ,支持 方法扩展 与 属性扩展,到 内联方法、内联类 等,使用Kotlin变得越来越简单舒服。但编程从来不是一件简单的工作,所有简洁都是建立在复杂的底层实现上。那些看似简单的kt代码,内部往往隐藏着不容忽视的内存开销。
介于此,本篇将根据个人开发经验,聊一聊 Kotlin
中那些隐藏的内存陷阱,也希望每一个同学都能在 性能 与 优雅 之间找到合适的平衡。
本篇定位简单 ,主要通过示例+相应字节码分析的方式,对日常开发非常有帮助。
导航
学完本篇,你将了解到以下内容:
lazy
, 没你想的那么简单;apply
!= 构建者模式;arrayOf()
的使用细节。好了,让我们开始吧!
密封类的小细节
密封类用来表示受限的类继承结构:当一个值为有限几种的类型、而不能有任何其他类型时。在某种意义上,他们是枚举类的扩展:枚举类型的值集合也是受限的,但每个枚举常量只存在一个实例,而密封类的一个子类可以有可包含状态的多个实例。摘自Kotlin中文文档
关于它用法,我们具体不再做赘述。
密封类虽然非常实用,经常能成为我们多type的绝佳搭配,但其中却藏着一些使用的小细节,比如 构造函数传值所导致的损耗问题。
错误示例
如题, 我们有一个公用的属性 sum
,为了便于复用,我们将其抽离到 Fruit
类构造函数中,让子类便于初始化时传入,而不用重复显式声明。
上述代码看着似乎没什么问题?按照传统的操作习惯,我们也很容易写出这种代码。
如果我们此时来看一下字节码:
不难发现,无论是子类Apple还是父类Fruit,他们都生成了 getSum()
与 setSum()
方法 与 sum
字段,而且,父类的 sum
完全处于浪费阶段,我们根本没法用到。
显然这并不是我们愿意看到的,我们接下来对其进行改造一下。
改造实践
我们对上述示例进行稍微改造,如下所示:
如题,我们将sum变量定义为了一个抽象变量,从而让子类自行实现。对比字节码可以发现,相比最开始的示例,我们的父类 Fruit
中减少了一个 sum
变量的损耗。
那有没有方法能不能把 getsum()
和 setSum()
也一起移除呢? ♂️
答案是可以,我们利用 接口 改造即可,如下所示:
如上所示,我们增加了一个名为 IFruit
的接口,并让 密封父类 实现了这个接口,子类默认在构造函数中实现该属性即可。
观察字节码可发现,我们的父类一干二净,无论是从包大小还是性能,我们都避免了没必要的损耗。
内联很好,但别太长
inline
,翻译过来为 内联 ,在 Kotlin
中,一般建议用于 高阶函数
中,目的是用来弥补其运行时的 额外开销。
其原理也比较简单,在调用时将我们的代码移动到调用处使用,从而降低方法调用时的 栈帧 层级。
栈帧: 指的是虚拟机在进行方法调用和方法执行时的数据结构,每一个栈帧里都包含了相应的数据,比如 局部参数,操作数栈等等。 Jvm在执行方法时,每执行一个方法会产生一个栈帧,随后将其保存到我们当前线程所对应的栈里,方法执行完毕时再将此方法出栈, 所以内联后就相当于省了一个栈帧调用。
如果上述描述中,你只记住了后半句,降低栈帧 ,那么此时你可能已经陷入了一个使用陷阱?
错误示例
如下截图中所示,我们随便创建了一个方法,并增加了 inline
关键字:
观察截图会发现,此时IDE已经给出了提示,它建议你移除 inline
, Why? 为什么呢?
不是说内联可以提高性能吗,那么不应该任何方法都应该加
inline
提高性能吗?(就是这么倔强 )
上面我们提到了,内联是会将代码移动到调用处,降低 一层栈帧,但这个性能提升真的大吗?
再仔细想想,移动到调用处,移动到调用处。这是什么概念呢?
假设我们某个方法里代码只有两行(我想不会有人会某个方法只有一行吧 ),这个方法又被好几处调用,内联是提高了调用性能,毕竟节省了一次栈帧,再加上方法行数少(暂时抛弃虚拟机优化这个底层条件)。 但如果方法里代码有几十行?每次调用都会把代码内联过来,那调用处岂不 ,带来的包大小影响某种程度上要比内联成本更高 !
如下图所示,我们对上述示例做一个论证:
Jvm: 我谢谢你。
推荐示例
我们在文章最开始提到了,Kotlin inline
,一般建议用于 高阶函数(lambda)
中。为什么呢?
如下示例:
转成字节码后,可以发现,tryKtx()
被创建为了一个匿名内部类 (Simple$test|1)
。每次调用时,相当于需要创建匿名类的实例对象,从而导致二次调用的性能损耗。
那如果我们给其增加 inline
呢? ,反编译后相应的 java代码 如下:
具体对比图如上所示,不难发现,我们的调用处已经被替换为原方法,相应的 lambda
也被消除了,从而显著减少了性能损耗。
Tips
如果查看官方库相应的代码,如下所示,比如 with
:
不难发现,inline
的大多数场景仅且在 高阶函数 并且 方法行数较短 时适用。因为对于普通方法,jvm本身对其就会进行优化,所以 inline
在普通方法上的的意义几乎聊胜于无。
总结如下:
- 因为内联函数会将方法函数移动到调用处,会增加调用处的代码量,所以对于较长的方法应该避免使用;
- 内联函数应该用于使用了 高阶函数(lambda) 的方法,而不是普通方法。
伴生对象,也许真的不需要
在 Kotlin
中,我们不能像 Java
一样,随便定义一个静态方法或者静态属性。此时 companion object
(伴生对象)就会派上用场。
我们常常会用于定义一个 key
或者 TAG
,类似于我们在 Java
中定义一个静态的 Key
。其使用起来也很简单,如下所示:
class Book {
companion object {
val SUM_MAX: Int = 13
}
}
这是一段普通的代码,我们在 Book
类中增加了一个伴生对象,其中有一个静态的字段 SUM_MAX。
上述代码看着似乎没什么问题,但如果我们将其转为字节码后再看一看:
不难发现,仅仅只是想增加一个 静态变量 ,结果凭空增加了一个 静态对象 以及多增加了 get() 方法,这个成本可能远超出一个 静态参数 的价值。
const
抛开前者不谈(静态对象),那么我们有没有什么方法能让编译器少生成一个 get()
方法呢(非private)?
注意观察IDE提示,IDE会建议我们增加一个 const
的参数,如下所示:
companion object {
const val SUM_MAX: Int = 13
}
增加了 const
后,相应的 get()
方法也会消失掉,从而节省了一个 get()
方法。
const
,在Kotlin
中,用于修饰编译时已知的val
(只读,类似final) 标注的属性。
- 只能用于顶层的class中,比如
object class
或者companion object
; - 只能用于基本类型;
- 不会生成get()方法。
JvmField
如果我们 某个字段不是 val
标注呢,其是 var
(可变)修饰的呢,并且这个字段要对外暴漏(非private)。
此时不难猜测,相应的字节码后肯定会同时生成 set与get 方法。
此时就可以使用 @JvmField
来进行修饰。
如下所示:
class Book {
companion object {
@JvmField
var sum: Int = 0
}
}
相应的字节码如下:
Tips
让我们再回到伴生对象本身,我们真的一定需要它吗?
对于和业务强关联的 key
或者 TAG
,可以选择使用伴生对象,并为其增加 const val
,此时语义上的清晰比内存上的损耗更加重要,特别在复杂的业务背景下。
但如果仅用于保存一些key,那么完全可以使用 object Class
替代,如下所示,将其回归到一个类中:
object Keys {
const val DEFAULT_SUM = 10
const val DEFAULT_MIN = 1
const val LOGIN_KEY = 99
}
2022/12/6补充
使用 kotlin
文件形式去写。
这种写法属于以增加静态类的方式避免伴生对象的内存损耗,如果你的场景是单独的增加一个tag,那么这种写法比较推荐。
对于sdk的开发者,同时建议增加 @file:JvmName(“ 文件名”) ,从而禁止生成的 xxxkt类 在 java 语境下被调用到 (欺负java不识别空格 )。
@file:JvmName(" Testxx")
private const val TAG = "KEY_TEST_TAG"
class TestKt {
private fun test() {
println(TAG)
}
}
Apply!=构造者模式
apply
作为开发中的常客,为我们带来了不少便利。其内部实现也非常简单,将我们的对象以函数的形式返回,this
作为接收者。从而以一种优雅的方式实现对对象方法、属性的调用。
但经常会看到有不少同学在构造者模式中写出以下代码,使用 apply
直接作为返回值,这种方式固然看着优雅,性能也几乎没有差别。但这种场景而言,如果我们注意到其字节码,会发现其并不是最佳之选。
示例
如题,我们存在一个示例Builder,并在其中添加了两个方法,即 addTitle(),与 addSecondTitle() 。后者以 apply
作为返回值,代码可读性非常好,相比前者,在 kotlin
中其显得非常优雅。
但如果我们去看一眼字节码呢?
如上所示,使用了 apply
后,我们的字节码中增加了多余步骤,相比不使用的,包大小会有一点影响,性能上几乎毫无差距。
Tips
apply
很好用,但需要区分场景。其可以改善我们在 kotlin
语义下的编程体验,但同时也不是任何场景都需要其。
如果你的方法中需要对某个对象操作多次,比如调用其方法或者属性,那么此时可以使用 apply
,反之,如果次数过少,其实你并不需要 apply
的优雅。
警惕,lazy 的使用方式
lazy
,中文译名为延迟初始化,顾名思义,用于延迟初始化一些信息。
作用也相对直接,如果我们有某个对象或字段,我们可能只想使用时再初始化,此时就可以先声明,等到使用时再去初始化,并且这个初始化过程默认也是线程安全(不特定使用NONE)。这样的好处就是性能优势,我们不必应用或者页面加载时就初始化一切,相比过往的 var xx = null ,这种方式一定程度上也更加便捷。
相应的,lazy一共有三种模式,即:
SYNCHRONIZED
(同步锁,默认实现)PUBLICATION
(CAS)NONE
(不作处理)
lazy
虽然使用简单,但在 Android
的开发背景下,lazy
经常容易使用不当 ♂️,也因此常常会出现为了[便利] 而造成的性能隐患。
示例如下:
如上所示,我们延迟初始化了一个点击事件,方便在 onCreate()
中进行设置 点击事件 以及后续复用。
上述示例虽然看着似乎没什么问题。但放在这样的场景下,这个 mClickListener
本身的意义也许并不大。为什么这样说?
UI线程
,当前操作方法又是 onCreate()
,即当前本身就是线程安全。此时依然使用 lazy(sys) ,即浪费了一定初始化性能。lazy
对象,即 SYNCHRONIZED
对应的 SynchronizedLazyImpl
。也就是说,我们一开始就已经多生成了一个对象。然后仅仅是为了一个点击事件,内部又会进行包装一次。相似的场景有很多,如果你的lazy是用于 Android生命周期组件 ,再加上本身会在 onCreate()
等中进行调用,那么很可能完全没有必要延迟初始化。
关于 arrayOf() 的使用细节
对于 arrayOf
,我们一般经常用于初始化一个数组,但其也隐藏着一些使用细节。
通常来说,对于基本类型的数组,建议使用默认已提供的函数比如,intArrayOf()
等等,从而便于提升性能。
至于原因,我们下面来分析,如下所示:
fun test() {
arrayOf(1, 2, 3)
}
fun testNoInteger() {
intArrayOf(1, 2, 3)
}
我们提供了两个方法,前者是默认方法,后者是带优化的方法,具体字节码如下:
如题,不难发现,前者使用的是 java
中的 包装类型 ,使用时还需要经历 拆箱 与 装箱 ,而后者是非包装类型,从而免除了这一操作,从而节省性能。
什么是装箱与拆箱? 背景:Java 中,万物皆对象,而八大基本类型不是对象,所以 Java 为每种基本类型都提供了相应的包装类型。 装箱就是指将基本类型转为包装类型,拆箱则是将包装类型转为基本类型。
总结
本篇中,我们以日常开发的视角,去探寻了 Kotlin
中那些 [隐藏] 的内存陷阱。
仔细回想,上述的不恰当用法都是建立在 [不熟练] 的背景下。Kotlin
本身的各种便利没有任何问题,其使得我们的 代码可读性 与 开发舒适度 增强了太多。但如果同时,我们还能注意到其背后的实现,也是不是就能在 性能与优雅 之间找到了一种平衡。
所谓左眼 kt
,右眼 java
,正是如此。作为一个 Kotlin
使用者,这也是我们所不断追寻的。