Kotlin与Java虚拟机十一:类与属性:构造顺序、backing field 与显式 backing field

第 11 章 类与属性:构造顺序、backing field 与显式 backing field

本章目标

  1. 精确掌握 Kotlin 对象的初始化顺序------并能解释一个真实的"字段还是 0"的 bug;
  2. 理解属性系统的三层结构:属性 / 访问器 / backing field;
  3. 掌握 📌 显式 backing field(2.3 实验 → 2.4 稳定) 的语法、五条限制、以及它与"backing property"惯用法的取舍;
  4. 吃透属性委托的约定与 provideDelegate;
  5. 搞清属性在字节码层面的所有细节(@JvmField、isXxx 命名、internal 混淆)。

11.1 类声明全解剖

kotlin 复制代码
@Serializable                                        // 注解
abstract class Repository<T : Entity>(               // 修饰符 + 类名 + 泛型约束
    protected val tableName: String,                 // 主构造器参数(同时是属性)
    private val dialect: Dialect,                    // 主构造器参数(私有属性)
    cacheSize: Int = 1000,                           // 主构造器参数(仅参数,不是属性)
) : BaseRepository<T>(tableName), Queryable by SqlQueryable() {   // 超类 + 接口 + 委托

    // ── 属性 ─────────────────────────────────
    protected val cache: MutableMap<String, T> = HashMap(cacheSize)   // 有 backing field
    val isEmpty: Boolean get() = cache.isEmpty()                      // 无 backing field(计算属性)

    // ── 初始化块 ─────────────────────────────
    init {
        require(tableName.isNotBlank()) { "table name required" }
    }

    // ── 成员函数 ─────────────────────────────
    abstract suspend fun findById(id: String): T?
    open fun evict(id: String) { cache.remove(id) }

    // ── 次构造器 ─────────────────────────────
    constructor(tableName: String) : this(tableName, PostgresDialect(), 500)

    // ── 伴生对象 ─────────────────────────────
    companion object {
        const val DEFAULT_CACHE_SIZE = 1000
        fun <T : Entity> of(table: String): Repository<T> = ...
    }
}

声明顺序的推荐规范(Kotlin 官方编码约定):

markdown 复制代码
1. 属性声明与 init 块
2. 次构造器
3. 方法声明
4. companion object

且属性声明不要与方法交错------按语义分组放在前面。


11.2 主构造器:参数 vs 属性

这是新手最容易混淆的地方:

kotlin 复制代码
class Person(name: String, val age: Int) {
    val displayName: String = name.uppercase()   // name 只是参数,可用于初始化
    // fun greet() = println(name)               // ❌ 编译错误:name 在构造器之外不可见
    fun greet() = println(displayName)           // ✅
}
写法 生成字段 生成 getter 作用域
class A(x: Int) ❌ ❌ 仅主构造器 + init 块 + 属性初始化器
class A(val x: Int) ✅ private final int x ✅ getX() 全类可见
class A(var x: Int) ✅ private int x ✅ getX() + setX() 全类可见
class A(private val x: Int) ✅ ✅ 但 private 全类可见,外部不可访问

🔬 为什么"仅参数"的形式存在 :它让你能在构造时用参数计算属性,但不保留参数本身------减少对象大小、避免暴露内部实现。

kotlin 复制代码
class Circle(radius: Double) {                 // 不存 radius,省 8 字节
    val area: Double = PI * radius * radius    // 构造时算好
    val circumference: Double = 2 * PI * radius
}

⚠️ 但如果参数被 Lambda 捕获,它会被提升为字段:

kotlin 复制代码
class Handler(name: String) {
    val callback: () -> Unit = { println(name) }    // ← name 被捕获 → 生成 private final String name 字段!
}

🧪 实验 11-1:验证字段提升

bash 复制代码
javap -p out/book/ch11/Handler.class
# private final java.lang.String name;        ← 出现了!
# private final kotlin.jvm.functions.Function0 callback;

11.3 初始化顺序:精确规则 + 一个真实的 bug

<init> 的执行顺序

kotlin 复制代码
① super() 调用(父类的 <init> 完整执行,包括父类的属性初始化与 init 块)
② 本类主构造器的属性赋值(val/var 参数 → this.field = param)
③ 本类的属性初始化器 与 init 块 ------ **严格按源码文本顺序交错执行**
④ 本类主构造器的函数体(如果有)
⑤ 次构造器:委托 this(...) 完成后,执行次构造器自己的函数体

🧪 实验 11-2:亲眼看到顺序

kotlin 复制代码
open class Base(x: Int) {
    val a = trace("Base.a", 1)
    init { trace("Base.init", 0) }
    val b = trace("Base.b", 2)
    private fun trace(s: String, v: Int): Int { println("$s = $v"); return v }
}

class Derived(x: Int) : Base(x) {
    val c = traceD("Derived.c", 3)
    init { traceD("Derived.init", 0) }
    val d = traceD("Derived.d", 4)
    private fun traceD(s: String, v: Int): Int { println("$s = $v"); return v }
}

