Kotlin | 这些隐藏的内存陷阱,你应该熟记于心

news2024/9/22 12:29:30

作者:Petterp

引言

Kotlin 是一个非常 yes 的语言,从 null安全 ,支持 方法扩展属性扩展,到 内联方法内联类 等,使用Kotlin变得越来越简单舒服。但编程从来不是一件简单的工作,所有简洁都是建立在复杂的底层实现上。那些看似简单的kt代码,内部往往隐藏着不容忽视的内存开销。

介于此,本篇将根据个人开发经验,聊一聊 Kotlin 中那些隐藏的内存陷阱,也希望每一个同学都能在 性能优雅 之间找到合适的平衡。

本篇定位简单🔖,主要通过示例+相应字节码分析的方式,对日常开发非常有帮助。

导航

学完本篇,你将了解到以下内容:

  1. 密封类构造函数传值的使用细节;
  2. 内联函数,你应该注意的地方;
  3. 伴生对象隐藏的性能问题;
  4. lazy, 没你想的那么简单;
  5. apply!= 构建者模式;
  6. 关于 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 本身的意义也许并不大。为什么这样说?

  1. 上述使用了 默认的lazy ,即同步锁,而Android默认线程为 UI线程 ,当前操作方法又是 onCreate() ,即当前本身就是线程安全。此时依然使用 lazy(sys) ,即浪费了一定初始化性能。
  2. MainActivity初始化时,会先在 构造函数 中初始化 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 使用者,这也是我们所不断追寻的。

善用字节码分析,你的技艺也将更上一筹。

更多Android 学习手册

Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集https://qr18.cn/CgxrRy
音视频面试题锦:https://qr18.cn/AcV6Ap

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/431642.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

电子招标采购系统:营造全面规范安全的电子招投标环境,促进招投标市场健康可持续发展

营造全面规范安全的电子招投标环境,促进招投标市场健康可持续发展 传统采购模式面临的挑战 一、立项管理 1、招标立项申请 功能点:招标类项目立项申请入口,用户可以保存为草稿,提交。 2、非招标立项申请 功能点:非招标…

使用SpringAOP的方式修改controller接口返回的数据

1为什么需要修改返回接口的数据? 先看一个关于返回接口数据中包含时间的接口,如下接口中的birth属性,是日期,假设我们不做任何处理,那么在页面,我们看到的将是如下的时间显示效果,这明显不是我…

NVM-无缝切换Node版本

NVM-无缝切换Node版本 如果未使用nvm之前已经下载了node,并且配置了环境变量,那么此时删除这些配置(Node的环境以及Node软件),使用nvm是为了在某些项目中使用低版本的node NVM下载 进入github的nvm readme: https://github.com/coreybutler/nvm-windows…

企业如何利用数据打造新的人才战略?

由于利率上升、能源价格上涨、政治不确定性、全球供应限制以及寻找和资助昂贵人才的问题,企业的优先事项,也就是人们的优先事项,正在以前所未有的速度发生转变。在数据的基础之上迅速做出正确决策,并灵活精准地向他人传达意义。 …

必要的项目管理软件因素

什么样的项目管理软件好?对于一个项目团队来说,从项目开始到项目结束,需要多个部门的配合。每个成员可能会参与一个以上的项目,这通常需要并行的多个项目。据介绍,国外90%以上的项目是用软件管理的,而中国只…

dump 定位分析

在缺少pdb的时候如何分析dump? windbgidaWindbg定位崩溃位置 通过windbg打开dump,并且分析dump !analyze -v 分析: 分析dump: !analyze -v错误原因:读取空指针错误线程:00001e04,可通过命令…

【Linux进阶篇】磁盘及分区

目录 🍁文件系统 🍂虚拟机添加磁盘 🍂磁盘分区方式 🍂分区格式化 🍂挂载磁盘 🍁磁盘高级管理 🍂LVM:逻辑卷 🍃创建PV 物理卷 🍃创建VG 卷组 🍃创建…

说说你对 Webpack 的理解?解决了什么问题?

1、对webpack的理解 简单来说,webpack 是一个用于现代JavaScript应用程序的静态模块打包工具。 当 webpack处理应用程序时,它会在内部构建一个依赖图,此依赖图对应映射到项目所需的每个模块(不再局限js文件)&#xf…

【数据结构与算法】 - 双向链表 - 详细实现思路及代码

