性能:装箱、Sequence、协程与 R8
Kotlin 的卖点之一是「零开销抽象」,而前面几章你已经见识过它是怎么做到的(inline、value class、编译期展开)。但「大多数抽象是零开销」不等于「所有抽象都是」,而知道边界在哪,是高手和熟练工的分水岭。这一章把账一笔笔算清:什么时候会装箱、集合操作到底多花了多少、协程的真实成本是多少、R8 会替你抹平哪些。但请先读最后一节 —— 因为这一章最重要的内容是「什么时候不该优化」,而绝大多数人在这上面浪费的时间,远比他们省下的多。
账单一:装箱
JVM 有两套数字:基本类型(int,栈上,4 字节)和包装类型(Integer,堆上一个对象)。Kotlin 只有一个 Int,编译器替你决定用哪套:
| 写法 | 编译成 | 代价 |
|---|---|---|
val x: Int = 1 | int | 零 |
val x: Int? = 1 | Integer | 装箱 —— 因为 int 表示不了 null |
List<Int> | List<Integer> | 每个元素都是对象(泛型擦除后只能装对象,第 5 章) |
IntArray | int[] | 零 —— 这就是它存在的理由 |
Array<Int> | Integer[] | 装箱(注意它和 IntArray 是两个不同的东西!) |
泛型函数 fun <T> f(x: T) 传 Int | 装箱 | 同上 |
好消息:JVM 缓存了 -128..127 的 Integer,所以小整数的装箱通常不分配新对象。坏消息:这也意味着 === 在这个区间内外行为不一致 —— 所以永远别用 === 比较数字。
什么时候真的要管:处理几万个数字的数组(图像像素、音频采样、大量坐标)时,用 IntArray/FloatArray 而不是 List<Int>。其余情况 —— 不要管。
账单二:集合操作的中间列表
users.filter { it.active } // ← 新建一个 List
.map { it.name } // ← 又新建一个 List
.first() // ← 只要第一个……前面全白算了
每个操作符都算完全部、产出一个新 List,再交给下一个。而 Sequence 是逐元素纵向流过整条管道的 —— 一个元素走完 map → filter,命中就停。
下面这台计数器让你亲眼看差别(拖动 n 到 1000 试试):
用 Sequence:元素多(几百以上)且管道长(3+ 个操作符),或者有提前退出(first/take/any/find)。
用 List:元素少(几十个以内)。Sequence 的每元素开销(每一步都要走一次迭代器的间接调用)在小集合上反而更慢,而且更难读。
这是一个真实的反直觉点:「惰性更快」是错的,惰性只在「省下的计算 > 多出的间接开销」时才更快。Android 上大多数列表只有几十条 —— 老老实实用 List。
账单三:lambda 与协程
lambda(第 2、9 章):传给 inline 函数 → 零分配;否则捕获变量的 lambda 每次执行都 new 一个对象。标准库的集合操作和作用域函数全是 inline 的,所以你随便用。真正要留意的是你自己写的高阶函数,以及存起来的回调。
协程:一个协程的成本 = 一个状态机对象(几十字节)+ 一个 Job 对象。比线程便宜三个数量级,但不是零:
- 别为每个列表项启动一个协程(一千个 item = 一千个 Job + 一千次调度)。
withContext不是免费的:它至少是一次调度(如果真的要换线程,还有一次线程切换)。在一个循环里对每个元素withContext(IO),你会得到成千上万次调度 —— 把withContext提到循环外面。- 但也别过度节省。一次协程启动的开销大约在微秒级别,而一次网络请求是几百毫秒。为了省几微秒把代码写丑,是拿命换钱。
账单四:几个常见误解
| 传言 | 真相 |
|---|---|
「字符串模板 "$a-$b" 慢」 | 假。编译成 StringBuilder(新 JVM 上是 invokedynamic 拼接),和手写一样快。放心用 |
「data class 比普通类慢」 | 假。生成的方法和你手写的一模一样 |
| 「扩展函数有额外开销」 | 假。它就是个静态方法(第 2 章) |
「?. 有开销」 | 基本没有。一个 null 检查而已,JIT 眼里等于零 |
| 「when 比 if-else 快」 | 看情况。连续整数/枚举 → tableswitch(O(1));sealed 类型 → 一串 instanceof(顺序判断)。但这点差别在真实应用里几乎从不重要 |
| 「协程比线程快」 | 不准确。协程不会让你的 CPU 计算变快 —— 它让你能用更少的线程处理更多的等待。它优化的是并发能力,不是计算速度 |
Android 侧:R8 和基线剖面
你的代码在设备上跑之前,还要过 R8 这一关(压缩、优化、混淆、脱糖)。它做的事包括:删掉没用到的代码和资源(这是 APK 瘦身的主力)、内联小方法、消除一些无用的对象分配。
这意味着:你在反编译里看到的「多一个对象分配」,有时会被 R8 抹掉。所以第 2 章那句话再说一遍 —— 反编译告诉你「编译器生成了什么」,不是「设备上最终跑了什么」。判断性能的最终裁判是 profiler。
而在真实的 Android 性能问题里,比上面所有账单加起来都重要的是这一条:
Android 上的代码默认是解释执行的,跑热了才会被 JIT 编译成机器码。所以冷启动和首次滚动特别慢 —— 而那恰恰是用户对性能感知最强的时刻。
基线剖面让你把「关键路径上的方法」列出来,在安装时就 AOT 编译好。收益是几十个百分点的启动时间和首帧时间改善 —— 比你抠一百个装箱有用一万倍。
如果你的 App 还没配基线剖面,那是你现在能做的最有价值的性能工作,没有之一。
最重要的一节:什么时候不该优化
如果这一章只能留一段,留这段。
你的 Android App 的性能问题,几乎从来不在 Kotlin 的语言特性上。它们在这些地方(按出现频率排序):
- 主线程上的 IO(数据库、文件、SharedPreferences 的
commit())—— 这是 ANR 的头号来源。 - 过度重组 / 过度绘制(第 13 章:不稳定的参数、每帧新建对象)。
- 冷启动(Application 里的初始化、没配基线剖面)。
- 图片(尺寸、解码、缓存)。
- 布局层级 / 长列表没做好复用。
- ……(很长的距离)……
- 然后才是:你的
List<Int>装箱了。
纪律三条:
- 先测,再改。用 Android Studio 的 Profiler、Macrobenchmark(测启动/滚动)、
kotlinx-benchmark(测纯函数)。没有测量的优化是迷信。 - 优化用户能感知的东西。省下 3 微秒没有意义,少掉一帧有意义。
- 可读性是默认的优先级。只有当 profiler 指着某一行说「就是它」时,你才有权把代码写丑。而这种情况,比你以为的少得多。
不。在同等写法下,Kotlin 编译出的字节码和 Java 几乎一致(第 2 章你已经亲眼看过了)。差异只出现在你用了 Java 没有的抽象时 —— 而那些抽象大多是零开销的(inline、value class),少数不是(装箱、中间集合),而这些坑Java 一样有(Java 的 Stream 也建中间集合,Java 的 List<Integer> 也装箱)。
唯一 Kotlin 独有的固定成本是标准库的体积(对 APK 大小有几百 KB 的影响,R8 之后更小)和空检查的 Intrinsics 调用(可以忽略不计,而且它换来的是不会崩在三层之外的 NPE)。
「Kotlin 慢」是一个已经被反复证伪的传言。如果有人拿它当拒绝 Kotlin 的理由,请他拿 benchmark 说话。
1. 先去看你的 App 有没有配基线剖面。没有的话,这一条比本章其他所有内容加起来都值钱。
2. 用 Profiler 抓一次真实的卡顿(滚动一个长列表)。看看火焰图上最宽的那一格是什么 —— 我打赌不是装箱。
3. 搜项目里的 asSequence()。检查每一处:集合真的很大吗?管道真的很长吗?小集合上的 Sequence 是负优化,而且更难读。
4. 搜循环里的 withContext。把它提到循环外面 —— 这类改动通常能省下成千上万次调度。
5. 如果有真正的数值密集代码(图像、音频、加密):把 List<Int> 换成 IntArray,然后用 kotlinx-benchmark 测一下差别。这是本章唯一一条「值得为装箱动手」的场景。
淬火炉结束。最后一炉「回火」:补上你不在的这几年 —— 语言、工具链、疆域。