fun main() { Derived(0) }

输出:

text 复制代码
Base.a = 1
Base.init = 0
Base.b = 2
Derived.c = 3
Derived.init = 0
Derived.d = 4

完全按文本顺序,父类先于子类。

⚠️ 真实的 bug:在构造器里调用 open 方法

kotlin 复制代码
open class Base {
    init {
        println("Base init, value = ${value()}")     // 调用虚方法!
    }
    open fun value(): Int = 0
}

class Derived : Base() {
    private val x: Int = 42
    override fun value(): Int = x
}

fun main() { Derived() }

输出:Base init, value = 0 ------ 不是 42!

🔬 原因 :Base.<init> 执行时,Derived.x 还没有被赋值 (步骤 ③ 还没轮到 Derived)。value() 通过虚分派调到 Derived.value(),读取 x,此时 x 是 int 字段的零值 0。

🚀 这是《Effective Java》第 19 条"要么为继承而设计并写文档,要么禁止继承"的经典案例。 Kotlin 的应对是默认 final ------如果 Base 不加 open,value() 就不能被重写,问题不存在。

三条防御措施:

  1. 不要在构造器/init 块里调用 open 成员;
  2. 如果必须,用模板方法 + 工厂函数:
kotlin 复制代码
class Service private constructor(val config: Config) {
    init { validate() }                      // validate 是 private final,安全
    private fun validate() { ... }

    companion object {
        fun create(raw: RawConfig): Service {
            val cfg = parse(raw)             // 所有复杂逻辑在构造之前完成
            return Service(cfg)
        }
    }
}
  1. 用 Detekt 规则拦截(ConstructorParameterNaming 之外,可自定义规则检测 init 块中的 open 调用)。

⚠️ 另一个陷阱:默认参数表达式引用未初始化的属性

kotlin 复制代码
class Config(
    val retries: Int = defaultRetries(),     // ① 默认值表达式
) {
    val timeout: Int = 30                    // ② 属性初始化器

    private fun defaultRetries() = timeout * 2   // ⚠️ 此时 timeout 还是 0!
}

println(Config().retries)      // 0,不是 60!

🔬 原因 :默认参数表达式在步骤 ②(属性赋值)之前 求值,此时 timeout 字段还是零值。

🚀 修复:

kotlin 复制代码
class Config(val timeout: Int = 30, val retries: Int = timeout * 2)   // ✅ 参数顺序保证
// 或
class Config(timeout: Int = 30) {
    val timeout: Int = timeout
    val retries: Int = timeout * 2      // ✅ 在属性初始化器里计算
}

11.4 次构造器

kotlin 复制代码
class Connection private constructor(
    val host: String,
    val port: Int,
    val timeoutMs: Int,
) {
    constructor(host: String) : this(host, 5432, 3000) {
        println("简单构造器:使用默认端口和超时")     // ← 次构造器体,最后执行
    }

    constructor(url: String) : this(parseHost(url), parsePort(url), 5000)

    companion object {
        fun fromUrl(url: String) = Connection(url)   // 工厂函数(更推荐)
    }
}

🚀 现代 Kotlin 的最佳实践是"少用次构造器,多用默认参数 + 工厂函数":

kotlin 复制代码
// ✅ 更好
class Connection(
    val host: String,
    val port: Int = 5432,
    val timeoutMs: Int = 3000,
) {
    companion object {
        fun fromUrl(url: String): Connection {
            val (h, p) = parse(url)
            return Connection(h, p, timeoutMs = 5000)
        }
    }
}

理由:

  1. 工厂函数有名字 (fromUrl 比 Connection(url) 表达力强);
  2. 工厂函数可以返回子类型或缓存实例;
  3. 默认参数比多个构造器更灵活(可以只覆盖某一个);
  4. 次构造器会让初始化顺序更难推理。

📌 JDK 25 的 JEP 513(Flexible Constructor Bodies) 让 Java 也能在 super() 之前写语句(参数校验、字段预赋值)。Kotlin 的模型不同(主构造器 + init 块 + 次构造器委托),所以这条 JEP 主要影响混编工程里 Java 侧的写法。


11.5 属性系统的三层结构

kotlin 复制代码
┌─────────────────────────────────────────────────────┐
│  属性(Property)------ Kotlin 语言层的概念               │
│  val/var name: Type                                  │
├─────────────────────────────────────────────────────┤
│  访问器(Accessors)------ getter(必有)+ setter(var)  │
│  可以自定义,可以改可见性,可以加注解                  │
├─────────────────────────────────────────────────────┤
│  backing field ------ JVM 字段(可能不存在!)            │
│  private final Type name                             │
└─────────────────────────────────────────────────────┘

什么时候没有 backing field

编译器只在"需要存储值"时才生成字段:

