Kotlin与Java虚拟机六:基础与类型系统:一切皆对象,但对象有代价

文章来源声明: 原文作者:灿飞; 来源站点:掘金; 原文链接:https://juejin.cn/post/7703787921422909481; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合Kotlin后端与性能敏感开发者,用JOL数字和六条装箱清单把抽象类型系统落到内存与缓存代价上,可直接用于热路径审查与代码评审。

第 6 章 基础与类型系统:一切皆对象,但对象有代价 --------------------------

本章目标

  1. 搞清 Kotlin 类型系统与 JVM 类型的精确映射规则——什么时候是 int,什么时候是 Integer;
  2. 掌握装箱的全部 6 个触发场景,并能用 JOL 量出真实内存代价;
  3. 理解 IntArray 与 List<Int> 在内存布局上的本质差异;
  4. 吃透 Nothing / Unit / Any 三个特殊类型、lateinit vs lazy、智能转换的精确触发条件;
  5. 建立"类型系统的每个便利都有物理代价"的思维习惯。

6.0 一个必须先看到的数字

val n = 1_000_000

val primitive: IntArray = IntArray(n) { it }              // 原生数组
val boxed: List<Int>    = (0 until n).toList()            // 装箱列表

🧪 实验 6-1:用 JOL 量出真实占用

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 实测输出(紧凑对象头默认开启):

IntArray     : 4000016 bytes        ≈ 3.81 MB
List<Int>    : 20000080 bytes       ≈ 19.07 MB
单个 Integer : 16 bytes

5 倍差距。 而且这不只是内存问题:

@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 原生类型

KotlinJVM 原生包装类
`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`—

"原生位置"指:字段类型、方法参数类型、方法返回类型、局部变量类型。

class Foo(val count: Int) { fun bar(x: Int): Int = x }

private final I count
public final getCount()I
public final bar(I)I

规则 2:可空的基本类型 → 包装类

class Foo(val count: Int?) { fun bar(x: Int?): Int? = x }

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:泛型类型参数位置 → 包装类(因为擦除)

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:验证泛型位置装箱

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 原生数组

KotlinJVM内存布局
`IntArray``int[]`连续 4 字节/元素
`LongArray``long[]`连续 8 字节/元素
`ByteArray``byte[]`连续 1 字节/元素
`CharArray``char[]`连续 2 字节/元素
`BooleanArray``boolean[]`连续 1 字节/元素(**不是 bit**!)
`DoubleArray`/`FloatArray`/`ShortArray`对应原生数组
`Array``Integer[]`引用数组 + 每元素一个 Integer 对象
`Array``String[]`引用数组
`Array``int[][]`✅ 原生嵌套

⚠️ Array<Int> 是 Integer[],不是 int[]。这是新手最容易写错的地方:

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,紧凑对象头):

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 装箱的六个触发场景(完整清单)

背下这六条,你就能预判大部分装箱开销:

① 可空基本类型

val a: Int? = null      // Integer
var counter: Long? = 0  // Long

② 泛型类型参数

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!

suspend fun count(): Int = 42
// 字节码签名:count(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
// 返回 Integer.valueOf(42)

🚀 这是第 37 章会详细讨论的"协程装箱税"。缓解手段:批量化(返回集合而非单个值)、用 Long 打包多个小整数、避免在超高频热路径上用 suspend 返回基本类型。

③ 集合

listOf(1, 2, 3)             // List<Integer>
mutableMapOf("a" to 1)      // Map<String, Integer>
list.sum(), list.max(), list.average()   // 内部大量装箱

④ 赋给 Any / Any?

val x: Any = 42             // Integer
println(42)                 // println(Any?) → 装箱!

⚠️ println 是最隐蔽的装箱源。Kotlin 的 println 有一组重载(Int、Long、Double、Boolean、Char、CharArray、Any?),编译器会选最具体的:

val i: Int = 42
println(i)                  // 选中 println(I)V?

🧪 实验 6-3:println 到底选哪个重载

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

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 捕获基本类型变量

var sum = 0
val f = { sum += 1 }        // sum 被捕获 → 装箱成 Ref.IntRef 对象!

