ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Kotlin object是懒汉还是饿汉?从JVM类初始化机制彻底讲清

Kotlin object是懒汉还是饿汉?从JVM类初始化机制彻底讲清 这几年面了不少候选人几乎每次聊到 Kotlin 单例都会撞上同一个问题“Kotlin 的object到底是懒汉还是饿汉”答案五花八门有人斩钉截铁说“饿汉”因为object在被访问时总会初始化实例也有人坚持说“懒汉”因为它不像 Java 静态块那样在类加载时就创建。两种说法都拿得出“依据”但真正继续追问下去——对象的实例是什么时候创建的字节码长什么样多线程下靠什么保证唯一——能答上来的人少之又少。object的懒饿之争本质是一个伪命题。这个概念本身是 Java 单例模式的二分法放到 Kotlin 的object上既对也不对。八股文该更新了不是要更新答案而是要更新提问方式别再问它是懒汉还是饿汉问题应该是“object的初始化时机由什么决定它在 JVM 层面到底做了什么”。这篇文章就把这件事彻底拆开讲清楚。1. 这个面试题本身就问错了从“懒汉/饿汉”的二分法说起1.1 懒汉和饿汉到底在区分什么经典单例模式的“懒汉”和“饿汉”区分的是实例创建的时间点。饿汉单例是类初始化时就把实例创建好典型写法public class Singleton { private static final Singleton INSTANCE new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } }懒汉单例是第一次调用getInstance()时才创建public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }两者的核心差异一个是“类初始化的一瞬间就 new”一个是“等到真正被使用才 new”。问题在于Java 的“类初始化”本身并不是一个字符串常量它是被 JVM 按需触发的。你自己写一个类类里写一百个静态字段只要没人主动访问它JVM 根本不会初始化这个类。所以严格来说饿汉单例也不是进程启动就创建而是“类被主动使用的那一刻”创建。这个“主动使用”的时机恰恰是后续讨论的起点。1.2 为什么直接套到 Kotlin object 上会卡壳Kotlin 的object编译后是一个类这个类是确定无疑的。它有一个静态字段INSTANCE构造器私有静态代码块中完成实例化。从字节码结构上看它非常接近 Java 饿汉单例。但“接近”不等于“就是”。Kotlinobject的初始化不是通过你手动调getInstance()来触发而是通过访问这个类的静态成员来触发。你写Singleton.doWork()在字节码层面其实是Singleton.INSTANCE.doWork()也就是读取了一个静态字段。读取静态字段就是“主动使用”于是触发类初始化实例随即创建。这里出现了第一个容易混淆的点从调用者视角看确实是在第一次使用object时才创建实例这看起来像“懒”从 JVM 视角看一旦类被初始化实例立刻创建这看起来又像“饿”。所以双方都能找到证据谁也说服不了谁。把“懒汉”“饿汉”直接套在object上等于用一把 20 年前的尺子量今天的新工具自然会卡壳。接下来直接看字节码这是最硬的证据。2. 反编译 object 的字节码它到底编译成了什么2.1 源码与反编译结果对照先写一个最简单的objectobject Singleton { val name: String singleton fun doWork() { println(working) } }用javap -p -c Singleton.class反编译去掉无关细节核心结构是这样的public final class Singleton { private static final String name; public static final Singleton INSTANCE; private Singleton() { name singleton; } static { Singleton instance new Singleton(); INSTANCE instance; name singleton; } public final String getName() { return name; } public final void doWork() { System.out.println(working); } }几个关键点INSTANCE是public static final外部可以直接访问不需要额外的getInstance()方法。构造函数是私有的杜绝外部new。实例是在静态代码块里创建的这个静态代码块经过编译后就是clinit方法属于“类初始化阶段”的执行内容。对比 Java 的饿汉单例结构几乎同源static final字段 类初始化时赋值。所以单看静态结构说object是饿汉并没有错。2.2 与手动编写懒汉单例的字节码差异再写一个 Kotlin 手动实现的懒汉单例class LazySingleton private constructor() { companion object { Volatile private var instance: LazySingleton? null fun getInstance(): LazySingleton { val current instance if (current ! null) { return current } synchronized(this) { val checkAgain instance if (checkAgain ! null) { return checkAgain } val created LazySingleton() instance created return created } } } }这个类的字节码里有synchronized指令有if分支有两次getstatic读取instance字段的判断还有volatile字段的访问屏障。逻辑明显比object复杂得多。而object的字节码里没有任何同步相关的指令。它靠的是 JVM 自身的类初始化机制。这就延伸到下一个关键点object的线程安全不是靠我们写的锁而是 ClassLoader 在初始化clinit时自动加锁。JVM 规范保证同一个类在同一进程中clinit方法只会被一个线程执行。多个线程同时触发类初始化时只有一个线程真正执行静态代码块其余线程阻塞等待。正因为这个机制object的实例化过程既不需要synchronized也不需要Double-Check。这比手工写的 DCL 更省心也更不容易出错。3. 真正的重点类初始化时机与被误认为“饿”的细节3.1 JVM 什么情况下会触发类初始化类加载其实分两段第一个阶段是“加载”就是把.class文件读进内存生成Class对象第二个阶段是“初始化”就是执行clinit。大部分讨论里混用的“类加载”精确说都应该是“初始化”。懒汉和饿汉之争较量的是初始化时点不是加载时点。《Java 虚拟机规范》明确列出了触发初始化的场景也就是“主动使用”new创建实例或者读取/写一个非常量的静态字段或者调用静态方法通过反射访问类、方法、字段初始化子类时父类尚未初始化会先触发父类初始化main方法所在的类会被初始化MethodHandle解析到静态成员相关的句柄时也会触发初始化。反过来不触发初始化的操作同样重要。比如访问static final的编译期常量JVM 会把值直接内联进调用方代码根本不会去碰那个类。再比如Class.forName(xxx, false, classLoader)第二个参数传false只加载不初始化。对于 Kotlinobject通过Singleton.doWork()调用方法时字节码会读取Singleton.INSTANCE触发初始化通过Singleton.name读取属性时同样会读取INSTANCE触发初始化从 Java 代码里访问Singleton.INSTANCE也一样触发初始化但如果 Kotlin 侧声明了const val VERSION 1.0外部访问Singleton.VERSION时编译期就内联了不会触发类初始化实例也不会创建。所以“饿汉”这个说法精确地讲应该改成object 的实例在类初始化的瞬间被创建而类初始化本身是 lazy 的。它既不是进程启动时的饿汉也不是第一次调用getInstance()时的懒汉而是“第一次真正触碰这个类时才触发的一次性初始化”。3.2 顶层 object 与嵌套 object 的初始化差异还有一个很容易被忽略的细节object的位置会影响初始化时机。如果是一个顶层object它会被编译为独立的类Singleton.class。如果它没有直接被使用整个进程都不会初始化它。这时候它“懒”得很彻底。如果是一个嵌套在类内部的objectclass Outer { object Inner { val x 1 } }Inner会编译成一个静态嵌套类Outer$Inner.class。关键是初始化Outer类时不会初始化Outer$Inner。即使你实例化了Outer()Inner也还是未初始化的状态。只有当你第一次访问Outer.Inner时Outer$Inner才会开始初始化进而创建Inner的实例。这意味着嵌套object的“懒”层次更分明外部类怎么折腾都不影响它直到被点名访问才出场。这在做局部模块化单例时很有用比如把某个依赖仓库作为内部类挂在外层容器下容器本身随便创建仓库单例却只在真正用到时初始化。3.3 用一个日志实验验证初始化时机理论讲多了不如直接跑一个实验。写一段 Kotlin 代码object LoggerHolder { init { println(LoggerHolder initialized) } } class Service { companion object { init { println(Service.Companion initialized) } } } fun main() { println(main start) println(access Service.Companion: ${Service}) println(access LoggerHolder: ${LoggerHolder}) }运行结果main start Service.Companion initialized access Service.Companion: Service.Companion... LoggerHolder initialized access LoggerHolder: LoggerHolder...可以看到main方法不会触发任何一个object的初始化只有真正访问它们时init块才执行。这就是“JVM 类初始化按需触发”的直接证据。你在面试里如果能讲到这一层至少说明你是实打实理解object的机制而不是背了一个“懒汉/饿汉”的结论。4. 比懒饿之争更值得讨论的线程安全、初始化依赖与单例变体4.1 object 的线程安全与初始化死锁object自身是线程安全的这个安全是由 JVM 类初始化机制兜底。但有一种情况值得警惕两个object在初始化阶段互相依赖。举个例子object A { init { println(A init) B.b } val a 1 } object B { init { println(B init) A.a } val b 2 } fun main() { println(A.a) }执行时main访问A.a触发A初始化A的init块里访问B.b触发B初始化B的init块里又回头访问A.a。此时A的clinit还没执行完JVM 正在持有A的初始化锁。当B尝试访问A.a时线程会发现A正在被另一个线程其实是同一个线程初始化吗不会。JVM 检测到当前线程已经持有A的初始化锁如果是同一个线程会放行继续执行但这时A的字段可能还没有完成赋值。这种“循环初始化”轻则产生空值、奇怪状态严重时可能直接影响业务逻辑。实际项目里object的init块中尽量避免直接访问另一个object的属性尤其是两个object初始化路径相互引用时。如果确实存在依赖关系更安全的做法是把公共依赖拆到第三个object让它们的初始化顺序变成单向的。4.2 单例变体by lazy、伴生对象持有、Holder 模式object是 Kotlin 中最直接的单例但并不是唯一的实现方式。业务场景不同还需要掌握其他变体。懒加载真正的“懒”by lazyclass HeavyService private constructor() { companion object { val instance: HeavyService by lazy { HeavyService() } } }by lazy的默认模式是LazyThreadSafetyMode.SYNCHRONIZED底层实现类似 DCL用同步锁保证线程安全而且把初始化点延迟到instance第一次被取值的那一刻。对比objectby lazy更接近传统“懒汉”的定义类本身已经初始化但实例延迟到属性首次访问。不过要分清楚一个概念object是“类初始化时创建实例”by lazy是“类初始化时不创建首次读属性时才创建”。这两者在 JVM 层面的初始化时点是不同的。如果你很在意“是否被使用”by lazy的延迟粒度更精细。Holder 模式利用类初始化机制实现高质量懒加载object HeavyService { private object Holder { val instance HeavyService() } val instance: HeavyService get() Holder.instance }这里Holder是一个嵌套objectHeavyService类初始化不会触发Holder初始化只有第一次读取instance属性时Holder才会初始化进而创建真正的实例。这相当于把 JVM 类加载的“按需初始化”用到了极致。相比by lazyHolder 模式的实现更简洁也完全避开了 DCL 的细节。Android 中更常见的做法伴生对象 object在 Android 项目里Repository 层经常这样写class UserRepository(private val api: ApiService) { companion object { Volatile private var instance: UserRepository? null fun getInstance(api: ApiService): UserRepository { return instance ?: synchronized(this) { instance ?: UserRepository(api).also { instance it } } } } }因为 Android 依赖注入有时候需要传入外部参数纯粹的object不带构造参数这时候就需要手动管理带参单例。也可以用object保存一个延迟初始化的实例object AppContainer { lateinit var userRepository: UserRepository private set fun init(api: ApiService) { userRepository UserRepository(api) } }object在这里扮演的更多是“全局容器”的角色而不是单例工厂。这类写法的关键不是懒和饿而是生命周期管理——错过初始化时机就会抛UninitializedPropertyAccessException。所以无论选哪种变体都要先想清楚依赖方向和初始化时序。4.3 单例实现方式的横向对比把几种常见写法放在一张表里对比实现方式初始化时机线程安全可传构造参数典型使用场景object类初始化时即首次主动使用时JVM 保证不支持无参单例、常量容器、工具类companion objectby lazy属性首次访问时默认 SYNCHRONIZED不支持需要延迟创建的重型对象Holder 模式嵌套 object嵌套 object 首次访问时JVM 保证不支持延迟创建且追求最小开销手动 DCL 单例首次getInstance()volatile synchronized支持需要传入参数的带参单例“懒汉还是饿汉”只是这张表里“初始化时机”这一列的最粗略二分。真正的架构决策看的是依赖注入方式、构造参数生命周期、性能开销以及初始化顺序的可控性。把这几个维度想明白比纠结一个词有意义得多。5. 面试官如果懂行会顺着这个问题往下怎么追问5.1 从“懒汉还是饿汉”延伸出的五连问如果对面是真正理解 JVM 的面试官他们不会满足于一个标签。我更愿意看到候选人能接住以下几个追问第一问object编译后的类里INSTANCE字段是什么类型为什么不用volatileINSTANCE是static final。它之所以不需要volatile是因为类初始化过程由 JVM 的ClassLoader加锁保证可见性。只要clinit执行完毕JVM 会保证所有线程看到的INSTANCE都是完整构造后的对象。这种保证比 DCL 的volatile更底层、更可靠。第二问用什么方式访问object不会触发初始化这里的关键是编译期常量和反射。声明为const val的字段在编译期直接内联访问不会触发类初始化。另外通过Class.forName(className, false, classLoader)只加载不初始化也不会触发clinit自然也就不会创建实例。第三问object能不能实现真正的懒加载可以。by lazy就是“属性级”的懒加载在类已经初始化的前提下把实例延迟到属性首次访问时创建。严格的“懒汉单例”在 Kotlin 中通常指的是companion object by lazy这种写法。如果嫌默认的SynchronizedLazyImpl开销大可以用 Holder 模式替代由嵌套object承担延迟初始化的职责。第四问两个object在init块中互相引用会怎样这是比懒饿之争有含金量得多的问题。JVM 类初始化时会持有当前类的初始化锁如果初始化过程中遇到另一个类的初始化不会出现死锁因为 JVM 层面会做处理但如果在同一个线程里循环引用属性可能处于半初始化状态读取到未赋值的字段或者依赖未建立的数据。日常开发中object的初始化块应尽量保持简单不做跨对象依赖。第五问object单例在 Android 场景下有什么坑Android 的进程生命周期比较特殊。进程被系统回收后静态字段全部丢失下一次进程重建时object会重新执行clinit。如果你的object持有旧进程中的状态这些状态不会自动恢复。更典型的问题是内存泄漏object本质是静态持有如果它内部持有了Activity、View等生命周期比较短的对象就会造成泄漏。这也是不建议直接用object持有全局上下文的原因需要持有时就选WeakReference或依赖注入框架管理生命周期。5.2 这个问题的本质八股文只给结论不给机制回头看“懒汉还是饿汉”这个提问方式它的问题在于它强迫候选人把丰富复杂的加载机制压成一个标签。能正确回答“是饿汉”的候选人未必知道INSTANCE是静态字段能回答“是懒汉”的也未必能解释 JVM 的类初始化时序。真正的知识点是objectstatic final INSTANCE 私有构造器 clinit中完成实例化它的初始化时点由 JVM 的“首次主动使用”决定。这句话既解释了“懒”的疑惑也解释了“饿”的来源。如果你正在准备面试建议不要背结论而是亲手跑一遍javap再写几个测试验证初始化日志。理解了机制之后面试官再怎么换问法你都可以直接从原理推导出答案而不是从记忆里捞一个结论。我自己在实际项目中被问到过这个问题也在代码 review 里纠正过同事“object 是懒汉”的说法。后来我意识到与其纠正别人用词不如把问题升级把“懒汉还是饿汉”换成“你的单例初始化时机是什么这个时机对你系统的启动耗时和依赖顺序有什么影响”这才是工程上真正需要回答的问题。讨论object之前先确认你要解决的到底是“创建时机”问题还是“生命周期管理”问题这两件事常常被混在一起但解法完全不同。
返回列表