kotlin 复制代码
class Rectangle(val w: Int, val h: Int) {
    val area: Int get() = w * h                    // ❌ 无字段(纯计算)
    val isSquare: Boolean get() = w == h           // ❌ 无字段

    var name: String = ""                          // ✅ 有字段(默认访问器需要存储)
        set(value) { field = value.trim() }        // 用了 field → 必须有字段

    val derived: String get() = name.uppercase()   // ❌ 无字段
}

🔬 判断规则 :如果属性的访问器里用了 field 关键字,或使用了默认访问器实现,就生成 backing field;否则不生成。

field 关键字

kotlin 复制代码
var counter: Int = 0
    set(value) {
        if (value < 0) throw IllegalArgumentException("negative")
        field = value                       // ← 只能在访问器里用
        println("set to $field")
    }

⚠️ field 只在 声明该属性的类内部、且只在访问器里可用。


11.6 📌 显式 backing field(2.3 实验 → 2.4 稳定)

它解决的长期痛点

Kotlin 有个经典的"backing property"惯用法,写起来很啰嗦:

kotlin 复制代码
// 旧写法:暴露只读视图,内部可变
class ShoppingCart {
    private val _items = mutableListOf<String>()      // ① 私有可变字段
    val items: List<String> get() = _items            // ② 公开只读属性 + 手写 getter

    fun addItem(item: String) { _items.add(item) }    // ③ 内部用 _items
}

三个问题:

  1. 需要两个声明(_items 和 items);
  2. _items 的命名约定只是惯例,不是语言保证;
  3. get() = _items 每次访问都走一次方法调用(虽然会被内联)。

新写法

kotlin 复制代码
class ShoppingCart {
    val items: List<String>
        field = mutableListOf()          // ← 显式 backing field

    fun addItem(item: String) {
        items.add(item)                  // ✅ 类内部:items 被智能转换为 MutableList<String>
    }
    fun removeItem(item: String) {
        items.remove(item)               // ✅
    }
}

也可以显式声明 backing field 的类型:

kotlin 复制代码
val items: List<String>
    field: MutableList<String> = mutableListOf()

🔬 语义:

  • 在声明类内部 ,items 解析为 backing field 的类型 (MutableList<String>),所以能调用 add/remove;
  • 在类外部 ,items 是属性声明的类型 (List<String>),只能读;
  • 只有一个声明、一个字段、一个 getter。

🧪 实验 11-3:看字节码

bash 复制代码
kotlinc Cart.kt -d out && javap -p -c out/ShoppingCart.class
text 复制代码
public final class ShoppingCart {
  private final java.util.List items;              // backing field(类型是 List,实例是 ArrayList)
  public final java.util.List getItems();          // getter
  public ShoppingCart();
  public final void addItem(java.lang.String);
  public final void removeItem(java.lang.String);
}

对比旧写法,少了一个 _items 字段 ,且 addItem 里直接 getfield items 而非 getfield _items。

五条限制(务必记住)

📌 属性只有在满足以下全部条件时才能有显式 backing field:

# 限制 原因
1 不能有自定义 getter field = ... 本身就替代了 getter 的存储职责
2 必须是 val(只读) 可变属性的 setter 语义与"内部可变、外部只读"冲突
3 不能是 open 子类可能重写 getter,破坏类型收窄的假设
4 不能是委托属性 (by) 委托属性没有自己的字段
5 不能是 const val 编译期常量已被内联,无字段可言

另外两条对 backing field 本身的约束:

  • backing field 的类型必须是属性类型的子类型 (MutableList<String> <: List<String> ✅);
  • backing field 必须是 private 可见性(由编译器保证,你无法改)。

什么时候仍然用"backing property"

当违反任一限制时(最常见的是需要自定义 getter):

kotlin 复制代码
class UserDirectory {
    private val _users = mutableListOf("sarah", "mike")

    val users: List<String>
        get() = _users.sorted()            // ← 自定义 getter:每次返回排序后的副本

    fun addUser(name: String) { _users.add(name) }
}

🚀 决策规则:

  • 需要"内部可变 + 外部只读",且不需要转换 → 显式 backing field(更简洁);
  • 需要在读取时做转换/校验/缓存/日志 → backing property (_x + 自定义 getter);
  • 需要惰性计算 → by lazy;
  • 需要变更通知 → Delegates.observable。

⚠️ 注意显式 backing field 的一个微妙之处 :因为外部拿到的是同一个可变对象的只读视图,所以:

kotlin 复制代码
val cart = ShoppingCart()
val items: List<String> = cart.items
cart.addItem("Apple")
println(items)        // [Apple] ← items 也变了!因为它是同一个 ArrayList 的视图

这不是不可变,只是"只读接口"。 如果需要真正的不可变快照,getter 必须复制:

kotlin 复制代码
val items: List<String> get() = _items.toList()     // 每次复制,O(n)

👉 这与第 1 章讲的 Collections.unmodifiableList 是同一个道理:"只读"和"不可变"是两件事。


