第 6 章 基础与类型系统:一切皆对象,但对象有代价
本章目标
- 搞清 Kotlin 类型系统与 JVM 类型的精确映射规则 ------什么时候是
int,什么时候是Integer;- 掌握装箱的全部 6 个触发场景,并能用 JOL 量出真实内存代价;
- 理解
IntArray与List<Int>在内存布局上的本质差异;- 吃透
Nothing/Unit/Any三个特殊类型、lateinitvslazy、智能转换的精确触发条件;- 建立"类型系统的每个便利都有物理代价"的思维习惯。
6.0 一个必须先看到的数字
kotlin
val n = 1_000_000
val primitive: IntArray = IntArray(n) { it } // 原生数组
val boxed: List<Int> = (0 until n).toList() // 装箱列表
🧪 实验 6-1:用 JOL 量出真实占用
kotlin
import org.openjdk.jol.info.GraphLayout
fun main() {
val n = 1_000_000
val primitive = IntArray(n) { it }
val boxed: List<Int> = (0 until n).toList()
println("IntArray : ${GraphLayout.parseInstance(primitive).totalSize()} bytes")
println("List<Int> : ${GraphLayout.parseInstance(boxed).totalSize()} bytes")
println("单个 Integer : ${GraphLayout.parseInstance(java.lang.Integer.valueOf(999_999)).totalSize()} bytes")
println("单个 Int : (原生类型,不占堆对象)")
}
JDK 27 实测输出(紧凑对象头默认开启):
text
IntArray : 4000016 bytes ≈ 3.81 MB
List<Int> : 20000080 bytes ≈ 19.07 MB
单个 Integer : 16 bytes
5 倍差距。 而且这不只是内存问题:
kotlin
@Benchmark fun sumPrimitive() = primitive.sumOf { it.toLong() } // ~0.35 ms
@Benchmark fun sumBoxed() = boxed.sumOf { it.toLong() } // ~2.8 ms
8 倍性能差距。 原因是三重的:内存占用 → 缓存局部性;引用解引用 → 额外间接访问;int[] 可被 C2 自动向量化(SIMD),Integer[] 不行。
🔬 本章的全部内容,都是为了让你能在写代码时预判这个数字。
6.1 Kotlin 类型 → JVM 类型:精确映射规则
Kotlin 官方文档说"Kotlin 中一切皆对象"。这句话在语义层面成立,在字节码层面是错的。
真实的映射规则有四条,按优先级:
规则 1:非空基本类型,在"原生位置"→ JVM 原生类型
| Kotlin | JVM 原生 | 包装类 |
|---|---|---|
Int |
int |
java.lang.Integer |
Long |
long |
java.lang.Long |
Short |
short |
java.lang.Short |
Byte |
byte |
java.lang.Byte |
Float |
float |
java.lang.Float |
Double |
double |
java.lang.Double |
Char |
char |
java.lang.Character |
Boolean |
boolean |
java.lang.Boolean |
Unit |
void |
kotlin.Unit(单例对象) |
Nothing |
(方法不返回) | kotlin.Nothing |
Any |
java.lang.Object |
--- |
Any? |
java.lang.Object |
--- |
String |
java.lang.String |
--- |
"原生位置"指:字段类型、方法参数类型、方法返回类型、局部变量类型。
kotlin
class Foo(val count: Int) { fun bar(x: Int): Int = x }
text
private final I count
public final getCount()I
public final bar(I)I
规则 2:可空的基本类型 → 包装类
kotlin
class Foo(val count: Int?) { fun bar(x: Int?): Int? = x }
text
private final Ljava/lang/Integer; count ← 装箱!
public final getCount()Ljava/lang/Integer;
public final bar(Ljava/lang/Integer;)Ljava/lang/Integer;
🔬 这是 Kotlin 类型系统设计的必然结果 :int 无法表示 null,所以 Int? 只能用 Integer。
规则 3:泛型类型参数位置 → 包装类(因为擦除)
kotlin
val list: List<Int> = listOf(1, 2, 3) // java.util.List<java.lang.Integer>
val pair: Pair<Int, Int> = 1 to 2 // 两个 Integer 字段
fun <T> identity(x: T): T = x // T 的实例化总是装箱
🧪 实验 6-2:验证泛型位置装箱
kotlin
class Holder<T>(val v: T)
fun main() {
val h = Holder(42) // Holder<Int>
println(GraphLayout.parseInstance(h).totalSize()) // 32 bytes:Holder(16) + Integer(16)
}
📌 Pair<Int, Int> 的代价 :Pair 有两个 Any? 字段,所以是 8(header) + 4 + 4 = 16 字节,加上两个 Integer 各 16 字节 = 48 字节 。而 Java 里用两个 int 局部变量只要 8 字节栈空间。
🚀 这就是为什么在数值密集代码里不要用 Pair<Int,Int> 做返回值 ------用 Long 打包((a.toLong() shl 32) or b.toLong())、用一个 data class 的原生字段版本、或返回 IntArray(2)。
规则 4:Array<T> vs 原生数组
| Kotlin | JVM | 内存布局 |
|---|---|---|
IntArray |
int[] |
连续 4 字节/元素 |
LongArray |
long[] |
连续 8 字节/元素 |
ByteArray |
byte[] |
连续 1 字节/元素 |
CharArray |
char[] |
连续 2 字节/元素 |
BooleanArray |
boolean[] |
连续 1 字节/元素(不是 bit!) |
DoubleArray/FloatArray/ShortArray |
对应原生数组 | |
Array<Int> |
Integer[] |
引用数组 + 每元素一个 Integer 对象 |
Array<String> |
String[] |
引用数组 |
Array<IntArray> |
int[][] |
✅ 原生嵌套 |
⚠️ Array<Int> 是 Integer[],不是 int[]。这是新手最容易写错的地方:
kotlin
val a = Array(10) { 0 } // Integer[],装箱
val b = IntArray(10) // int[],无装箱
val c = IntArray(10) { it * 2 } // int[],带初始化 lambda(会被内联,无 Function 对象)
// 转换
val d: Array<Int> = b.toTypedArray() // int[] → Integer[],装箱 10 次
val e: IntArray = a.toIntArray() // Integer[] → int[],拆箱 10 次
🔬 数组内存布局(JDK 27,紧凑对象头):
css
int[1_000_000]:
┌────────┬────────┬──────────────────────────────┐
│ 头 8B │ len 4B │ 4,000,000 B 数据(连续) │ = 4,000,012 → 对齐到 4,000,016
└────────┴────────┴──────────────────────────────┘
Integer[1_000_000](压缩指针开启,堆 < 32GB):
┌────────┬────────┬──────────────────────────────┐ ┌────────┬───────┐
│ 头 8B │ len 4B │ 4,000,000 B 引用(连续) │ ───► │ 头 8B │ int 4B│ ×1,000,000
└────────┴────────┴──────────────────────────────┘ └────────┴───────┘
= 4,000,016 + 16,000,000 = 20,000,016
📌 一个精确的观察 :Integer 对象在开启和关闭紧凑对象头时都是 16 字节 (传统:12 头 + 4 字段 = 16,已对齐;紧凑:8 头 + 4 字段 = 12 → 对齐到 16)。紧凑对象头对只有一个小字段的对象没有收益。
真正受益的是字段总大小 ≥ 8 字节的对象:
| 对象 | 传统头(12B) | 紧凑头(8B) | 节省 |
|---|---|---|---|
Point(val x: Int, val y: Int) |
12+8=20 → 24 | 8+8=16 | -33% |
data class User(val id: Long, val age: Int) |
12+8+4=24 → 24 | 8+8+4=20 → 24 | 0%(对齐巧合) |
class Node(a,b,c,d: Any?) |
12+16=28 → 32 | 8+16=24 | -25% |
Integer |
16 | 16 | 0% |
int[1000] |
12+4+4000 → 4024 | 8+4+4000 → 4016 | -0.2% |
👉 所以"紧凑对象头省 10--20% 堆"的说法只对"小对象密集且字段较多"的应用成立。第 27 章会给完整的测量方法。
6.2 装箱的六个触发场景(完整清单)
背下这六条,你就能预判大部分装箱开销:
① 可空基本类型
kotlin
val a: Int? = null // Integer
var counter: Long? = 0 // Long
② 泛型类型参数
kotlin
List<Int>, Map<String, Int>, Pair<Int, Int>, Optional<Int>, Flow<Int>, StateFlow<Int>
class Box<T>(val v: T)
fun <T> f(x: T): T
⚠️ 协程里特别常见 :suspend fun count(): Int 的返回类型在字节码里是 Object(因为可能返回 COROUTINE_SUSPENDED),所以每次调用都会装箱一次 Int!
kotlin
suspend fun count(): Int = 42
// 字节码签名:count(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
// 返回 Integer.valueOf(42)
🚀 这是第 37 章会详细讨论的"协程装箱税"。缓解手段:批量化(返回集合而非单个值)、用 Long 打包多个小整数、避免在超高频热路径上用 suspend 返回基本类型。
③ 集合
kotlin
listOf(1, 2, 3) // List<Integer>
mutableMapOf("a" to 1) // Map<String, Integer>
list.sum(), list.max(), list.average() // 内部大量装箱
④ 赋给 Any / Any?
kotlin
val x: Any = 42 // Integer
println(42) // println(Any?) → 装箱!
⚠️ println 是最隐蔽的装箱源 。Kotlin 的 println 有一组重载(Int、Long、Double、Boolean、Char、CharArray、Any?),编译器会选最具体的:
kotlin
val i: Int = 42
println(i) // 选中 println(I)V?
🧪 实验 6-3:println 到底选哪个重载
bash
cat > P.kt <<'EOF'
fun main() {
val i: Int = 42
val d: Double = 1.5
val s = "x"
println(i)
println(d)
println(s)
println("$i")
}
EOF
kotlinc P.kt -d outp && javap -c -p outp/PKt.class | grep println
text
invokevirtual #20 // Method java/io/PrintStream.println:(I)V ← 无装箱 ✅
invokevirtual #24 // Method java/io/PrintStream.println:(D)V ← 无装箱 ✅
invokevirtual #27 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
invokevirtual #30 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V ← 装箱!
📌 kotlin.io.println 有针对基本类型的重载,直接委托给 PrintStream.println(int) 等,不装箱。 但一旦你用字符串模板或 Any?,就会装箱。
但注意 :第 4 章那个 measured 例子里出现了 Double.valueOf 装箱------那是因为 measured 的泛型返回类型 R 被实例化为 Double,走了规则 ②,与 println 无关。
⑤ Lambda 捕获基本类型变量
kotlin
var sum = 0
val f = { sum += 1 } // sum 被捕获 → 装箱成 Ref.IntRef 对象!
🔬 Kotlin 的解决方案是 Ref 包装类 (kotlin.jvm.internal.Ref$IntRef):
text
// 字节码等价
val sumRef = Ref.IntRef() // 分配一个持有 int 字段的对象
sumRef.element = 0
val f = { sumRef.element += 1 }
⚠️ 这意味着在 lambda 里修改外部的 var 基本类型变量会分配一个对象 。而且 Ref.IntRef 是非线程安全的裸字段。
🚀 规避 :如果 lambda 会被 inline 展开(如 forEach、let),则不需要 Ref 包装------变量直接在调用点的栈帧里:
kotlin
var sum = 0
list.forEach { sum += it } // forEach 是 inline → 无 Ref 分配 ✅
val g = { sum += 1 } // 非 inline 的 lambda 字面量 → Ref.IntRef ❌
这是 inline 最被低估的收益之一。
⑥ 委托属性、Any 参数的 API、反射
kotlin
val x by lazy { 42 } // Lazy<Int> 内部存 Any?,装箱
method.invoke(obj, 42) // vararg Any? → 装箱
mapOf("k" to 1) // Pair<String, Int>,装箱
6.3 数字:Kotlin 没有隐式转换
这是从 Java 转来最容易撞的墙:
kotlin
val i: Int = 42
val l: Long = i // ❌ 编译错误:Type mismatch
val l2: Long = i.toLong() // ✅
Java 里 long l = i; 是合法的(隐式加宽),Kotlin 禁止。
设计理由:隐式转换是精度丢失与性能问题的温床。经典 Java bug:
java
long ms = intSeconds * 1000; // int 溢出!结果错误后再加宽
Kotlin 强迫你写:
kotlin
val ms: Long = seconds.toLong() * 1000 // 显式,无溢出
完整转换方法
kotlin
toByte() toShort() toInt() toLong() toFloat() toDouble() toChar()
toBigInteger() toBigDecimal() // JVM 专属
// 📌 Kotlin 2.4.0:无符号整数 → BigInteger 的新 API 转稳定
toUByte() toUShort() toUInt() toULong()
字面量
kotlin
val one = 1 // Int
val long = 1L // Long(必须大写 L,小写 l 易与 1 混淆)
val hex = 0xFF // 支持
val bin = 0b1010_1010 // 支持(1.1+)
// val oct = 0777 // ❌ Kotlin 不支持八进制字面量!
val big = 1_000_000_000 // 下划线分隔(1.1+)
val f = 1.0f // Float 必须带 f
val d = 1.0 // Double
val u = 42u // UInt
val ul = 42uL // ULong
浮点比较
kotlin
0.1 + 0.2 == 0.3 // false!(IEEE 754)
abs(0.1 + 0.2 - 0.3) < 1e-9 // ✅ 正确做法
// Kotlin 标准库提供了带容差的比较(JVM)
kotlin.math.abs(a - b) < eps
🚀 金额一律用 BigDecimal:
kotlin
val price = "19.99".toBigDecimal()
val total = price * 3.toBigDecimal() + "0.5".toBigDecimal()
// ⚠️ BigDecimal(0.1) vs BigDecimal("0.1")
BigDecimal(0.1) // 0.1000000000000000055511151231257827021181583404541015625 ← 陷阱!
BigDecimal("0.1") // 0.1 ✅
⚠️ BigDecimal.equals 比较 scale :BigDecimal("1.0") == BigDecimal("1.00") 是 false!要用 compareTo(Kotlin 里 > < 走 compareTo,但 == 走 equals)。
kotlin
val a = "1.0".toBigDecimal()
val b = "1.00".toBigDecimal()
println(a == b) // false ← 陷阱
println(a.compareTo(b) == 0) // true ✅
println(a in b..b) // compareTo 语义
无符号类型
kotlin
val u: UInt = 4_000_000_000u // 超过 Int.MAX_VALUE
val ub: UByte = 255u
🔬 实现真相 :UInt 是 @JvmInline value class UInt(private val data: Int)。所以:
- 在非泛型位置,
UInt零开销 (就是int,只是运算语义不同); - 在泛型位置或可空时装箱 成
UInt对象(16 字节); - 数组用
UIntArray(int[])。
🚀 什么时候用无符号类型 :位操作、协议解析(网络字节序、文件格式)、加密。⚠️ 不要用于业务数值------它们的算术语义(溢出回绕)容易埋坑,且与 Java 库互操作需要转换。
6.4 字符串
Kotlin 的 String 就是 java.lang.String
没有中间层,没有包装。所以 Java 的所有字符串知识完全适用 :不可变、常量池、intern()、UTF-16 编码、📌 JDK 9 的 Compact Strings(Latin-1 优化)。
模板与多美元插值
kotlin
val name = "Kotlin"
val s1 = "Hello, $name!" // 简单模板
val s2 = "Length: ${name.length}" // 表达式模板
val s3 = "$" // 单独的 $ 需要转义?不需要
val s4 = "\$" // 或者转义
📌 多美元插值(multi-dollar interpolation,Kotlin 2.2 稳定) 解决了"字符串里本身就有 $"的痛点:
kotlin
// 旧写法:痛苦
val price = "Price: \$${amount}"
val regex = "\${'$'}\\d+"
val shellScript = "echo \$HOME" // 需要转义
// 新写法(2.2+):
val price = $$"Price: $amount" // 用 $$ 前缀,$ 需要写两个才是插值
val shellScript = $$"echo $HOME" // 单个 $ 就是字面量!✅
val template = $$$"Value: $$x" // $$$ 前缀,$$ 才是插值
⚠️ 注意语义反转 :$$"..." 里,单个 $ 是字面量,$$ 才是插值。这个设计让 shell 脚本、正则、货币、模板引擎字符串变得自然。
原始字符串与缩进处理
kotlin
val json = """
{
"name": "Kotlin",
"version": ${KotlinVersion.CURRENT}
}
""".trimIndent()
val sql = """
SELECT id, name
FROM users
WHERE age > #minAge
ORDER BY name
""".trimMargin("#") // 自定义边界符
🔬 trimIndent() 的成本 :它在运行期执行------检测最小公共缩进、逐行裁剪、拼接成新字符串。如果在热路径或高频调用的常量场景使用,会有分配开销。
🚀 优化:
kotlin
// ❌ 每次调用都 trimIndent
fun query() = """
SELECT ...
""".trimIndent()
// ✅ 提到顶层,只算一次
private val QUERY = """
SELECT ...
""".trimIndent()
fun query() = QUERY
📌 Kotlin 2.4.0 的"编译期常量改进"(实验) 让部分这类计算能在编译期完成。
字符串拼接的字节码真相
kotlin
val s = "a" + x + "b"
text
new StringBuilder → invokespecial <init> → ldc "a" → append → aload x → append
→ ldc "b" → append → toString
📌 Kotlin 不使用 JDK 9+ 的 invokedynamic 字符串拼接 (第 4 章已解释:为了多平台一致性)。JIT 的逃逸分析通常能消除 StringBuilder 分配,但在循环里拼接字符串仍然是 O(n²):
kotlin
// ❌ 每次循环创建新 String
var s = ""
for (i in 0 until 100_000) s += i
// ✅ 用 buildString(内部是 StringBuilder,且 inline)
val s = buildString { for (i in 0 until 100_000) append(i) }
// ✅ 或用 joinToString
val s = (0 until 100_000).joinToString("")
6.5 三个特殊类型:Unit、Nothing、Any
Unit:不是 void
kotlin
fun doSomething(): Unit { } // Unit 可省略
🔬 Unit 是一个真实的类型 ,有一个单例对象 kotlin.Unit.INSTANCE(字节码里就是 getstatic kotlin/Unit.INSTANCE)。
为什么不用 void?因为函数是一等公民,需要能被赋给变量、作为泛型参数:
kotlin
val f: () -> Unit = { println("hi") } // 函数类型需要 Unit
val handlers: List<() -> Unit> = listOf(...) // 泛型参数不能是 void
fun run(block: () -> Unit) { block() }
📌 映射规则:
- 方法返回类型位置的
Unit→void; - 泛型参数位置的
Unit→kotlin.Unit(装箱的单例,无分配开销,因为是单例)。
Nothing:永不正常返回
kotlin
fun fail(msg: String): Nothing = throw IllegalStateException(msg)
val x: Int = if (cond) 42 else fail("bad") // ✅ 类型推断成功!
🔬 Nothing 是底部类型(bottom type) ,它是所有类型的子类型。所以 fail(...) 可以出现在任何期望值的位置。
为什么需要它:让编译器的控制流分析知道"这条路径不会返回值"。
kotlin
fun validate(s: String?): String {
if (s == null) fail("null") // 返回 Nothing
return s // ✅ 智能转换生效:s 已知非空
}
⚠️ 如果 fail 声明为 Unit 返回 ,编译器会认为它可能正常返回,s 的智能转换就失败了 → 报 smart cast impossible。
📌 标准库里返回 Nothing 的函数 :error()、TODO()、throw 表达式、exitProcess()、assert 失败分支。
Nothing? 是只有一个值 null 的类型:
kotlin
val alwaysNull: Nothing? = null // 合法,且只能是 null
这在与 Java 泛型互操作时偶尔出现(如 Collections.emptyList() 的类型推断)。
Any / Any?
kotlin
Any // ≈ java.lang.Object,但**不包含**基本类型(Int 不是 Any 的子类型?其实是,但会装箱)
Any? // 所有类型的根(包括可空类型)
🔬 Any 只有三个方法 :equals、hashCode、toString。Java Object 的其他方法(getClass、clone、finalize、notify、wait)在 Kotlin 里不可直接调用:
kotlin
val o: Any = "x"
// o.javaClass // ❌ 编译错误
o::class.java // ✅ Kotlin 方式
o.hashCode() // ✅
// o.wait() // ❌
(o as java.lang.Object).wait() // ✅ 需要显式转换(罕见)
📌 finalize() 在 Kotlin 里无法重写 ------这是有意的设计,因为 📌 JDK 18 起 finalize 已被标记为 deprecated for removal(JEP 421) 。要清理资源用 AutoCloseable + use,或 java.lang.ref.Cleaner。
6.6 变量、常量与推断
kotlin
val immutable = 42 // 类型推断为 Int
var mutable = 42
val explicit: Long = 42L // 显式类型
const val COMPILE_TIME = 42 // 编译期常量
const val 的严格条件
必须全部满足:
- 类型是基本类型或
String; - 位于顶层 或
object/companion object内; - 用编译期可求值的表达式初始化;
- 不能有自定义 getter。
kotlin
const val MAX = 100 // ✅
const val NAME = "Kotlin" // ✅
const val DOUBLED = MAX * 2 // ✅ 编译期可算
// const val LIST = listOf(1,2,3) // ❌ 不是基本类型/String
// const val NOW = System.nanoTime() // ❌ 非编译期常量
object Config { const val VERSION = "1.0" } // ✅
class Foo { companion object { const val X = 1 } } // ✅
🔬 const val 的字节码 :编译为 public static final 字段,且带 ConstantValue 属性 。这意味着其他类引用它时,值会被内联进引用方的常量池 ------修改常量后,引用方必须重新编译 才生效(与 Java 的 static final 完全一样的陷阱)。
⚠️ 这就是"常量类库升级后调用方行为没变"的根因。第 47 章的二进制兼容章节会展开。
类型推断与显式返回类型
kotlin
fun add(a: Int, b: Int) = a + b // 推断返回 Int
fun add2(a: Int, b: Int): Int = a + b // 显式
🚀 规则(也是 explicitApi() 模式强制的):
- 私有函数/局部函数:可以推断;
- 公开 API :必须显式声明返回类型。
理由有三:
- 可读性:调用方不需要看函数体就知道返回什么;
- API 稳定性:推断的返回类型会随实现变化而"悄悄"改变 → 破坏二进制兼容;
- 编译速度:显式返回类型让编译器不需要分析函数体就能处理调用点,改善增量编译。
⚠️ 递归函数必须显式声明返回类型(编译器无法推断):
kotlin
fun fact(n: Int): Long = if (n <= 1) 1 else n * fact(n - 1)
// fun fact(n: Int) = ... ❌ 编译错误
6.7 lateinit vs lazy vs Delegates.notNull
三者解决同一个问题------"延迟初始化",但语义完全不同:
lateinit var |
by lazy { } |
Delegates.notNull<T>() |
|
|---|---|---|---|
| 可修饰 | var |
val |
var |
| 支持基本类型 | ❌ 不支持 Int/Long 等 |
✅ | ✅ |
| 线程安全 | ❌(裸字段赋值) | ✅ 默认 SYNCHRONIZED | ❌ |
| 可检查是否初始化 | ✅ ::x.isInitialized |
❌ | ❌ |
| 未初始化访问 | UninitializedPropertyAccessException |
自动计算 | IllegalStateException |
| 开销 | 零(就是一个字段) | 一个 Lazy 对象 + 锁 |
一个委托对象 |
| 典型用途 | DI 注入、JUnit @BeforeEach、Android onCreate |
昂贵的按需计算、单例 | 基本类型的两阶段初始化 |
kotlin
class Service {
lateinit var repository: Repository // DI 注入
private val config: Config by lazy { // 首次访问才加载
loadExpensiveConfig()
}
private var retryCount by Delegates.notNull<Int>() // Int 不能用 lateinit
fun init() {
if (!::repository.isInitialized) {
repository = DefaultRepository()
}
retryCount = 3
}
}
lazy 的三种模式(性能差异巨大)
kotlin
by lazy { } // SYNCHRONIZED(默认):双重检查锁 + volatile
by lazy(LazyThreadSafetyMode.PUBLICATION) { } // 允许多线程同时计算,只发布第一个结果
by lazy(LazyThreadSafetyMode.NONE) { } // 无锁,仅限单线程
🔬 SynchronizedLazyImpl 的实现:
kotlin
private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T> {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE // ← 注意 @Volatile
private val lock = lock ?: this
override val value: T
get() {
val _v1 = _value
if (_v1 !== UNINITIALIZED_VALUE) return _v1 as T // 快路径:无锁
return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) _v2 as T
else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}
}
⚠️ 代价:
- 一个
SynchronizedLazyImpl对象(📌 JDK 27 紧凑头下:8 + 4(ref) + 4(ref) + padding = 24 字节)+ 一个 lambda 对象; - 每次读取都要走一次
volatile读(快路径); - 初始化后
initializer置 null,所以 lambda 可以被 GC。
🚀 热路径优化 :如果确定单线程访问(如仅在 main 或单个协程里),用 LazyThreadSafetyMode.NONE 省掉 volatile 读和对象开销。但默认值的选择是对的 ------绝大多数 lazy 用于单例,需要线程安全。
🧪 实验 6-4:测量 lazy 的读取开销
kotlin
@Benchmark fun lazyRead(): Int = lazyVal // ~2 ns
@Benchmark fun directRead(): Int = directVal // ~0.3 ns
6.8 智能转换(Smart Cast):精确的触发条件
kotlin
fun printLength(x: Any?) {
if (x is String) {
println(x.length) // ✅ x 已被智能转换为 String
}
if (x == null) return
println(x.hashCode()) // ✅ x 已被转换为 Any(非空)
}
🔬 智能转换成功的充要条件(编译器必须能证明"从检查点到使用点之间值不可能改变"):
| 情形 | 能否智能转换 | 原因 |
|---|---|---|
局部 val |
✅ 总是 | 不可能被重新赋值 |
局部 var |
✅ 若检查后未修改、且未被"会修改它的 lambda"捕获 | 编译器做数据流分析 |
val 属性(同模块,无自定义 getter,非 open) |
✅ | 不可能被子类或外部改变 |
var 属性 |
❌ | 可能被其他线程/代码修改 |
| 有自定义 getter 的属性 | ❌ | getter 每次可能返回不同值 |
open 属性 / 接口属性 |
❌ | 子类可能重写 getter |
委托属性(by) |
❌ | 委托可以返回任何值 |
来自其他模块的 val |
❌ | 无法保证未被修改 |
在 lambda 内引用外部 var |
❌ | lambda 可能延迟执行 |
kotlin
class Foo {
var name: String? = null
val id: String? = null
fun test() {
if (name != null) println(name!!.length) // ❌ 需要 !!(var 属性)
if (id != null) println(id.length) // ✅ 智能转换(val 属性)
}
}
fun bar(x: Any?) {
var y = x
if (y is String) println(y.length) // ✅ 局部 var,未修改
val list = mutableListOf<Any?>()
list.add(x)
if (x is String) {
val f = { println(x.length) } // ✅ x 是参数(val)
f()
}
}
fun baz(cond: Boolean) {
var z: Any? = if (cond) "s" else 1
if (z is String) {
z = 42 // ← 修改了
// println(z.length) // ❌ 智能转换失效
}
}
🚀 无法智能转换时的正确应对(按优先级):
kotlin
// ❌ 最差:!!
if (name != null) println(name!!.length)
// ✅ 方案 1:赋值给局部 val(最推荐,零成本)
val n = name
if (n != null) println(n.length)
// ✅ 方案 2:安全调用
name?.let { println(it.length) }
// ✅ 方案 3:let + 提前返回
val n = requireNotNull(name) { "name must not be null" }
println(n.length)
// ✅ 方案 4:contract(让编译器相信你,第 9 章详谈)
fun check(x: Any?): String {
require(x is String) // require 带 contract:returns() implies (x is String)
return x // ✅ 智能转换生效
}
📌 require / check / requireNotNull / checkNotNull 都带 contract,这是标准库帮你在编译期做数据流分析的典范:
kotlin
public inline fun requireNotNull(value: T?): T {
contract { returns() implies (value != null) }
return value!!
}
6.9 区间与进度(Range & Progression)
kotlin
for (i in 1..10) { } // 闭区间 [1,10],IntRange
for (i in 1 until 10) { } // 半开 [1,10),等价于 1..<10
for (i in 1..<10) { } // 📌 1.9 起..< 运算符稳定
for (i in 10 downTo 1) { } // 递减
for (i in 1..100 step 10) { } // 步长
for (i in list.indices) { } // 0 until list.size
for ((i, v) in list.withIndex()) { }
if (x in 1..100) { } // contains 检查
if (s in "a".."z") { } // 字符串区间也支持
🔬 性能真相:区间不是"创建了集合"
kotlin
for (i in 1..1_000_000) { }
编译器会识别这个模式并降解为普通 for 循环 ,不分配 IntRange 对象,不创建 Iterator:
text
iconst_1
istore_1
ldc #xx // 1000000
istore_2
loop:
iload_1
... 循环体 ...
iinc 1, 1
iload_1
iload_2
if_icmple loop
⚠️ 但如果你把区间赋给变量或用非字面量形式,就可能真的分配对象:
kotlin
val range = 1..n // 分配 IntRange 对象
for (i in range) { } // 仍然可能优化,但取决于编译器
listOf(1..10).flatMap { it.toList() } // toList() 会真的物化 10 个 Integer
🚀 step 与 downTo 会生成 IntProgression ,其迭代器实现比朴素 for 稍慢(需要检查 last 与 step 的符号)。在极端热路径上,手写 var i = 10; while (i >= 1) { ...; i-- } 更快。
⚠️ 经典陷阱:Int.MAX_VALUE 边界
kotlin
for (i in 1..Int.MAX_VALUE) { } // 会怎样?
IntRange 的 last = Int.MAX_VALUE,循环条件 i <= last。当 i 到达 MAX_VALUE 后,iinc 溢出为 Int.MIN_VALUE,MIN_VALUE <= MAX_VALUE 为真 → 死循环?
🔬 实际上 Kotlin 的 IntProgressionIterator 用 finalElement + hasNext 标志位处理了这个情况,不会死循环 。但你自己手写的 while (i <= MAX_VALUE) i++ 会死循环。
6.10 typealias:别名不是新类型
kotlin
typealias UserId = String
typealias Handler = (Event) -> Unit
typealias NodeMap = MutableMap<String, MutableList<Node>>
fun find(id: UserId): User? = null
🔬 typealias 是纯编译期的 :字节码里 UserId 完全消失,就是 java.lang.String。
⚠️ 所以它不提供类型安全:
kotlin
typealias UserId = String
typealias OrderId = String
fun findUser(id: UserId) { }
findUser(orderId) // ✅ 编译通过!但这是 bug
🚀 需要真正的类型区分时,用 value class:
kotlin
@JvmInline value class UserId(val raw: String)
@JvmInline value class OrderId(val raw: String)
fun findUser(id: UserId) { }
findUser(OrderId("x")) // ❌ 编译错误 ✅
且 value class 在多数位置零运行时开销 (会被内联成 String)。第 12 章详解。
6.11 本章小结
- "一切皆对象"是语义层面的说法 。字节码层面,Kotlin 严格遵循四条映射规则:非空基本类型在原生位置用原生类型;可空必装箱;泛型位置必装箱;
Array<Int>是Integer[]而IntArray是int[]。 - 装箱有 6 个触发场景 ,其中最隐蔽的三个是:suspend 函数返回基本类型 、
println配合字符串模板 、lambda 捕获可变基本类型变量 (生成Ref.IntRef)。 List<Int>比IntArray慢 8 倍、占 5 倍内存------这不是 Kotlin 的问题,是 JVM 泛型擦除的必然结果。数值密集场景必须用原生数组。- Kotlin 禁止隐式数字转换,这是刻意设计,能防住 Java 里最经典的一类溢出 bug。
Nothing是底部类型 ,让控制流分析能理解"这条路径不返回",是智能转换与require/check契约的基础。const val会被内联进引用方的常量池 ------修改常量后调用方必须重编译(与 Javastatic final同样的陷阱)。- 智能转换有精确的触发条件 ,
var属性、自定义 getter、委托属性、跨模块val都不行。正确应对是"赋给局部 val",而不是!!。 - 📌 紧凑对象头(JDK 27 默认)对
Integer和数组没有收益 ,收益来自字段总量 ≥ 8 字节的对象(如两个Int字段的Point:24 → 16 字节)。
6.12 练习题
6-1. 下面每行代码分别产生多少次装箱?
kotlin
fun f(a: Int, b: Int?, list: List<Int>, any: Any): Long {
val x = a // ①
val y = b ?: 0 // ②
val z = list[0] // ③
val w = any as? Int // ④
val p = a to b // ⑤
println(a) // ⑥
println("$a") // ⑦
var sum = 0
val fn = { sum += a } // ⑧
listOf(1,2,3).forEach { sum += it } // ⑨
return x.toLong() + z
}
答案要点
① 0 次 (int → int) ② 1 次 (b ?: 0 的结果类型是 Int,但 b 本身是 Integer;Elvis 需要拆箱 b,然后结果 Int 不装箱。准确说:0 次装箱,1 次拆箱 ) ③ 0 次装箱,1 次拆箱 (List<Integer> 取出后拆箱为 int) ④ 1 次 (as? Int 结果类型是 Int?,装箱) ⑤ 2 次 (Pair<Int, Int?>:a 装箱为 Integer,b 已是 Integer) ⑥ 0 次 (println(Int) 重载存在,直接 PrintStream.println(I)V) ⑦ 1 次 (字符串模板走 Any? 或 StringBuilder.append(Object);实际编译为 append(I) 则 0 次------需实测 ,Kotlin 对基本类型模板有 append(int) 优化路径) ⑧ 1 次 (sum 被非内联 lambda 捕获 → 分配 Ref.IntRef;a 作为不可变捕获不额外装箱) ⑨ 0 次额外分配 (forEach 是 inline,sum 直接访问栈上局部变量;但 listOf(1,2,3) 本身有 3 次装箱)
这类题的价值不在精确答案,而在训练"看到代码就能想到字节码"的反射。 建议用 javap -c 逐行验证。
6-2. 为什么 suspend fun count(): Int 每次调用都会装箱?有什么规避手段?
答案要点
原因 :CPS 变换后,suspend 函数的返回类型在字节码里必须是 Object(因为可能返回 COROUTINE_SUSPENDED 哨兵)。所以 Int 结果必须 Integer.valueOf() 装箱后返回,调用方再拆箱。
规避:
- 批量化 :返回
IntArray/List<Int>而非单个Int,把装箱成本摊薄; - 用
Long打包 :多个小整数打包进一个Long(仍会装箱 1 次,但省了 N 次); - 避免在超高频热路径使用 suspend 返回基本类型 :例如每秒百万次的计数器接口,考虑用
Channel<Int>(同样装箱)之外的方案,或直接用虚拟线程 + 阻塞 API; - 等 Valhalla:value class 的扁平化能从根本上解决;
- 实测优先 :JIT 的逃逸分析在很多场景能消除这个装箱(标量替换),先用
-prof gc确认是否真的分配了。
6-3. (🧪 动手)用 JOL 测量下列对象在 JDK 27(紧凑头开启)与 JDK 21(未开启)下的大小差异: (a) data class Point(val x: Int, val y: Int) (b) class Holder(val ref: Any?) (c) IntArray(10) (d) data class Triple3(val a: Int, val b: Int, val c: Int)
答案要点
| 对象 | JDK 21(12B 头) | JDK 27(8B 头) | 差异 |
|---|---|---|---|
| (a) Point(2×int) | 12+8=20→24 | 8+8=16 | -8 (-33%) |
| (b) Holder(1 ref) | 12+4=16→16 | 8+4=12→16 | 0(对齐巧合) |
| (c) int10 | 12+4+40=56→56 | 8+4+40=52→56 | 0(对齐巧合) |
| (d) Triple3(3×int) | 12+12=24→24 | 8+12=20→24 | 0(对齐巧合) |
结论 :紧凑对象头的收益高度依赖字段总大小与 8 字节对齐的相互作用。不是"所有对象都省 4 字节"。这解释了为什么官方给出的整体收益是"10--20%"而不是"每个对象省 4 字节"。
验证命令:
bash
java -XX:+UseCompactObjectHeaders -cp ... JolMain # JDK 21/25 需显式开启
java -XX:-UseCompactObjectHeaders -cp ... JolMain # JDK 27 显式关闭
6-4. 解释为什么在 lambda 中修改外部的 var counter: Int 会分配对象,而 list.forEach { counter += it } 不会。
答案要点
关键差异是 inline。
非内联的 lambda 是一个独立的对象(Function0/Function1 的实现类),它不能直接访问调用方的栈帧局部变量 (因为 lambda 可能在别处、别的时间执行)。所以编译器把被捕获的可变基本类型变量包装进 kotlin.jvm.internal.Ref$IntRef 对象(一个持有 public int element 字段的类),lambda 和外部代码都通过这个对象读写。
forEach 是 inline 函数 → lambda 体被复制到调用点的字节码里 → 它就在同一个栈帧中 → 可以直接用 iload/istore 操作局部变量,无需 Ref 包装,也无需分配 Function 对象。
验证:
kotlin
var c = 0
list.forEach { c += it } // javap 看不到 Ref$IntRef
val f = { c += 1 } // javap 看到 new Ref$IntRef
f()
6-5. const val 和普通 val 在字节码层面有什么区别?各自的风险是什么?
答案要点
const val:
- 编译为
public static final字段 +ConstantValue属性; - 引用方的常量池里直接存值 ,不生成
getstatic指令; - ⚠️ 风险 :修改常量后,引用方不重新编译就仍用旧值 。这是库版本升级的经典陷阱(改了
const val DEFAULT_TIMEOUT = 30→60,下游 jar 没重编译,行为不变)。
普通顶层 val:
- 编译为
private static final字段 +getXxx()静态方法 (在XxxKt文件类里); - 引用方生成
invokestatic getXxx(); - ✅ 修改后下游无需重编译即可生效(JIT 可能仍会内联常量,但去优化机制会处理);
- ⚠️ 代价:一次方法调用(会被内联)。
准则 :库的公开常量优先用普通 val 而非 const val,除非你确定它永不变(如数学常数)。应用内部代码用 const val 无妨。
6.13 面试题精选
Q1:Kotlin 有基本类型吗?Int 和 Integer 是什么关系?
答题框架:
- 语义上没有基本类型 ------
Int是Number的子类,可以调方法(x.toString())、可以为 null(Int?)。 - 字节码上有 ------编译器根据位置决定:非空且在字段/参数/返回/局部变量位置 →
int;可空或泛型位置 →java.lang.Integer。 - 因此
Int是"编译期的多态类型",Integer是它在 JVM 上的一种表现。 - 加分:
IntArray→int[],Array<Int>→Integer[];UInt是@JvmInline value class;装箱的 6 个触发场景。
Q2:List<Int> 和 IntArray 的性能差异有多大?为什么?
答题框架:
- 量级:求和场景约 5--8 倍差距;内存约 5 倍差距(100 万元素:3.8MB vs 19MB)。
- 四个原因 :① 内存占用(每个 Integer 16 字节 + 引用 4/8 字节);② 缓存局部性(连续 vs 指针跳跃);③ 额外解引用(两次内存访问);④ 向量化(
int[]可 SIMD,Integer[]不行)。 - 验证手段 :JMH +
-prof gc、JOL 的GraphLayout.totalSize()。 - 前瞻 :Valhalla value class 将允许
List<int>扁平化存储,从根本上消除这个差距。
Q3:lateinit 和 lazy 怎么选?
答题框架:见 6.7 的对照表。核心判断:
- 需要外部注入 + 可变 →
lateinit; - 需要惰性计算 + 不可变 →
lazy; - 基本类型且两阶段初始化 →
Delegates.notNull; - 性能敏感且确定单线程 →
lazy(NONE); - 加分:提到
::x.isInitialized、LazyThreadSafetyMode三档、SynchronizedLazyImpl的 volatile 快路径与initializer = null释放引用。
Q4:什么是智能转换?它在什么情况下会失败?失败了怎么办?
答题框架:
- 定义:编译器基于数据流分析,在类型检查后自动把变量视为更具体的类型,免去强制转换。
- 失败条件(背 6 条):
var属性、自定义 getter、open/接口属性、委托属性、跨模块val、被 lambda 捕获的可变局部变量。 - 应对(按优先级):赋给局部
val>?.let>requireNotNull/checkNotNull(带 contract)>!!(最后手段,应被 Detekt 禁止)。 - 加分:
require/check系列通过 contract 把运行时断言变成编译期数据流信息,这是 Kotlin 独有的能力(第 9 章)。
下一章预告 :第 7 章《空安全》------Kotlin 最核心的差异化能力。我们会讲清楚:T? 在类型论上是什么、平台类型 T! 是怎么来的、Intrinsics.checkNotNullParameter 的运行时成本到底是多少(剧透:接近零,因为 HotSpot 用信号处理实现隐式空检查)、以及 JSpecify 如何终结"五套 nullability 注解"的混乱。