🔬 Kotlin 的解决方案是 Ref 包装类(kotlin.jvm.internal.Ref$IntRef):

// 字节码等价
val sumRef = Ref.IntRef()      // 分配一个持有 int 字段的对象
sumRef.element = 0
val f = { sumRef.element += 1 }

⚠️ 这意味着在 lambda 里修改外部的 var 基本类型变量会分配一个对象。而且 Ref.IntRef 是非线程安全的裸字段。

🚀 规避:如果 lambda 会被 inline 展开(如 forEach、let),则不需要 Ref 包装——变量直接在调用点的栈帧里:

var sum = 0
list.forEach { sum += it }     // forEach 是 inline → 无 Ref 分配 ✅
val g = { sum += 1 }           // 非 inline 的 lambda 字面量 → Ref.IntRef ❌

这是 inline 最被低估的收益之一。

⑥ 委托属性、Any 参数的 API、反射

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 转来最容易撞的墙:

val i: Int = 42
val l: Long = i          // ❌ 编译错误:Type mismatch
val l2: Long = i.toLong() // ✅

Java 里 long l = i; 是合法的(隐式加宽),Kotlin 禁止。

设计理由:隐式转换是精度丢失与性能问题的温床。经典 Java bug:

long ms = intSeconds * 1000;      // int 溢出!结果错误后再加宽

Kotlin 强迫你写:

val ms: Long = seconds.toLong() * 1000    // 显式,无溢出

完整转换方法

toByte() toShort() toInt() toLong() toFloat() toDouble() toChar()
toBigInteger() toBigDecimal()          // JVM 专属
// 📌 Kotlin 2.4.0:无符号整数 → BigInteger 的新 API 转稳定
toUByte() toUShort() toUInt() toULong()

字面量

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

浮点比较

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:

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)。

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 语义

无符号类型

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 优化)。

模板与多美元插值

val name = "Kotlin"
val s1 = "Hello, $name!"                  // 简单模板
val s2 = "Length: ${name.length}"         // 表达式模板
val s3 = "$"                              // 单独的 $ 需要转义?不需要
val s4 = "\$"                             // 或者转义

📌 多美元插值(multi-dollar interpolation,Kotlin 2.2 稳定) 解决了"字符串里本身就有 $"的痛点:

// 旧写法:痛苦
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 脚本、正则、货币、模板引擎字符串变得自然。

原始字符串与缩进处理

val json = """
    {
      "name": "Kotlin",
      "version": ${KotlinVersion.CURRENT}
    }
""".trimIndent()

val sql = """
    SELECT id, name
    FROM users
    WHERE age > #minAge
    ORDER BY name
""".trimMargin("#")      // 自定义边界符

🔬 trimIndent() 的成本:它在运行期执行——检测最小公共缩进、逐行裁剪、拼接成新字符串。如果在热路径或高频调用的常量场景使用,会有分配开销。

🚀 优化:

// ❌ 每次调用都 trimIndent
fun query() = """
    SELECT ...
""".trimIndent()

// ✅ 提到顶层,只算一次
private val QUERY = """
    SELECT ...
""".trimIndent()
fun query() = QUERY

📌 Kotlin 2.4.0 的"编译期常量改进"(实验) 让部分这类计算能在编译期完成。

字符串拼接的字节码真相

val s = "a" + x + "b"

new StringBuilder → invokespecial <init> → ldc "a" → append → aload x → append
→ ldc "b" → append → toString

📌 Kotlin 不使用 JDK 9+ 的 invokedynamic 字符串拼接(第 4 章已解释:为了多平台一致性)。JIT 的逃逸分析通常能消除 StringBuilder 分配,但在循环里拼接字符串仍然是 O(n²):

// ❌ 每次循环创建新 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

fun doSomething(): Unit { }      // Unit 可省略

🔬 Unit 是一个真实的类型,有一个单例对象 kotlin.Unit.INSTANCE(字节码里就是 getstatic kotlin/Unit.INSTANCE)。

为什么不用 void?因为函数是一等公民,需要能被赋给变量、作为泛型参数:

val f: () -> Unit = { println("hi") }        // 函数类型需要 Unit
val handlers: List<() -> Unit> = listOf(...)  // 泛型参数不能是 void
fun run(block: () -> Unit) { block() }