11.7 const val 编译期常量

kotlin 复制代码
const val MAX_LOGIN_ATTEMPTS = 3                 // 顶层
object Config { const val VERSION = "1.0" }      // object 内
class S { companion object { const val X = 1 } } // companion 内

三个条件:

  1. 位于顶层 / object / companion object;
  2. 类型是 String 或基本类型;
  3. 不能有自定义 getter。

🔬 字节码 :public static final 字段 + ConstantValue 属性。

📌 官方文档明确指出:编译期常量仍然有 backing field,因此可以通过反射访问。 但引用方不会读这个字段------值已被内联进引用方的常量池。

⚠️ 升级陷阱(第 6 章已提):

kotlin 复制代码
// 库 v1
object Limits { const val MAX = 100 }
// 使用方(已编译)
val x = Limits.MAX        // 字节码:sipush 100   ← 值被内联,没有 getstatic!

// 库 v2:MAX 改成 200
// 使用方不重新编译 → 仍然是 100 💥

🚀 库设计准则 :公开的可调参数用普通 val(走 getter),真正的数学/协议常量才用 const val。

kotlin 复制代码
object Limits {
    const val HTTP_OK = 200            // ✅ 永不变
    val maxRetries = 3                 // ✅ 走 getter,可热更新
}

注解参数必须用 const val(因为注解值必须是编译期常量):

kotlin 复制代码
const val SUBSYSTEM_DEPRECATED = "This subsystem is deprecated"

@Deprecated(SUBSYSTEM_DEPRECATED)
fun processLegacyOrders() { }

11.8 lateinit 完整规则

可以用在哪里

  • ✅ 顶层属性
  • ✅ 局部变量(📌 1.2+)
  • ✅ 类体中的属性

不能用的场景

  • ❌ 主构造器参数(class A(lateinit var x: String) 非法)
  • ❌ 有自定义 getter/setter 的属性
  • ❌ 可空类型(lateinit var x: String? 非法)
  • ❌ 基本类型 (Int/Long/Boolean/Char/Float/Double/Short/Byte)→ 用 Delegates.notNull<Int>()

isInitialized 的限制

kotlin 复制代码
class WeatherStation {
    lateinit var latestReading: String

    fun printReading() {
        if (this::latestReading.isInitialized) println(latestReading)
        else println("No reading available")
    }
}

📌 只能在"能访问该属性"的地方用 isInitialized :同类、外层类、或同文件的顶层属性。不能检查别的类的 lateinit 属性(因为它编译成 private 字段 + public getter,无法从外部访问字段)。

⚠️ isInitialized 的实现是反射 (读取字段的 null 状态),所以不要在热路径频繁调用。

🔬 字节码与线程安全

kotlin 复制代码
lateinit var view: View
text 复制代码
private View view;                          // 普通字段,初始 null
public final View getView() {
    View v = this.view;
    if (v == null) Intrinsics.throwUninitializedPropertyAccessException("view");
    return v;
}
public final void setView(View v) {
    Intrinsics.checkNotNullParameter(v, "<set-?>");
    this.view = v;
}

⚠️ 字段不是 volatile:

  • 线程 A 调用 setView,线程 B 调用 getView;
  • 没有 happens-before 关系(除非有其他同步);
  • B 可能读到 null → 抛 UninitializedPropertyAccessException;
  • 更糟:B 可能读到一个半构造的 View (如果 View 的字段不是 final)。

🚀 多线程初始化请用 by lazy(默认 SYNCHRONIZED 带 volatile) ,或者显式用 @Volatile:

kotlin 复制代码
@Volatile lateinit var config: Config    // ✅ 但注意 lateinit + volatile 的组合在某些版本有限制,需实测

11.9 属性委托:约定与 provideDelegate

三个约定

kotlin 复制代码
class Delegate {
    // ① getValue:读
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String = ...

    // ② setValue:写(var 才需要)
    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) { ... }

    // ③ provideDelegate:在委托对象创建时介入(可选)
    operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty<Any?, String> = ...
}

val x: String by Delegate()

🔬 by 的编译结果:

text 复制代码
private final Delegate x$delegate;          // 存的是委托对象,不是值!

public final String getX() {
    return x$delegate.getValue(this, /* KProperty */ Reflection.property(...));
}

⚠️ 每次读取属性都调用 getValue ,并传一个 KProperty 对象(通常是缓存的单例,但仍有方法调用开销)。

provideDelegate 的价值

它让你在委托创建时做校验,而不是在每次读写时:

kotlin 复制代码
class NotNullVar<T : Any>(private var value: T?) {
    operator fun getValue(thisRef: Any?, prop: KProperty<*>) =
        value ?: throw IllegalStateException("${prop.name} not initialized")
    operator fun setValue(thisRef: Any?, prop: KProperty<*>, v: T) { value = v }
}

class Validator {
    operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): NotNullVar<String> {
        // 在对象构造时就检查注解、名字约定等
        require(prop.name.startsWith("_")) { "delegated property must start with _" }
        return NotNullVar(null)
    }
}