目录 一、概述 二、双向链表 三、双向链表实现步骤  📌3.1 C语言定义双向链表结点  📌3.2 双向链表初始化  📌3.3 双向链表插入数据  📌3.4 双向链表删除数据  📌3.5 双向链表查找数据  📌3.6 双向链…

总线仿真与测试工具CANoe介绍(图文并茂)

1、什么是CANoe CANoe是德国Vector公司的一款用于开发、测试和分析单个ECU和整个ECU网络的综合性工具,包括软件和硬件。它在整个开发过程中为网络设计者、开发和测试工程师提供支持:从规划到系统级测试。由于其多种变体和功能能够对不同的项目提供支持,被全球OEM和供应商广…

USB 速度识别

文章目录USB 速度识别低速设备全速设备高速设备空闲状态低速 / 全速设备高速设备SE0SE1USB 速度识别 在 USB host 或者 hub 的每个下行端口(downstream facing port)的 D 和 D- 上分别接了一个 15KΩ 的下拉电阻到地,这样,当端口…

SpringBoot自动装配原理(附面试快速答法)

文章目录SpringBoot自动装配原理1. 从调用SpringApplication构造器方法开始2. 解析启动类4.按需装配4.1 分析dubbo自动装配5. 如果定义自己的starter6. 面试答法SpringBoot自动装配原理 之前面试被问到这个题目,只会答一些spi、AutoConfigration注解、Import之类的&…

《SQLi-Labs》01. Less 1~5

Less-1 ~ Less-5 前言Less-1知识点题解 Less-2题解 Less-3题解 Less-4题解 Less-5知识点题解 sqli。开启新坑。 前言 对于新手,为了更加直观的看到 sql 注入语句,可以在以下文件添加两句: echo $sql; # 将构造的 sql 语句进行输出 echo &qu…

Maven依赖冲突分析和解决

使用maven管理jar包依赖时,可能会出现jar包版本冲突,不同版本的api调用方式可能不同,会出现NoSuchMethodError和ClassNotFoundException问题,甚至编译不通过,如:在common-lang3 的3.8.1版本中MethodUtils::…

Python中的@cache巧妙用法

缓存是一种空间换时间的策略,缓存的设置可以提高计算机系统的性能,这篇文章主要介绍了Python中的cache巧妙用法,需要的朋友可以参考下 Python中的cache有什么妙用? 缓存是一种空间换时间的策略,缓存的设置可以提高计算机系统的性…

Spark 简介与原理

目录标题1 Spark 简介与原理1.1 Spark与Hadoop的区别1.2 Spark的应用场景1.3 Spark的作业运行流程1.4 Spark 2.X与Spark 1.X的区别1 Spark 简介与原理 Spark 是一个大规模数据处理的统一分析引擎。 具有迅速、通用、易用、支持多种资源管理器的特点。 Spark生态系统: Spark SQL…

双榜加冕!加速科技荣登2023准独角兽中国未来独角兽双榜单

4月10日至11日,由杭州市人民政府、民建浙江省委会、中国投资发展促进会主办的第7届万物生长大会在杭州国际博览中心隆重举行。会上,中国投资发展促进会创投专委会、杭州市创业投资协会联合微链共同发布2023杭州市独角兽(准独角兽)…

【高危】vm2 <3.9.16 沙箱逃逸漏洞(CVE-2023-29199)

漏洞描述 vm2 是一个基于 Node.js 的沙箱环境,可以使用列入白名单的 Node 内置模块运行不受信任的代码。 vm2 3.9.16之前版本中,由于transformer.js中transformer函数中异常处理逻辑不够完善,攻击者可通过制造异常绕过handleException()并造…

精通 TensorFlow 2.x 计算机视觉:第一部分

原文:Mastering Computer Vision with TensorFlow 2.x 协议:CC BY-NC-SA 4.0 译者:飞龙 本文来自【ApacheCN 深度学习 译文集】,采用译后编辑(MTPE)流程来尽可能提升效率。 不要担心自己的形象,…

Downie 4 4.6.14 MAC上最新最好用的一款视频下载工具

Downie for Mac 简介 Downie是Mac下一个简单的下载管理器,可以让您快速将不同的视频网站上的视频下载并保存到电脑磁盘里然后使用您的默认媒体播放器观看它们。 Downie 4 下载 Downie 4 for Mac Downie 4 for Mac软件特点 支持许多站点 -当前支持1000多个不同的…