📌 映射规则:

  • 方法返回类型位置的 Unit → void;
  • 泛型参数位置的 Unit → kotlin.Unit(装箱的单例,无分配开销,因为是单例)。

Nothing:永不正常返回

fun fail(msg: String): Nothing = throw IllegalStateException(msg)

val x: Int = if (cond) 42 else fail("bad")   // ✅ 类型推断成功!

🔬 Nothing 是底部类型(bottom type),它是所有类型的子类型。所以 fail(...) 可以出现在任何期望值的位置。

为什么需要它:让编译器的控制流分析知道"这条路径不会返回值"。

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 的类型:

val alwaysNull: Nothing? = null    // 合法,且只能是 null

这在与 Java 泛型互操作时偶尔出现(如 Collections.emptyList() 的类型推断)。

Any / Any?

Any    // ≈ java.lang.Object,但**不包含**基本类型(Int 不是 Any 的子类型?其实是,但会装箱)
Any?   // 所有类型的根(包括可空类型)

🔬 Any 只有三个方法:equals、hashCode、toString。Java Object 的其他方法(getClass、clone、finalize、notify、wait)在 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 变量、常量与推断

val immutable = 42              // 类型推断为 Int
var mutable = 42
val explicit: Long = 42L        // 显式类型

const val COMPILE_TIME = 42     // 编译期常量

const val 的严格条件

必须全部满足:

  1. 类型是基本类型或 String;
  2. 位于顶层或 object / companion object 内;
  3. 用编译期可求值的表达式初始化;
  4. 不能有自定义 getter。
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 章的二进制兼容章节会展开。

类型推断与显式返回类型

fun add(a: Int, b: Int) = a + b        // 推断返回 Int
fun add2(a: Int, b: Int): Int = a + b  // 显式

🚀 规则(也是 explicitApi() 模式强制的):

  • 私有函数/局部函数:可以推断;
  • 公开 API:必须显式声明返回类型。

理由有三:

  1. 可读性:调用方不需要看函数体就知道返回什么;
  2. API 稳定性:推断的返回类型会随实现变化而"悄悄"改变 → 破坏二进制兼容;
  3. 编译速度:显式返回类型让编译器不需要分析函数体就能处理调用点,改善增量编译。