标准委托的实现剖析

kotlin 复制代码
// Delegates.notNull
public fun <T : Any> notNull(): ReadWriteProperty<Any?, T> = NotNullVar()

private class NotNullVar<T : Any>() : ReadWriteProperty<Any?, T> {
    private var value: T? = null
    override fun getValue(thisRef: Any?, property: KProperty<*>): T =
        value ?: throw IllegalStateException("Property ${property.name} should be initialized before get.")
    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { this.value = value }
}

// Delegates.observable
public inline fun <T> observable(initialValue: T, crossinline onChange: (prop, old, new) -> Unit)
    : ReadWriteProperty<Any?, T> = object : ObservableProperty<T>(initialValue) {
        override fun afterChange(property: KProperty<*>, oldValue: T, newValue: T) =
            onChange(property, oldValue, newValue)
    }

// Delegates.vetoable ------ beforeChange 返回 false 则拒绝赋值
// Delegates.map ------ 从 Map 读取属性值(用于动态配置)

Delegates.map 的妙用:

kotlin 复制代码
class Config(map: Map<String, Any?>) {
    val host: String by map
    val port: Int by map
    val debug: Boolean by map
}

val c = Config(mapOf("host" to "localhost", "port" to 8080, "debug" to false))

⚠️ key 缺失时抛 NoSuchElementException;类型不匹配时抛 ClassCastException。

自定义委托实战:一次性初始化 + 清空引用

kotlin 复制代码
/** 读取一次后清空引用,帮助 GC 回收大对象 */
class ConsumeOnce<T : Any>(initial: T?) {
    private var value: T? = initial
    operator fun getValue(thisRef: Any?, prop: KProperty<*>): T =
        value?.also { value = null } ?: throw IllegalStateException("${prop.name} already consumed")
}

class Parser(input: String) {
    private val rawInput by ConsumeOnce(input)      // 大字符串,用完就放
    fun parse(): Ast { val src = rawInput; ... }    // 之后 rawInput 的引用被清空
}

11.10 可见性修饰符

修饰符 类成员 顶层声明
public(默认) 到处可见 到处可见
private 类内(含成员) 同文件内
protected 类内 + 子类 ❌ 不可用
internal 同模块内可见 同模块内可见

⚠️ Kotlin 没有 package-private (Java 的默认可见性)。这是有意的:包结构应该是逻辑分组 ,不应该是访问控制边界(Java 里"为了访问而把类放同一个包"是常见的坏味道)。

🔬 internal 的字节码真相(第 3 章已提,这里给证据):

kotlin 复制代码
// module name = "ch11-classes"
class Widget {
    internal fun helper() = 42
    internal val state = 0
}
text 复制代码
public final class Widget {
  private final int state;
  public final int helper$ch11_classes();        // ← public + 名称混淆!
  public final int getState$ch11_classes();      // ← getter 也混淆
  public Widget();
}

⚠️ 四个后果:

  1. Java 代码可以调用 helper$ch11_classes()(IDE 会警告);
  2. 反射可以访问(因为是 public);
  3. 改 Gradle 模块名会破坏二进制兼容(方法名里嵌了模块名);
  4. internal 不是真正的封装,只是"编译器 + IDE 的提示"。

🚀 需要真正封装时 :用 private + 明确的公开 API;或者用 JPMS 模块(module-info.java)。

访问器的可见性

kotlin 复制代码
class BankAccount(initial: Int) {
    var balance: Int = initial
        private set                     // 外部只读,内部可写

    fun deposit(amount: Int) { if (amount > 0) balance += amount }
}

⚠️ 访问器的可见性不能比属性更宽:

kotlin 复制代码
private var x = 0
    public set              // ❌ 编译错误

11.11 嵌套类与内部类

kotlin 复制代码
class Outer {
    private val secret = 42

    class Nested {                        // 静态嵌套类(不持有 Outer 引用)
        // fun f() = secret               // ❌ 无法访问 Outer 的成员
        fun g() = "nested"
    }

    inner class Inner {                   // 内部类(持有 Outer 引用)
        fun f() = secret                  // ✅ 可以访问
    }
}

Outer.Nested()                            // 直接构造
Outer().Inner()                           // 需要外部实例

🔬 字节码差异:

text 复制代码
public final class Outer$Nested { ... }                    // 无 outer 字段
public final class Outer$Inner {
    final Outer this$0;                                    // ← 持有外部引用!
    public Outer$Inner(Outer outer) { this.this$0 = outer; }
}

⚠️ inner class 的内存泄漏风险:它持有外部类引用,如果内部类实例被长期持有(如注册到全局监听器、放进静态集合),外部类就无法被 GC。

🚀 准则 :默认用 class(嵌套,静态) ,只有真的需要访问外部实例时才用 inner。这与 Java 的默认相反(Java 的内部类默认持有外部引用,静态嵌套类要写 static)------Kotlin 的默认更安全。

