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