inline与reified原理
# 04.inline与reified原理
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. inline展开机制
- 4. noinline与crossinline
- 5. reified破解擦除
- 6. 内联的代价
- 7. value类零开销
- 8. 常见反例分析
- 9. 与其他语言对比
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段反常代码
以下代码是一个真实的性能事故——某高频路径调用 forEachIndexed,同事觉得"就是个循环工具函数",业务上线后性能监控发现该点吃了 15% CPU:
// HotPath.kt
fun processEvents(events: List<Event>) {
var total = 0L
events.forEachIndexed { i, ev -> // 高频调用,QPS 数十万
if (ev.type == 1) total += ev.amount
}
submit(total)
}
同事把 forEachIndexed 改成手写 for 循环,CPU 降到 5%——30 倍的性能差?可是 forEachIndexed 明明是标准库的 inline 函数啊。
深入排查后发现了真相——线上代码里其实根本没用标准库的 Iterable.forEachIndexed。团队在早期封装了一个"业务通用工具类",为了加日志和埋点,自己写了一份签名一样的 forEachIndexed:
// AppUtils.kt —— 团队自造工具类
object AppUtils {
fun <T> forEachIndexed(list: List<T>, block: (Int, T) -> Unit) { // ⚠ 没加 inline
for ((i, item) in list.withIndex()) block(i, item)
}
}
// 通过顶层扩展导出 —— 调用方以为是标准库
fun <T> List<T>.forEachIndexed(block: (Int, T) -> Unit) = AppUtils.forEachIndexed(this, block)
我们的 APM 火焰图在事故复盘会上定格在这一帧:
[processEvents] 15.2% ┐
├─ [Lambda.invoke$] 9.8% ┐
├─ [Alloc Function2] 3.1% │ <── 每次循环分配 Lambda 对象
└─ [YoungGC / promote] 2.3% ┘
关键数据:QPS 30 万 × Lambda 对象 24 字节 ≈ 7 MB/s 分配 → Young GC 频次翻 5 倍 → CPU 增涨 10 pp。
# 1.2 顺藤摸到根因
表象:一个 30 行的高阶函数,加不加 inline 关键字,性能差 30 倍。
反编译两版代码,对比 Lambda 对象的分配情况:
未 inline 版本(业务的 AppUtils.forEachIndexed)字节码:
INVOKESTATIC processEvents(...)
...
NEW HotPathKt$processEvents$1 ; 每次调用都要 new
DUP
ALOAD 1 ; 捕获 total
INVOKESPECIAL HotPathKt$processEvents$1.<init>
ASTORE 2 ; Lambda 对象
ALOAD 2
INVOKESTATIC AppUtils.forEachIndexed(...)
结果:每次 processEvents 被调用,都要 new HotPathKt$processEvents$1,即使 Lambda 内容只有一行加法。
inline 版本(改成 Iterable.forEachIndexed)字节码:
INVOKESTATIC processEvents(...)
...
; 直接 for 循环展开
LDC 0
ISTORE 3 ; i = 0
; ...
; if (ev.type == 1) total += ev.amount 直接展开在这里
IADD
LSTORE 1 ; total
结果:没有 NEW 指令,没有 Lambda 类文件,直接是加法字节码。
两版差异 4 大关键点:
| 观察点 | 未 inline | inline |
|---|---|---|
| Lambda 对象分配 | 每次调用 1 次 | 0 次 |
生成 $1.class 文件 | 是 | 否 |
调用 invoke() 虚方法 | 是 | 否 |
| JIT 能否融合 | 只能各自内联 | 已经在同一方法内 |
# 1.3 我们要回答什么
看到这里,我们至少积累了 6 个疑问:
inline关键字究竟让编译器做了什么?- Lambda 在 Kotlin 里是什么?为什么不 inline 会有性能损耗?
crossinline/noinline/reified三个"配角"各自解决什么问题?- JVM 泛型擦除的历史包袱是什么?
reified如何"破解"它? inline有没有代价?什么情况下不应该**inline?value class与inline的关系是什么?零开销真的零吗?
带着这 6 个问题,进入正文。
# 2. 架构概览
# 2.1 四关键字关系
inline fun ... (crossinline block1, noinline block2, ...) { ... }
│ │ │
│ │ └─ 该 Lambda 参数不内联,作为对象保留
│ └─────────────────── Lambda 可内联但禁止非局部返回
└────────────────────────────────── 函数整体在调用点展开
inline fun <reified T> parse(...)
│
└──── 类型参数 T 在内联后可通过 T::class 反射
四个关键字的分工非常清晰——inline 是发起者,另外三个是限制/增强修饰:
| 关键字 | 作用对象 | 效果 | 触发的编译期行为 |
|---|---|---|---|
inline | 函数 | 整体在调用点原地展开 | 函数体 + 所有 Lambda 都消失 |
noinline | Lambda 参数 | 该 Lambda 保留为对象 | 生成 FunctionN 匿名类 |
crossinline | Lambda 参数 | Lambda 内联但禁止非局部返回 | 通过语法检查报错 |
reified | 类型参数 T | 类型信息保留到内联后 | 把 T 替换为具体类型(如 String.class) |
记忆口诀:inline 是"炸开函数",noinline 是"保留 Lambda",crossinline 是"内联但断链",reified 是"把 T 变具体"。
# 2.2 为什么这么切
疑惑:为什么不直接让 JVM JIT 内联,而要让 Kotlin 编译期做内联?
论证:
- JVM JIT 内联只在方法足够热时才做,冷路径没优势——冷启动、单次调用都要吃 Lambda 分配的税
- Kotlin 的高阶函数 + Lambda 是结构性的语言特性——每次调用
list.filter { }都需要一个 Lambda 对象;如果不消除,filter/map/apply/let/also/run全都成为性能陷阱 - Kotlin 编译期内联让 Lambda 完全消失——不留分配、不留调用、不留
$1.class文件 - 更重要的是——只有编译期内联,才能实现
reified(类型信息注入)和"非局部返回"——这两个能力 JVM JIT 内联做不到
深层原因:Kotlin 是"函数即一等公民"的语言,函数式风格是标准库主流用法。如果 Lambda 分配不能消除,Kotlin 会输给 Java 手写循环——语言设计上这是不能接受的。所以 inline 是 Kotlin 高阶函数能"零开销"的必要机制。
结论:inline 是 Kotlin 高阶函数的能力放大器——没有它,filter/map/apply/let 都会成为性能陷阱。这也是为什么标准库里几乎所有的高阶函数(with/apply/also/let/run/takeIf/takeUnless/repeat/synchronized/measureTimeMillis/forEach/map/filter/…)都是 inline。
# 3. inline展开机制
# 3.1 调用点即函数体
inline fun measure(block: () -> Unit): Long {
val start = System.nanoTime()
block()
return System.nanoTime() - start
}
// 调用方
val time = measure { doWork() }
编译后调用方大致等价于:
val start = System.nanoTime()
doWork() // Lambda 内容原地展开
val time = System.nanoTime() - start
关键:Lambda 体、函数体全部原地展开,两者的区别在字节码上消失了——就好像我们从来没有过 measure 函数,也从来没有过 Lambda。
多次调用的展开效果:
val t1 = measure { doA() }
val t2 = measure { doB() }
val t3 = measure { doC() }
展开后三份独立字节码——这是"代码膨胀"的根源,但也是性能的来源(每一份都可以被 JIT 独立优化,例如内联 doA/doB/doC)。
# 3.2 Lambda对象消除
Kotlin Lambda 在字节码层的本质是 kotlin.jvm.functions.FunctionN 接口的匿名实现类(Function0 到 Function22):
public interface Function1<in P1, out R> {
R invoke(P1 arg);
}
未 inline 版本的调用点字节码:
NEW SomeKt$processEvents$1 ; 分配 Lambda 对象
DUP
INVOKESPECIAL SomeKt$processEvents$1.<init>(...) ; 构造(可能捕获外层变量)
ASTORE 2 ; 存到局部变量
ALOAD 2
INVOKEVIRTUAL measure(Lkotlin/jvm/functions/Function0;) ; 调用高阶函数
inline 版本的调用点字节码:
INVOKESTATIC doWork()V ; 直接调 doWork
Lambda 对象消失——分配(占 24-48 字节)、GC 压力、虚函数调用(invoke 通过接口分派)全消失。
捕获闭包的场景更重要:
fun demo() {
var counter = 0
(1..100).forEach { counter += it } // forEach 是 inline
}
如果 forEach 不是 inline:
- 需要用
IntRef包装counter(因为要传给 Lambda 修改) - 每次调用
demo()分配Function1+IntRef - Lambda 内每次访问
counter需要counter.element间接寻址
因为 forEach 是 inline:
- 展开后
counter就是普通的局部变量 - 无对象分配、无间接寻址
- 完全等价于手写 for 循环
# 3.3 非局部返回实现
inline fun findFirst(list: List<Int>, cond: (Int) -> Boolean): Int? {
for (x in list) if (cond(x)) return x // 这里 return 是 findFirst 的
return null
}
fun demo(): Int {
findFirst(listOf(1, 2, 3)) {
if (it == 2) return -1 // ⚠ 这个 return 属于 demo,不属于 Lambda
}
return 999
}
非局部返回:Lambda 里的 return 能跳出外层普通函数——只有内联的高阶函数才允许这种写法。
原理:因为 findFirst 被 inline,展开后 demo 的字节码大致是:
fun demo(): Int {
// findFirst 展开:
for (x in listOf(1, 2, 3)) {
// Lambda 展开:
if (x == 2) return -1 // 此时 return 在 demo 函数体里 → IRETURN
}
// null 检查省略
return 999
}
字节码上就是普通的 IRETURN——能这样跳出,是因为 Lambda 内容已经被展开在外层函数体内,return 的目标自然就是外层函数。
局部返回:如果只想跳出 Lambda 本身(不跳出 demo),用带标签的 return@findFirst:
fun demo(): Int {
findFirst(listOf(1, 2, 3)) {
if (it == 2) return@findFirst true // 只从 Lambda 里返回,findFirst 继续
false
}
return 999
}
非 inline 函数的 Lambda 只允许局部返回——因为 Lambda 是独立对象,return 没有"外层函数"可以跳。
# 3.4 字节码前后对比
用 javap -p -v 对比两版:
版本 A:不加 inline
fun measure(block: () -> Unit): Long {
val start = System.nanoTime()
block()
return System.nanoTime() - start
}
fun main() {
val t = measure { println("hi") }
}
产生的 class 文件(简化):
MainKt.class—— 主类,包含measure和mainMainKt$main$1.class—— Lambda 匿名类,实现Function0<Unit>
main 的字节码:
NEW MainKt$main$1
DUP
INVOKESPECIAL MainKt$main$1.<init>()V
INVOKESTATIC MainKt.measure(Lkotlin/jvm/functions/Function0;)J
LSTORE 0
版本 B:加 inline
inline fun measure(block: () -> Unit): Long { ... }
产生的 class 文件:
MainKt.class—— 只有主类- 没有
$1.class
main 的字节码:
INVOKESTATIC java/lang/System.nanoTime()J
LSTORE 1 ; start
LDC "hi"
INVOKESTATIC kotlin.io.ConsoleKt.println(Ljava/lang/Object;)V
INVOKESTATIC java/lang/System.nanoTime()J
LLOAD 1
LSUB
LSTORE 0 ; t
观察结果:
| 项 | 未 inline | inline |
|---|---|---|
| Class 文件数 | 2 (MainKt, MainKt$main$1) | 1 (MainKt) |
main 字节码指令数 | 5 条(构造+调用) | 8 条(直接内嵌) |
| 运行期堆分配 | 1 次(Lambda 对象) | 0 次 |
| 方法调用 | invoke + measure 2 次 | println 1 次 |
结论:inline 用"字节码变多"换"运行时开销归零",属于空间换时间的编译期决策。
# 4. noinline与crossinline
# 4.1 noinline使用时机
问题:如果 inline 函数的 Lambda 参数需要保存起来(比如放进列表、赋给字段、作为返回值),Lambda 就不能被消除,必须保留为对象——这时用 noinline。
inline fun register(
inlineHandler: () -> Unit, // 会被展开
noinline delayedHandler: () -> Unit // 保存起来,稍后调用
) {
inlineHandler()
pendingList.add(delayedHandler) // 需要引用 Lambda 对象,必须 noinline
}
如果不加 noinline,编译器直接报错:
Error: Illegal usage of inline-parameter 'delayedHandler'.
Add 'noinline' modifier to the parameter declaration.
noinline 的常见场景:
| 场景 | 例子 | 为什么必须 noinline |
|---|---|---|
| 存进集合 | list.add(block) | 需要 Lambda 对象引用 |
| 传给非 inline 函数 | otherFun(block) | 非 inline 函数只接受对象 |
| 作为函数返回值 | return block | 返回值需要具体对象 |
| 存到字段 | this.callback = block | 字段存对象 |
| 装入 Map 用 key/value | map[k] = block | 需要对象 |
内联函数的效益:即使有一个参数是 noinline,其他参数依然可以内联——不是"全或无",而是参数粒度的控制。
# 4.2 crossinline限制
问题:如果 Lambda 参数会被传递到另一个非内联位置调用(比如内部类的方法、Runnable 的 run),我们仍然可以 inline 它——但必须禁止非局部返回(因为返回目标已丢失)。
inline fun runOnUiThread(crossinline block: () -> Unit) {
handler.post { // Runnable 的 lambda(object 表达式)
block() // block 在 Runnable.run() 里被调用
}
}
runOnUiThread {
// return // ⚠ 编译报错,crossinline 禁止非局部返回
doSomething()
}
为什么禁止? handler.post 接受 Runnable——它会把 Runnable 对象存起来,稍后在另一个线程或时机调用 .run()。当 run() 触发时:
- 外层调用
runOnUiThread的函数可能早已返回 - Lambda 里的
return要跳的目标已经不在栈上 - 强行跳会导致跨栈帧非法跳转——JVM 层是不允许的
所以 crossinline 的语义是:"Lambda 内容可以内联展开到 Runnable 里,但不允许它含有 return"。这样即使 Lambda 稍后被异步调用,也不会有"跳无可跳"的问题。
crossinline 展开示意:
// 上面的 runOnUiThread 展开等价于:
handler.post(object : Runnable {
override fun run() {
doSomething() // Lambda 展开在 Runnable 内
}
})
Lambda 展开进 Runnable.run()——虽然还是生成了一个 Runnable 匿名类,但 Lambda 内容不再需要额外分配 Function0 对象。
# 4.3 非局部返回禁止
crossinline 只让 Lambda 内联但禁止 return;如果需要早退可用 return@runOnUiThread(局部返回,只从 Lambda 里返回,不跳出外层函数)。
fun demo() {
runOnUiThread {
if (!enabled) return@runOnUiThread // ✅ 局部返回,OK
// return // ❌ 编译报错
doWork()
}
afterCall() // demo 依然会执行这一行
}
记忆:crossinline 三个字合起来就是"跨界内联"——Lambda 可能跨到别的执行时机(异步/回调)执行,所以要断掉"回外层"的连接。
# 4.4 三者组合矩阵
| 修饰 | Lambda 被内联 | 允许非局部返回 | 可保存为对象 | 用途 |
|---|---|---|---|---|
| 默认 (inline 函数的 Lambda) | ✅ | ✅ | ❌ | 通用场景,最强能力 |
crossinline | ✅ | ❌ | ❌ | Lambda 会异步/回调执行 |
noinline | ❌ | ❌ | ✅ | Lambda 需要长期持有 |
一个函数可以混用:
inline fun schedule(
onNow: () -> Unit, // 默认,全能
crossinline onLater: () -> Unit, // 异步执行
noinline onError: (Throwable) -> Unit // 保存到 map 里备用
) {
try {
onNow() // 内联展开
thread { onLater() } // 交给别的线程
errorMap[Thread.currentThread()] = onError // 保存
} catch (e: Throwable) { onError(e) }
}
这个函数同时具备三种 Lambda 修饰能力——按每个参数的实际用途选择即可。
# 5. reified破解擦除
# 5.1 JVM泛型擦除
Java/Kotlin 在 JVM 上都受"类型擦除"限制:
List<String> l = ...;
if (l instanceof List<Integer>) { } // ⚠ 编译报错,无法运行时区分
字节码里 List<String> 和 List<Integer> 都是 Ljava/util/List;——泛型信息在编译期被"擦除",只保留在方法/字段的签名 attribute 里,运行期不可访问。
擦除的历史原因:Java 5 (2004) 引入泛型时为二进制兼容旧字节码——如果每个泛型实例化都生成新类(像 C++ 模板或 C# CLR),JDK 4 的 class 文件就不能与 JDK 5 的代码互通。这是"务实的技术债"。
擦除带来的痛点:
fun <T> loadJson(json: String, type: Class<T>): T { ... }
val u = loadJson(text, User::class.java) // 必须手动传 User::class.java
每次调用都要显式传类型——因为运行时拿不到 T。Java 用 Class<T> 参数,Kotlin 之前也这么做——直到 reified 出现。
# 5.2 reified替换机理
Kotlin 的 reified 结合 inline 让泛型"复活":
inline fun <reified T> Any.isType(): Boolean = this is T
val ok = "hi".isType<String>() // 编译时展开为 "hi" is String
核心机制:函数被 inline 时,编译器把 T 替换为具体类型(在这里是 String),因此 is T 变成 is String——可正常编译。
展开的字节码等价物:
val ok = "hi" is String // 编译期已知类型,直接生成 INSTANCEOF 指令
字节码:
LDC "hi"
INSTANCEOF java/lang/String
ISTORE 1
没有 Class<T> 参数、没有反射、没有擦除困扰——因为 T 在字节码层就是 String。
reified 能做什么:
| 操作 | 例子 | 展开后 |
|---|---|---|
is T | x is T | x is String |
as T / as? T | x as? T | x as? String |
T::class | T::class | String::class |
T::class.java | T::class.java | String::class.java |
arrayOf<T> | arrayOf<T>(...) | arrayOf<String>(...) |
都是编译期的字面替换——没有任何运行期开销。
# 5.3 字节码类型嵌入
最常见的应用——JSON 反序列化:
inline fun <reified T> loadJson(json: String): T = Gson().fromJson(json, T::class.java)
val u: User = loadJson(text)
编译后调用点:
NEW com/google/gson/Gson
DUP
INVOKESPECIAL com/google/gson/Gson.<init>()V
ALOAD text
LDC LUser; ; ← T::class.java 被替换为 User.class
INVOKEVIRTUAL Gson.fromJson(Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;
CHECKCAST User
ASTORE u
关键:LDC LUser; 是字面量 Class 引用,编译期就已确定——无反射、无 KClass 装箱、无擦除困扰。
类似的库友好化用法:
// SharedPreferences 取值
inline fun <reified T> SharedPreferences.get(key: String, default: T): T = when (default) {
is String -> getString(key, default) as T
is Int -> getInt(key, default) as T
is Boolean -> getBoolean(key, default) as T
else -> throw IllegalArgumentException("Unsupported ${T::class}")
}
// Intent 启动
inline fun <reified T : Activity> Context.start() = startActivity(Intent(this, T::class.java))
this.start<MainActivity>() // 展开为 startActivity(Intent(this, MainActivity::class.java))
结论:reified 让 T::class / is T / as T 都成为可能——因为它们最终被替换成具体类。
# 5.4 无inline不reified
规则:reified 必须与 inline 一起用,否则类型参数无从替换。
fun <reified T> bad() { } // ❌ 编译报错
// error: only type parameters of inline functions can be reified
inline fun <reified T> good() { } // ✅
深层原因:reified 的实现依赖"函数在调用点被展开"——只有展开时,编译器才知道 T 应该被替换成什么具体类型。非 inline 函数只编译一次,T 无法预知,只能保留擦除后的 Object。
限制的具体表现:
| 场景 | 能否 reified |
|---|---|
inline fun <reified T> | ✅ |
普通函数 fun <reified T> | ❌ 编译错 |
类的类型参数 class Foo<reified T> | ❌ 编译错 |
内联属性访问器 inline val <reified T> | ✅ 特殊场景 |
核心口诀:内联到哪儿,T 就能具化到哪儿。
# 6. 内联的代价
# 6.1 代码膨胀实测
一个 5 行的 inline 函数被调用 100 次 → 生成的字节码相当于 500 行(100 份独立展开)。
实测数据(一个真实的 Android 中型库):
| 项 | 关闭 inline | 全开 inline | 增幅 |
|---|---|---|---|
| classes.dex 大小 | 210 KB | 356 KB | +70% |
| 方法数(method_ids) | 6820 | 6120 | -10%(Lambda 类消失) |
| APK 未压缩大小 | 1.2 MB | 1.35 MB | +12% |
| APK 压缩后 | 340 KB | 380 KB | +12% |
| 冷启动耗时 | 480 ms | 465 ms | -3%(Lambda 分配少) |
| 稳态 QPS | 12k | 13.5k | +12% |
观察:dex 变大不等于 APK 变大——因为字节码里重复的 pattern 被 zip 压缩掉不少。而方法数反而减少了(Lambda 匿名类不再生成)。
# 6.2 JIT优化影响
副作用:函数体过大时,JIT C2 编译器可能因为方法字节码 > 8000 字节(默认 -XX:MaxInlineSize 相关阈值)而拒绝二次内联。
场景:
inline fun bigWork(block: () -> Unit) {
// 60 行复杂逻辑
block()
// 40 行处理
}
fun caller() {
bigWork { doA() }
bigWork { doB() }
// ... 20 处调用
}
caller的字节码 = 20 × 100 行 = 2000 行- 加上其他方法,可能超过 C2 阈值
- 结果:JIT 放弃编译
caller,走解释器 → 反而变慢
建议:inline 函数体 ≤ 20-30 行为宜;超过后要考虑拆成小 inline + 大 non-inline 组合。
# 6.3 二进制大小坑
Android APK 有 dex 方法数上限(64K methods per dex,突破需 MultiDex)——过度 inline 会让方法内代码量虚假膨胀,虽然不直接增加 method_ids,但会:
- 增大 APK 体积(用户下载慢)
- 增大安装占用(存储紧张的低端机)
- 增大 dex opt 时间(首次启动 ART 编译)
Google Play 建议:APK 每增大 6 MB,安装成功率下降 1%。inline 膨胀对下沉市场(存储不足 100 MB 的设备)尤其敏感。
# 6.4 public_inline_ABI风险
严重问题:如果一个 public inline fun 是库的 API,函数体变化会破坏二进制兼容——因为老调用方已经把函数体展开到自己的字节码里。
场景:
// v1.0:MyLib.jar
public inline fun measure(block: () -> Unit): Long {
val s = System.nanoTime()
block()
return System.nanoTime() - s
}
App 调用后编译产物里,measure 的函数体已经复制到 App 的字节码里。
// v2.0:MyLib.jar(改了实现)
public inline fun measure(block: () -> Unit): Long {
val s = System.currentTimeMillis() // ⚠ 改为毫秒
block()
return (System.currentTimeMillis() - s) * 1_000_000 // 转纳秒
}
App 升级 MyLib.jar 到 v2.0 后:
- 不重新编译的话,App 里还是 v1.0 的展开体——精度还是纳秒
- 重新编译的部分是 v2.0 的展开体——精度是毫秒
混用后行为不一致,测试很难发现。
Kotlin 的对策:@PublishedApi 注解让 private/internal 的辅助 API 能被 public inline 引用(编译器松开可见性检查,但要求作者对 ABI 稳定性负责)。
@PublishedApi
internal fun helper() { ... }
inline fun publicApi() {
helper() // 允许调用 internal 函数,但作者要保证 helper 签名稳定
}
结论:库作者对 public inline 的函数体变更要极其谨慎——最佳实践是"只加不改、只加不删"。
# 7. value类零开销
# 7.1 value类历史演进
value class(原 inline class)是"编译期做类型包装"的思想的延伸——把 inline 从"函数展开"扩展到"类型擦除"。
- Kotlin 1.3(2018-10):实验性
inline class——第一次尝试 - Kotlin 1.5(2021-05):稳定为
@JvmInline value class——引入@JvmInline明确 JVM 语义 - Kotlin 1.9+:
value class关键字保留,为未来的 Valhalla 值类型做接口对齐 - Kotlin 2.0+:与 Java Valhalla(JEP 401)方向对齐,未来可能享受 JVM 层零开销
# 7.2 字节码擦除表现
@JvmInline
value class UserId(val value: Long)
fun greet(uid: UserId) { println(uid.value) }
字节码(javap -p):
public static final void greet-XX(long) ; 参数是 long,不是 UserId!
关键:greet 的参数在字节码层是 long,不是 UserId 对象。UserId 这个包装类在运行时不存在——真·零开销包装。
函数名后缀 -XX(例如 -vHUOwGA) 是 Kotlin 编译器加的**"混淆位"**——防止与"参数确实是 long 的普通函数"发生签名冲突(Java 端只看到 long 参数):
fun greet(uid: UserId) { } // 编译为 greet-XX(long)
fun greet(uid: Long) { } // 编译为 greet(long)
两者在 Kotlin 层能重载,在 Java 层因为后缀而分离——这也是 value class 与 Java 互操作要注意的点(Java 端调用要拼后缀,很不友好)。
# 7.3 装箱触发条件
以下情况仍会装箱为普通对象(runtime 生成 UserId 实例):
| 场景 | 例子 | 是否装箱 |
|---|---|---|
| 直接传参 | greet(uid) | ❌ 不装箱(就是 long) |
| 用作泛型参数 | List<UserId> | ✅ 装箱 |
| 用作可空类型 | UserId? | ✅ 装箱(null 用对象引用表示) |
| 用作接口引用 | val x: Any = uid | ✅ 装箱 |
| 数组元素 | Array<UserId> | ✅ 装箱(用 Java Object[]) |
| 数组特化 | LongArray 包 UserId | ❌ 不装箱(需要手动特化) |
| Java 端接收 | Java 代码调用 | ✅ 装箱 |
装箱代码(box-impl):
public static final UserId box-impl(long v) ; 由编译器生成
装箱触发的场景需要生成额外的适配代码,但通常调用点(Kotlin 内部、非泛型、非可空)都是零开销。
# 7.4 与JEP401对比
Java Valhalla JEP 401(value types)会做类似事情,但在 JVM 层,比 Kotlin 编译期擦除更彻底:
| 项 | Kotlin value class | Java JEP 401 |
|---|---|---|
| 实现层次 | 编译期擦除 | JVM 层原生值类型 |
| 装箱条件 | 泛型/可空/接口 | 基本不装箱(JVM 处理) |
| 数组表现 | Object[] | 值类型专用数组 (flat memory) |
| 与旧代码互通 | 需要函数后缀 | JVM 原生兼容 |
| 内存局部性 | 装箱后差 | 平坦内存布局,非常好 |
未来展望:JEP 401 落地后,Kotlin value class 可以平滑升级到 JVM 原生值类型——所有现存代码不需要改动,享受更好性能。这是语言设计的前瞻性。
# 8. 常见反例分析
# 8.1 过度内联膨胀
反例:某电商 SDK 曾把 100 多个工具函数全部 inline:
inline fun <T> T?.orDefault(default: T): T = this ?: default
inline fun String?.orEmpty(): String = this ?: ""
inline fun <T> T.log(tag: String): T = also { Log.d(tag, "$it") }
// ... 100+ 个
结果:
- 主 module dex 大小从 800 KB 涨到 1.4 MB
- APK 增大 40%
- 冷启动没变快,反而慢了(dex opt 变慢)
治理方案:
| 函数特征 | 是否保留 inline |
|---|---|
| 接受 Lambda 参数 | ✅ 保留 |
| 需要 reified | ✅ 保留 |
一行的 ?: 类工具 | ❌ 移除 inline(性能收益微乎其微) |
| 大函数 | ❌ 移除 inline |
| Public API 且逻辑复杂 | ❌ 移除 inline(避免 ABI 风险) |
治理后 APK 缩回 900 KB,性能无损。
# 8.2 大函数不宜inline
规则:函数体 > 30 行时,谨慎 inline——JIT 可能不做二次内联,反而更慢。
反例:
inline fun processRequest(req: Request, block: (Response) -> Unit) {
// ... 50 行认证、验签、日志、metrics 逻辑
val resp = execute(req)
// ... 30 行处理、缓存、上报
block(resp)
// ... 20 行 cleanup
}
100 行的 inline 函数在 20 个调用点展开 = 2000 行字节码 → JIT C2 拒绝优化整个调用方 → 走解释器。
修复:把可 inline 的部分拆小:
// 只保留 Lambda 接收部分 inline
inline fun processRequest(req: Request, block: (Response) -> Unit) {
val resp = executeInternal(req) // ← 非 inline
block(resp)
cleanupInternal(resp) // ← 非 inline
}
private fun executeInternal(req: Request): Response { /* 50 行 */ }
private fun cleanupInternal(resp: Response) { /* 20 行 */ }
这样 processRequest 展开只有 3 行——Lambda 消除的收益依然拿到,且 JIT 可以正常优化。
# 8.3 public_inline稳定性
真实事故:某开源 JSON 库改了 public inline fun 内部实现(换了内部 helper 类的包名),老版本调用方运行时报 NoClassDefFoundError——JVM 找不到被引用的内部类。
// v1
package com.foo
internal class Helper { ... }
public inline fun parse(json: String): Any {
return Helper().doParse(json) // 展开后调用方引用 com.foo.Helper
}
// v2(重构后)
package com.foo.internal // ⚠ 改包了
internal class Helper { ... }
public inline fun parse(json: String): Any {
return Helper().doParse(json) // 展开后调用方要引用 com.foo.internal.Helper
}
App 使用 v1 编译后不重新编译,仅升级 jar 到 v2:
- 调用点已经展开为
NEW com/foo/Helper - v2 jar 里只有
com/foo/internal/Helper - 运行时报
NoClassDefFoundError: com/foo/Helper
教训:public inline 的函数体是 API 的一部分——包名、内部类名、辅助函数名任何变动都会破坏兼容性。加 @PublishedApi 才允许引用 internal 元素,但作者要为稳定性负责。
# 8.4 循环里的Lambda
反例:
for (item in items) {
listOf(item).forEach { doWork(it) } // ⚠ 每次循环创建 listOf;forEach 是 inline OK
}
分辨"Lambda 分配"vs"集合分配"两种性能坑:
| 分配源 | 是否消除 | 每次循环开销 |
|---|---|---|
forEach 的 Lambda | ✅ inline 消除 | 0 |
listOf(item) 返回的 List | ❌ 每次循环都会分配 | 一次 List 对象 |
修复:
for (item in items) {
doWork(item) // 直接调用,压根不需要 listOf
}
另一种坑——集合 API 链式调用:
items
.filter { it.enabled } // ← 分配中间 List
.map { it.value } // ← 又分配中间 List
.forEach { process(it) }
filter 和 map 虽然是 inline,Lambda 被消除——但返回的中间 List 是真实对象。改用 Sequence 或 asSequence() 惰性求值:
items.asSequence()
.filter { it.enabled }
.map { it.value }
.forEach { process(it) } // 中间 List 全消失,只一次流式遍历
结论:inline 消除的是 Lambda 对象,不是中间集合对象——两者要分开看。
# 9. 与其他语言对比
# 9.1 Cpp的inline关键字
C++ inline 是"允许 ODR 违反"提示 + "建议内联"提示——编译器有最终决定权。
inline int add(int a, int b) { return a + b; }
- ODR(One Definition Rule):跨编译单元允许多次定义
- 内联决策:由编译器根据
-O2、函数大小、调用频率决定 - 现代 C++ 中
inline主要用途已不是内联提示,而是允许 header 里定义函数
与 Kotlin 对比:
| 项 | C++ inline | Kotlin inline |
|---|---|---|
| 是否强制内联 | 建议 | 强制 |
| 主要目的 | ODR 松弛 | 消除 Lambda 分配 |
| 决策方 | 编译器 | 语言规定 |
# 9.2 Rust单态化机制
Rust 泛型走单态化(monomorphization)——每个具体类型生成一份代码,本质与 reified 类似,但覆盖所有泛型(不需要额外标记):
fn add<T: std::ops::Add<Output=T>>(a: T, b: T) -> T { a + b }
let x = add(1i32, 2i32); // 编译期生成 add_i32
let y = add(1.0f64, 2.0f64); // 编译期生成 add_f64
编译后是两份完全独立的函数——运行期零开销、可 inline、无擦除。
代价:编译慢、二进制大(每个类型都有一份)。
# 9.3 CSharp具化泛型
C# 泛型在 CLR 层保留类型信息(reified generics)——无需额外关键字,运行时可以 typeof(T):
public T Parse<T>(string json) where T : new() {
Type t = typeof(T); // 运行期直接拿到类型
return JsonConvert.DeserializeObject<T>(json);
}
- 引用类型:所有实例共用一份代码(类型信息在运行时)
- 值类型:每个具体类型生成一份专用代码(类似 Rust 单态化)
与 Kotlin reified 对比:
| 项 | C# 泛型 | Kotlin reified |
|---|---|---|
| 生效范围 | 所有泛型 | 只有 inline fun |
| 实现层次 | CLR 运行时 | Kotlin 编译期 |
| 运行时开销 | 有(类型元数据查询) | 无(已展开为具体类) |
| 需要额外关键字 | ❌ | ✅ reified |
# 9.4 三者取舍
| 语言 | 泛型策略 | 优点 | 缺点 |
|---|---|---|---|
| Java/Kotlin | 擦除 | 兼容旧字节码 | 无法 is T |
Kotlin reified | 编译期展开 | 局部具化 | 只在 inline 内 |
| Rust | 单态化 | 完全具化 | 编译慢、二进制大 |
| C# | CLR 保留 | 完全具化 + 运行时反射 | 运行时开销 |
| C++ 模板 | 单态化 | 极强表达力 | 编译错误信息晦涩 |
Kotlin 的定位:局部具化 + 语言层解决——不推翻 JVM 擦除,而是在编译期通过 inline 展开做局部注入,是"务实的折中"。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到 1.1 节的 6 个疑问:
inline让编译器把函数体 + Lambda 都在调用点原地展开——Lambda 对象消失,等价于手写循环。- Kotlin Lambda 是
FunctionN接口的匿名实现——非 inline 时每次调用要分配对象(约 24-48 字节 + 捕获闭包字段)。 crossinline禁止非局部返回(因为 Lambda 会异步执行),noinline保留 Lambda 对象(因为要长期持有),reified保留类型信息——三者互补。- JVM 泛型擦除源于兼容性(Java 5 引入泛型时要兼容旧字节码)——
reified通过 inline 展开时把T替换为具体类型,从而绕过擦除。 inline的代价:代码膨胀、二进制大小、JIT 二次内联失败、public API 稳定性、@PublishedApi责任。value class借助 inline 思想做类型包装的零开销——但泛型/接口/可空场景仍会装箱。
案例根因:那位同事封装的 forEachIndexed 没有加 inline——每次调用都要分配一个 Function2 Lambda 对象,QPS 数十万时 GC 压力巨大,Young GC 频次翻 5 倍。
修复:
// AppUtils.kt
inline fun <T> forEachIndexed(list: List<T>, block: (Int, T) -> Unit) { // ✅ 加 inline
for ((i, item) in list.withIndex()) block(i, item)
}
或直接删除自定义版本,改用标准库的 Iterable.forEachIndexed——性能立刻回归。
# 10.2 一个inline_reified一生
一个 inline fun <reified T> parse 从声明到消失的完整时序图:
写下 inline fun <reified T> parse(json: String): T = Gson().fromJson(json, T::class.java)
↓
kotlinc 前端 (Parse/Resolve)
├─ 检查 T 是否与 reified 兼容
├─ 检查函数是否 inline
└─ 生成 IR(中间表示)
↓
kotlinc 编译时:函数体保存在 metadata 中(@kotlin.Metadata annotation)
├─ 生成 parse 的普通字节码(用于 Java 端)
└─ 保存 inline 版本到 metadata(用于 Kotlin 端展开)
↓
调用点 val u: User = parse(text)
↓
kotlinc 展开(Inliner):
├─ 从 metadata 读出 parse 函数体
├─ 把类型参数 T 替换为 User
├─ 把 T::class.java 替换为 User::class.java
└─ 把展开后的字节码插入调用方
↓
最终字节码:Gson().fromJson(text, User.class)
↓
运行期:直接反射调用 Gson,无泛型擦除困扰
↓
JIT (C2) 热点后:
├─ 内联 fromJson
├─ 逃逸分析可能栈上分配临时对象
└─ 生成最优机器码
关键:一次编译,两处使用——普通字节码给 Java 端调用,metadata 给 Kotlin 编译器展开。
# 10.3 设计哲学回扣
- 零开销抽象(Zero-cost Abstraction):编译期能做的事,运行期就不用做——Lambda 分配、类型信息、泛型擦除全在编译期解决
- 能力叠加而非替代:
inline + reified + crossinline + noinline组合表达能力——每个关键字解决一个具体问题,正交组合 - 代价前置:内联膨胀是编译期代价(class 文件变大、编译变慢),换来运行期收益(无分配、无虚函数)——代价可控
- 不与 JVM 硬碰:擦除无法改,就在语言层做局部绕过——
reified只在 inline 内生效,绕开而非推翻 - 前瞻性设计:
value class为未来的 JVM Valhalla 值类型做接口对齐,用户代码不需要改动就能享受未来红利
# 10.4 内联速查表
| 场景 | 是否 inline |
|---|---|
| 接受 Lambda 参数的高阶函数 | ✅ inline |
需要 reified T | ✅ 必须 inline |
| 需要"非局部返回" | ✅ 必须 inline(默认参数 Lambda) |
| 需要保存 Lambda 稍后调用 | inline + noinline |
| Lambda 会在异步/回调里执行 | inline + crossinline |
| 不接 Lambda 的普通函数 | ❌ 不 inline(无收益) |
| 大函数(>30 行) | ❌ 不 inline(膨胀) |
| public API 且函数体可能演进 | ❌ 谨慎 inline(ABI 风险) |
| 递归函数 | ❌ 不能 inline(编译报错) |
简单 ?: 类工具 | ❌ 不 inline(收益微,膨胀大) |
value class 装箱触发速查表:
| 场景 | 装箱 |
|---|---|
直接调用 fun(uid: UserId) | ❌ |
List<UserId> | ✅ |
UserId? | ✅ |
val x: Any = uid | ✅ |
| Java 端调用 | ✅ |
| 反射调用 | ✅ |
关键字组合三步选择法:
- 高阶函数?→ 加
inline - Lambda 会被保存?→ 该参数
noinline - Lambda 会异步执行?→ 该参数
crossinline - 需要类型信息?→ 类型参数
reified
下一篇预告:05.委托与属性原理 —— 我们看看 by lazy 到底生成了几个类,observable 属性的通知机制如何用组合而不是继承实现。