Android 上的经典案例:Handler 写成非静态内部类 → Activity 泄漏。


11.12 属性在 JVM 上的表示与 Java 互操作

命名规则

Kotlin 属性 Java 侧看到的 getter setter
val name: String getName() ---
var name: String getName() setName(String)
val isActive: Boolean isActive() ---
var isActive: Boolean isActive() setActive(boolean) ⚠️
val isEmpty: Boolean isEmpty() ---

⚠️ is 前缀的特殊规则 :以 is 开头的 Boolean 属性,getter 不加 get 前缀,setter 把 is 换成 set。所以 isActive → isActive() / setActive()。

🚀 这个不对称会导致 Java 侧的困惑 (bean.isActive() 但 bean.setActive(true)),也是某些 JavaBean 内省工具出问题的原因。建议 Boolean 属性避免用 is 前缀,或者接受这个约定。

@JvmField:暴露为字段

kotlin 复制代码
class Config {
    @JvmField val maxRetries = 3          // 直接暴露 public 字段,无 getter
}
java 复制代码
// Java
int n = config.maxRetries;                // 直接字段访问

⚠️ @JvmField 的约束 :属性必须有 backing field、不能是 private(否则没意义)、不能有自定义访问器、不能是 open/override/const/lateinit。

🚀 何时用:

  • 与 Java 库互操作且对方期望字段(如某些框架的配置类);
  • 极致性能场景(省一次方法调用,但 JIT 通常已经内联了 getter,收益微乎其微)。

一般不要用------它放弃了属性访问器的未来扩展能力(比如以后想加校验,就得改字段为属性 → 二进制不兼容)。

顶层属性

kotlin 复制代码
// file: Config.kt, package book.ch11
val appName = "Book"
fun version() = "1.0"
text 复制代码
public final class book.ch11.ConfigKt {
    private static final String appName = "Book";
    public static final String getAppName();
    public static final String version();
}
java 复制代码
// Java
String n = ConfigKt.getAppName();

📌 用 @file:JvmName("AppConfig") 改类名:

kotlin 复制代码
@file:JvmName("AppConfig")
package book.ch11
val appName = "Book"
java 复制代码
String n = AppConfig.getAppName();      // 更专业

🚀 所有暴露给 Java 的 Kotlin 文件都应该加 @file:JvmName ,否则 XxxKt 这种名字很难看且难猜。


11.13 本章小结

  1. 初始化顺序 :super() → 主构造器属性赋值 → 属性初始化器与 init 块按文本顺序交错 → 主构造器体 → 次构造器体。
  2. 两个真实 bug :① 构造器里调用 open 方法 → 读到子类未初始化的零值字段;② 默认参数表达式引用尚未初始化的属性 → 得到 0。Kotlin 默认 final 恰好防住了 ①。
  3. 主构造器参数 x: Int 与属性 val x: Int 完全不同:前者只在构造期可见(除非被 Lambda 捕获而提升为字段)。
  4. 属性 = 属性声明 + 访问器 + 可选的 backing field。计算属性没有字段。
  5. 📌 显式 backing field(2.4 稳定) 用一行 field = mutableListOf() 取代了 _items + get() = _items 的三行惯用法;但只读接口 ≠ 不可变对象 (外部拿到的仍是同一个 ArrayList 的视图)。五条限制 :无自定义 getter、必须 val、非 open、非委托、非 const。
  6. const val 会被内联进引用方常量池 → 库的公开可调参数应该用普通 val。
  7. lateinit 不支持基本类型、非 volatile、isInitialized 只能在可访问该属性的地方用 。多线程初始化优先 by lazy。
  8. 属性委托存的是委托对象而非值 ,每次读都调 getValue(有开销);provideDelegate 让你在创建时校验。
  9. internal 编译为 public + 名称混淆(name$moduleName),不是真封装,且改模块名破坏二进制兼容。
  10. 嵌套类默认是静态的(与 Java 相反),这个默认更安全(避免内存泄漏)。

11.14 练习题

11-1. 下面代码输出什么?为什么?

kotlin 复制代码
open class A {
    open val x: Int = 1
    init { println("A.init x=$x") }
}
class B : A() {
    override val x: Int = 2
    init { println("B.init x=$x") }
}
fun main() { B() }

答案要点

text 复制代码
A.init x=1
B.init x=2

分析:

  1. B() 触发 A.<init>;
  2. A.<init> 执行 this.x = 1(对 A.x 的字段赋值)------⚠️ 注意:A.x 被 override 成属性访问器,实际上 A 的初始化会调用 setX?不,A 的 x 有 backing field,override val x 在 B 中也有自己的 backing field。这是两个不同的字段!
  3. A 的 init 块读 x → 走 getter → 虚分派到 B.getX() → 读 B.x 字段 → 此时还是 0?

