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

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

本章目标

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

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 的严格条件

必须全部满足:

  1. 类型是基本类型或 String;
  2. 位于顶层 或 object / companion object 内;
  3. 用编译期可求值的表达式初始化;
  4. 不能有自定义 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 :必须显式声明返回类型。

理由有三:

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

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

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

⚠️ 代价:

  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 的读取开销

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

  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. 下面每行代码分别产生多少次装箱?

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() 装箱后返回,调用方再拆箱。

规避:

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

相关推荐
计算机毕设定制辅导-无忧学长1 小时前
《基于微信小程序健康习惯激励系统设计与实现》
java·spring boot·mysql·微信小程序·uniapp·健康习惯激励系统
雪芽蓝域zzs2 小时前
第 1 章:IDEA 创建 SpringBoot3.5.16 Maven 项目
java·maven·intellij-idea
golang学习记2 小时前
IDEA 开始换芯,发布全新AI版终端
java·人工智能·intellij-idea
vipxieliang2 小时前
ValidX 正则表达式预编译优化:Pattern 复用策略
java·spring boot·后端
Wang's Blog3 小时前
Java 项目实战:交友项目 - FastDFS 工作原理
java·大数据·交友
Wang's Blog3 小时前
Java 项目实战:交友项目 - 保存访客记录
java·开发语言·交友
liangbo73 小时前
Spring系列01:IoC 与 DI 到底解决了什么
java·spring
江湖中的阿龙3 小时前
Oracle 紧急安全警报:Java 9.1 分漏洞 CVE-2026-91403,撤销对 eMudhra 风险证书的信任
java·安全·oracle
挖掘狂人3 小时前
缓存和数据库的一致性,别停在 “延时双删”
java·后端·程序员