⚠️ 递归函数必须显式声明返回类型(编译器无法推断):

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()`
**可修饰**`var``val``var`
**支持基本类型**❌ 不支持 `Int`/`Long` 等✅✅
**线程安全**❌(裸字段赋值)✅ 默认 SYNCHRONIZED❌
**可检查是否初始化**✅ `::x.isInitialized`❌❌
**未初始化访问**`UninitializedPropertyAccessException`自动计算`IllegalStateException`
**开销**零(就是一个字段)一个 `Lazy` 对象 + 锁一个委托对象
**典型用途**DI 注入、JUnit `@BeforeEach`、Android `onCreate`昂贵的按需计算、单例基本类型的两阶段初始化
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 的三种模式(性能差异巨大)

by lazy { }                                   // SYNCHRONIZED(默认):双重检查锁 + volatile
by lazy(LazyThreadSafetyMode.PUBLICATION) { } // 允许多线程同时计算,只发布第一个结果
by lazy(LazyThreadSafetyMode.NONE) { }        // 无锁,仅限单线程

🔬 SynchronizedLazyImpl 的实现:

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
                }
            }
        }
}

⚠️ 代价:

  1. 一个 SynchronizedLazyImpl 对象(📌 JDK 27 紧凑头下:8 + 4(ref) + 4(ref) + padding = 24 字节)+ 一个 lambda 对象;
  2. 每次读取都要走一次 volatile 读(快路径);
  3. 初始化后 initializer 置 null,所以 lambda 可以被 GC。

🚀 热路径优化:如果确定单线程访问(如仅在 main 或单个协程里),用 LazyThreadSafetyMode.NONE 省掉 volatile 读和对象开销。但默认值的选择是对的——绝大多数 lazy 用于单例,需要线程安全。

🧪 实验 6-4:测量 lazy 的读取开销

@Benchmark fun lazyRead(): Int = lazyVal            // ~2 ns
@Benchmark fun directRead(): Int = directVal        // ~0.3 ns


6.8 智能转换(Smart Cast):精确的触发条件

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 可能延迟执行
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)              // ❌ 智能转换失效
    }
}

🚀 无法智能转换时的正确应对(按优先级):

// ❌ 最差:!!
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,这是标准库帮你在编译期做数据流分析的典范:

public inline fun requireNotNull(value: T?): T {
    contract { returns() implies (value != null) }
    return value!!
}


6.9 区间与进度(Range & Progression)

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") { }        // 字符串区间也支持

🔬 性能真相:区间不是"创建了集合"

for (i in 1..1_000_000) { }

编译器会识别这个模式并降解为普通 for 循环,不分配 IntRange 对象,不创建 Iterator:

  iconst_1
  istore_1
  ldc     #xx    // 1000000
  istore_2
loop:
  iload_1
  ... 循环体 ...
  iinc    1, 1
  iload_1
  iload_2
  if_icmple loop

⚠️ 但如果你把区间赋给变量或用非字面量形式,就可能真的分配对象:

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 边界

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:别名不是新类型

typealias UserId = String
typealias Handler = (Event) -> Unit
typealias NodeMap = MutableMap<String, MutableList<Node>>

fun find(id: UserId): User? = null

🔬 typealias 是纯编译期的:字节码里 UserId 完全消失,就是 java.lang.String。

⚠️ 所以它不提供类型安全:

typealias UserId = String
typealias OrderId = String

fun findUser(id: UserId) { }
findUser(orderId)          // ✅ 编译通过!但这是 bug

🚀 需要真正的类型区分时,用 value class:

@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 本章小结

  1. "一切皆对象"是语义层面的说法。字节码层面,Kotlin 严格遵循四条映射规则:非空基本类型在原生位置用原生类型;可空必装箱;泛型位置必装箱;Array<Int> 是 Integer[] 而 IntArray 是 int[]。
  2. 装箱有 6 个触发场景,其中最隐蔽的三个是:suspend 函数返回基本类型、println 配合字符串模板、lambda 捕获可变基本类型变量(生成 Ref.IntRef)。
  3. List<Int> 比 IntArray 慢 8 倍、占 5 倍内存——这不是 Kotlin 的问题,是 JVM 泛型擦除的必然结果。数值密集场景必须用原生数组。
  4. Kotlin 禁止隐式数字转换,这是刻意设计,能防住 Java 里最经典的一类溢出 bug。
  5. Nothing 是底部类型,让控制流分析能理解"这条路径不返回",是智能转换与 require/check 契约的基础。
  6. const val 会被内联进引用方的常量池——修改常量后调用方必须重编译(与 Java static final 同样的陷阱)。
  7. 智能转换有精确的触发条件,var 属性、自定义 getter、委托属性、跨模块 val 都不行。正确应对是"赋给局部 val",而不是 !!。
  8. 📌 紧凑对象头(JDK 27 默认)对 Integer 和数组没有收益,收益来自字段总量 ≥ 8 字节的对象(如两个 Int 字段的 Point:24 → 16 字节)。

6.12 练习题

6-1. 下面每行代码分别产生多少次装箱?

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` 取出后拆箱为 `int`) ④ **1 次**(`as? Int` 结果类型是 `Int?`,装箱) ⑤ **2 次**(`Pair`:`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()` 装箱后返回,调用方再拆箱。

规避:

  1. 批量化:返回 IntArray / List<Int> 而非单个 Int,把装箱成本摊薄;
  2. 用 Long 打包:多个小整数打包进一个 Long(仍会装箱 1 次,但省了 N 次);
  3. 避免在超高频热路径使用 suspend 返回基本类型:例如每秒百万次的计数器接口,考虑用 Channel<Int>(同样装箱)之外的方案,或直接用虚拟线程 + 阻塞 API;
  4. 等 Valhalla:value class 的扁平化能从根本上解决;
  5. 实测优先: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) int\[10\]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 字节"。

验证命令:

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 对象。

验证:

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 注解"的混乱。