🔬 实际输出是 A.init x=1 ,因为 Kotlin 对 open val x = 1 的处理是:A 里生成字段 x 和 getter getX();B 里生成字段 x 和 override 的 getX() 返回 B.x。A.init 里的 x 读取会调用 getX()(虚方法)→ 返回 B.x = 0。

⚠️ 所以真实输出应该是:

text 复制代码
A.init x=0        ← B.x 还没初始化!
B.init x=2

这正是 11.3 讲的"构造器里访问 open 成员"陷阱的属性版本。

教训 :不要在 init 块或属性初始化器里读取 open 属性。修复方案:把 A 的 x 改成 final(去掉 open),或者把依赖 x 的逻辑移到工厂函数/延迟执行。

这个练习的价值在于:你以为你懂了初始化顺序,但属性 override 会引入虚分派,让"文本顺序"的直觉失效。请务必亲手跑一遍验证。

11-2. 用显式 backing field 重写下面的类,并指出重写后语义上的一个变化。

kotlin 复制代码
class Registry {
    private val _entries = mutableMapOf<String, Entry>()
    val entries: Map<String, Entry> get() = _entries
    fun register(name: String, e: Entry) { _entries[name] = e }
}

答案要点

kotlin 复制代码
class Registry {
    val entries: Map<String, Entry>
        field = mutableMapOf()

    fun register(name: String, e: Entry) { entries[name] = e }
}

语义变化:

  1. 字段数量 :从 2 个(_entries 和 entries 其实是同一个字段?不------旧写法只有 _entries 一个字段,entries 是纯 getter)变成 1 个(entries 的 backing field)。所以内存占用相同(都是一个引用字段)。
  2. 访问路径 :旧写法 registry.entries 走 getter 返回 _entries;新写法 getter 返回 backing field。行为等价。
  3. 类内部的可变性 :旧写法必须用 _entries[name] = e;新写法可以直接 entries[name] = e(编译器智能转换为 MutableMap)。这是最大的可读性收益。
  4. ⚠️ 两者都不是不可变 :外部拿到的 Map 是同一个 LinkedHashMap 的只读视图,可以强转为 MutableMap 修改(第 1/16 章的陷阱)。

如果需要真不可变 :val entries: Map<String, Entry> get() = _entries.toMap()(每次复制,O(n)),或用 backing property 模式。

11-3. 为什么显式 backing field 不允许自定义 getter?如果我想"内部可变 + 读取时排序",应该怎么做?
答案要点

为什么不允许 :显式 backing field 的核心语义是"类内部访问该属性名时,解析为 backing field 的更具体类型 "(一种编译器智能转换)。如果同时存在自定义 getter,就产生了歧义:类内部的 items 到底走 getter 还是直接读字段?两者可能返回不同的值(getter 可能做了转换/复制)。Kotlin 选择禁止这种组合以保持语义清晰。

替代方案(backing property):

kotlin 复制代码
class UserDirectory {
    private val _users = mutableListOf<String>()
    val users: List<String> get() = _users.sorted()      // 读取时排序
    fun addUser(name: String) { _users.add(name) }
}

优化:如果排序很频繁且列表变化少,加缓存:

kotlin 复制代码
private var sortedCache: List<String>? = null
val users: List<String>
    get() = sortedCache ?: _users.sorted().also { sortedCache = it }
fun addUser(name: String) { _users.add(name); sortedCache = null }

⚠️ 这个缓存不是线程安全的,需要 @Volatile 或 synchronized。

11-4. (🧪 动手)验证 lateinit 在多线程下的可见性问题。设计一个能大概率复现失败的实验。
答案要点

kotlin 复制代码
class Holder { lateinit var payload: BigObject }
class BigObject { val data = LongArray(1_000_000) { it } }

fun main() {
    repeat(1000) { round ->
        val h = Holder()
        var failed = false
        val writer = thread { h.payload = BigObject() }
        val reader = thread {
            try { if (h::payload.isInitialized) h.payload.data[999_999] }
            catch (e: Throwable) { failed = true }
        }
        writer.join(); reader.join()
        if (failed) println("round $round failed")
    }
}

说明 :由于 x86 的强内存模型和 JIT 的行为,这个实验不一定能复现------这正是并发 bug 可怕的地方(在开发机上"没问题",在生产环境的 ARM/高负载下出问题)。

理论分析:

  • lateinit 字段是普通字段,写入没有 release 语义,读取没有 acquire 语义;
  • 根据 JMM,reader 线程可能:① 看到 null(抛 UninitializedPropertyAccessException);② 看到非 null 引用但对象的字段未初始化(读到 data 为 null 或数组元素为 0);
  • 情况 ② 之所以不会发生在 final 字段上,是因为 JMM 对 final 有安全发布保证(第 30 章)。

正确做法 :by lazy(内部 @Volatile + 双检查)、或 @Volatile 字段 + 手动 null 检查、或用不可变对象 + AtomicReference。

11-5. const val 和普通顶层 val 在字节码层面有什么区别?各自的适用场景与风险?
答案要点

const val X = 5 val x = 5(顶层)
字段 public static final int X + ConstantValue 属性 private static final int x(无 ConstantValue)
访问器 ❌ 无 ✅ getX() 静态方法
引用方字节码 值被内联 (iconst_5),无 getstatic/invokestatic invokestatic getX()
性能 最快(零调用) JIT 内联后同样快
升级风险 ⚠️ 改值后引用方必须重新编译 ✅ 改值后引用方无需重编译(JIT 会去优化并重编译)
可用于注解参数 ✅ ❌
支持类型 仅 String 和基本类型 任意
反射可见 ✅(有 backing field) ✅

适用场景:

  • const val:协议常量、数学常数、注解参数、真正的编译期已知值;
  • 普通 val:库的公开可调参数、运行时计算的值、需要未来能改的配置。

库设计准则 :公开 API 中的"可能变化的值"一律用普通 val,避免升级时的隐形破坏。


11.15 面试题精选

Q1:Kotlin 的属性和 Java 的字段 + getter/setter 有什么区别?

答题框架:

  1. 语法层 :属性是一等概念,访问用 obj.x 而非 obj.getX();
  2. 实现层:属性 = 可选的 backing field + getter(必有)+ setter(var);可以只有 getter(计算属性,无字段);
  3. 能力 :可以自定义访问器、改访问器可见性(private set)、加注解到访问器、用委托(by)、📌 用显式 backing field(2.4+)实现"内部可变外部只读";
  4. 互操作 :Kotlin 属性生成符合 JavaBean 规范的 getter/setter(is 前缀有特殊规则);@JvmField 可直接暴露字段;Java 的 getter/setter 在 Kotlin 里被合成为属性访问;
  5. 风险 :const val 内联导致升级陷阱;internal 属性名混淆;lateinit 非 volatile。

Q2:Kotlin 对象的初始化顺序是什么?有什么陷阱?

答题框架 :见 11.3。核心是"super() → 构造器属性 → 初始化器与 init 块按文本顺序 → 构造器体",加两个陷阱(open 成员调用、默认参数引用未初始化属性),加防御措施(默认 final、工厂函数、参数顺序)。

Q3:lateinit 和 by lazy 怎么选?

见第 6 章 Q3 + 11.8。补充:lateinit 不支持基本类型与可空类型、非 volatile、可用 isInitialized 检查(但有作用域限制);lazy 默认 SYNCHRONIZED(volatile + 双检查),有三种模式可调,会多分配一个 Lazy 对象。

Q4:什么是显式 backing field?它解决了什么问题?有什么限制?

答题框架:

  • 是什么 :📌 Kotlin 2.3 实验、2.4 稳定的语法,val items: List<String> + field = mutableListOf(),让属性的 backing field 类型可以比属性声明类型更具体。
  • 解决什么 :取代 private val _items = mutableListOf() + val items: List<String> get() = _items 的三行惯用法;且类内部访问该属性名会被智能转换为 backing field 类型,可以直接调用可变方法。
  • 五条限制 :无自定义 getter、必须 val、非 open、非委托属性、非 const;backing field 类型必须是属性类型的子类型且 private。
  • 注意 :它提供的是"只读接口",不是不可变------外部拿到的仍是同一个可变对象。
  • 替代 :需要自定义 getter 时用 backing property(_x 模式)。

下一章预告 :第 12 章《特殊类家族》------data/sealed/value/enum/object/companion。我们会讲:data class 的 equals 为什么不看父类字段、value class 什么时候会装箱(以及那个会让 Java 调用方崩溃的名称混淆 )、sealed 为什么比 enum 更适合演化、object 的线程安全保证来自哪一行字节码。

相关推荐
终码一生11 分钟前
IntelliJ IDEA 2026.3 发布:智能体住进 IDE
后端
Gopher_HBo11 分钟前
profbuf.go —— 单写单读无锁环形缓冲
后端
aiks_yuan13 分钟前
40亿个QQ号 1G内存怎么去重:Java BitSet 装不下,long 数组位图 512MB 实测 46 秒(JDK 17)
java
文丑颜不良啊29 分钟前
LeetCode2521~2540题目解析
java·leetcode
杨凯凡30 分钟前
Spring Framework 7 HTTP Interface 实战:声明式 RestClient、超时与测试
java·spring·http
Oracle恢复实录37 分钟前
YashanDB → Oracle DBLink:PL/SQL 变量日期条件未下推
后端
传奇开心果40 分钟前
【探秘 Jetpack Compose 框架深水区】 第8课:rememberSaveable 的边界在哪里?——Bundle、Saver 与什么值得跨进程存活
android·kotlin
橙序员小站44 分钟前
插件发布3天,下载破千:我把公众号创作接进了DSH
后端
吴名氏.1 小时前
Java转AI Agent教程(第十二周)检索与切分
java·开发语言·人工智能·ai·ai agent