ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Kotlin延迟初始化:lateinit var与by lazy的全面对比与选型指南

Kotlin延迟初始化:lateinit var与by lazy的全面对比与选型指南 在Kotlin里待久了你大概率会撞上一对看起来很像、但性格完全相反的APIlateinit var和by lazy { }。它们都被归到“延迟初始化”这个筐里可一个是管可变字段的“先占坑、后填值”一个是管不可变缓存的“用到了才创建”。我刚学Kotlin那会儿也踩过串味儿的坑该用lateinit的地方拿lazy硬写该用lazy的地方用lateinit去扛多线程可见性结果就是一堆UninitializedPropertyAccessException和莫名其妙的时序问题。这篇文章就当一场华山论剑把这两位高手的出身、底牌、边界和实战场景全摊开讲透。我会结合JVM字节码层面和真实Android/服务端项目经验把lateinit与by lazy的差异拆到最小颗粒度最后再给出一套可以直接抄作业的选型判断方法。不管你是刚上手Kotlin的新人还是写过一段时间的老手这轮交锋应该都能让你少走几段弯路。1. 在讲它俩之前先弄明白“延迟初始化”到底在解决什么1.1 两个典型场景依赖注入型属性和昂贵计算平时写Kotlin类属性通常在一开始就确定值。但有两类情况让你不得不“晚点再说”。第一类是依赖注入型属性。典型的例子是Android里的ActivityonCreate里才知道Intent携带了什么参数或者才完成findViewById绑定再比如服务端框架里那些由Spring、Guice在构造之后才注入的组件。这类属性本质上是“外部给我的数据/依赖”在对象创建的那一刻根本拿不到必须等生命周期走到某个节点才能赋值。第二类是昂贵计算型属性。某个字段计算成本很高比如加载配置文件、解析大JSON、创建体积很大的工具对象但业务上又不是每次访问都一定需要它。如果类一加载就急着算反而浪费启动时间。更合理的做法是谁第一次碰它就让它现场算一次之后直接复用结果。这两种诉求看起来都叫“延迟初始化”但隐含的语义完全不同。第一种要求属性能被重新赋值因为依赖注入可能发生在任意时机甚至多次。第二种要求属性只算一次且保持不变因为昂贵计算不值得做两遍。这就引出了Kotlin给出的两套解决方案lateinit var服务于第一种by lazy { }服务于第二种。1.2 两派武功的本质var派与val派你可以这样记lateinit var是var派它的一切设计都围绕“可变性”展开by lazy是val派它的一切设计都围绕“不可变性与一次性”展开。lateinit var声明时不给值编译器允许但要求你在访问前必须完成赋值。它不缓存、不保证线程安全、也不管你是否重复赋值。by lazy { }声明一个委托属性第一次访问时执行初始化块之后将结果缓存起来后续访问直接拿缓存值。默认线程安全且val语义天然不可变。理解了这一层很多疑惑就能解开。比如为什么lateinit不能用Int为什么lazy不能对付需要多次赋值的属性为什么lateinit访问时抛的是UninitializedPropertyAccessException而不是NullPointerException。下面两章分别拆解门派的内部心法。2. lateinit流派先占坑用的时候保证有值2.1 lateinit的限制清单类型、位置、时机lateinit用起来极其简单但它的限制也是出了名的多。我先列一张限制清单每一条后面都标注了背后的原因限制具体规则原因类型限制不能是Int、Long、Float、Double、Boolean等基础类型lateinit底层靠null标记“未赋值”基础类型没有null形态空性限制类型必须非空不能是String?lateinit的意义就是“不需要可空判断”可空类型直接用 null就好位置限制Kotlin 1.2之前只允许在类体内1.2之后顶层属性和局部变量也可用早期编译器能力受限后来放开委托限制不能和by委托一起用lateinit本身就是编译器直接支持的属性语法不走委托getValue/setValue时机限制访问前必须赋值否则抛UninitializedPropertyAccessException这正是它的核心契约我见过有人试图用lateinit var button: Button?编译直接报错。正确的姿势是class MainActivity : AppCompatActivity() { private lateinit var userName: String override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) userName intent.getStringExtra(USER_NAME).orEmpty() } fun displayName() { // 访问时编译器不报错但如果onCreate没跑到这里就会抛异常 println(userName) } }这种写法在Android里太常见了绑定视图的成员、注入的Presenter、从Intent解析出来的参数处处都是lateinit的身影。但要记住它的“延迟”只解决“编译器允许后赋值”这个问题并不负责“防止你过早访问”。运行时什么检查都不做就靠你凭自觉保证访问时机。2.2 反编译字节码看看lateinit真正做了什么很多人只知道lateinit的用法不知道编译器在背后做了什么。我以前把一段Kotlin代码反编译成Java看过整个过程清晰得让人安心。Kotlin源码class Demo { lateinit var name: String fun printName() { if (::name.isInitialized) { println(name) } } }反编译后的Java逻辑简写public final class Demo { NotNull public String name; NotNull public final String getName() { String var1 this.name; if (var1 null) { throw new UninitializedPropertyAccessException(lateinit property name has not been initialized); } return var1; } public final void setName(NotNull String value) { if (value null) { throw new IllegalArgumentException(lateinit property name has not been initialized); } this.name value; } public final void printName() { if (this.name ! null) { // 对应 isInitialized System.out.println(this.name); } } }看到了吗所谓“未初始化检查”本质就是判空。字段还是普通字段只不过getter里多了一个空指针检查不为空就返回为空就抛UninitializedPropertyAccessException。这也是为什么它无法支持基础类型——Int类型的默认值是0不是null编译器压根没法区分“没赋值”和“赋值为0”。如果硬要支持就得引入额外标志位开销不划算Kotlin干脆禁止。这个反编译结果还解释了另一个很多人忽略的细节lateinit的初始化和访问都不带任何同步。你在这个线程赋完值另一个线程立刻读读到的可能是旧值甚至因为指令重排直接读到一个半初始化状态。所以多线程场景里lateinit只能用在“你确定只有单线程访问”或者“外部已有锁保护”的情况下。2.3 isInitialized唯一的runtime防御手段lateinit不让你做空判断但给了你一个特殊的反射式API::name.isInitialized。这个表达式返回当前属性是否已经初始化可用来做防御。fun printName() { if (::name.isInitialized) { println(name) } else { println(name还没准备好) } }注意这个API的语法细节::name是属性引用而它只有在lateinit场景下才有isInitialized成员可用。普通属性、by lazy属性都不支持这个操作。它有两点局限你得知道。其一isInitialized本身也只能访问当前类内部的lateinit属性。如果你想从另一个类判断某个对象的lateinit属性是否初始化没戏编译器根本不给你编译过。其二它解决不了竞态问题。两个线程同时调用isInitialized一个判断完还没赋值另一个已经读了照样崩。所以它只适合单线程下的时序防御不适合当锁用。3. by lazy流派值只在第一次访问时诞生3.1 lazy委托的存储机制和编译产物by lazy写起来像个语法糖但它在底层其实是一个标准委托属性。Kotlin会把class LazyDemo { val config: Config by lazy { loadConfig() // 假设这个函数很重 } }编译成类似这样的结构class LazyDemo { private val config$delegate lazy { loadConfig() } val config: Config get() config$delegate.getValue(this, ::config) }lazy()函数返回一个LazyT对象属性里的getter会调用这个对象的getValue方法。也就是说真正的值是存放在LazyT对象内部的每次访问属性时先查一下缓存有没有值有就返回没有就执行初始化块并缓存。这带来一个很重要的推论by lazy初始化出的值被Lazy实例持有而不是直接持有在属性本体上。所以Lazy对象本身的生命周期直接决定了值的生命周期。如果这个Lazy被某个对象长期持有即使原值不再被业务需要它也一直躺在内存里。默认情况下Lazy实例是随属性所在的对象一起创建、一起销毁的。你在一个Activity里写val userRepo by lazy { UserRepo() }userRepo就随着Activity创建而创建委托对象随着Activity销毁才释放。3.2 LazyThreadSafetyMode三种模式的工程选择lazy()默认是线程安全的但Kotlin还允许显式指定模式。三种模式含义不同选错了不是慢一点的问题而是可能直接出bug的问题。模式线程安全实现思路适用场景LazyThreadSafetyMode.SYNCHRONIZED安全加锁先查缓存再执行初始化默认模式多数场景都合适LazyThreadSafetyMode.PUBLICATION安全允许重复计算但只发布一个结果不加锁多个线程可以同时执行初始化块谁先完成谁的值对外发布初始化块本身无副作用、纯计算时可用LazyThreadSafetyMode.NONE不安全完全不保护多线程访问会出问题单线程环境或者你能保证初始化块只在启动时被单线程访问时用工程上我最常遇到的是NONE被误用。有些人在写Android的Activity时为了省一点锁开销给lazy加上了NONE然后在一个后台线程里访问直接导致初始化块被调用两次或者读到半初始化的状态。说实话99%的场景下SYNCHRONIZED带来的性能损耗根本不值一提锁在“首次初始化”时才真正竞争之后访问走的是无锁快路径。所以我的建议是没有性能和并发层面的强需求别去动模式。3.3 初始化块抛异常后的重试行为一个很容易被忽视的细节是lazy的初始化块抛了异常会怎样答案是不会永久缓存这个异常下次访问时会重新执行初始化块。这跟很多人猜的“一锤子买卖”不一样。因为Lazy只把“成功计算出值”当作已初始化状态异常并不会填充缓存。所以你在by lazy里加载一个远端配置第一次网络异常抛了个IOException第二次访问时它还会再试一次。这个行为在某些场景下很友好比如重试逻辑;但在另一些场景下很坑比如初始化块里有打点计数、文件创建等副作用异常之后重试就会重复产生副作用。我之前维护过一个老项目里面有个lazy块会执行一次数据库建表操作刚好网络偶发失败结果一次访问就重复建了好几次表日志里全是重复ID。如果你需要“异常后不再重试”的语义得自己在初始化块里捕获异常并包一层缓存private var loadConfig: Config? null val config: Config get() loadConfig ?: run { try { ConfigLoader.load().also { loadConfig it } } catch (e: Exception) { // 这里决定是否缓存失败结果 throw e } }3.4 lazy的序列化问题Kotlin的lazy默认实现是SynchronizedLazyImpl这个类实现了Serializable但它能否在反序列化后正常工作取决于你使用的序列化框架。最典型的坑是Gson和某些Kryo配置下Lazy对象的内部状态可能没有被正确恢复。比如你把一个by lazy属性通过Gson序列化再反序列化得到的新对象里Lazy可能变成null或者_value还是未初始化状态导致后续访问行为跟预期不符。你以为是原对象其实是个“翻新的旧货”。如果你有跨进程传输包含lazy属性的对象我的建议有两个要么在DTO层面把lazy属性展开为普通属性要么给Lazy字段加transient并在反序列化后手动重建。最省心的做法是不要序列化带lazy的领域对象序列化这件事本身就不该让Kotlin的委托机制牵扯进来。4. 华山论剑两者正面硬刚的对比维度4.1 可变性与赋值次数var和val的本质分歧这是两派最根本的分歧也是选型的第一依据。lateinit var允许你反复赋值。第一次赋值是初始化后续赋值是更新。这在你处理“外部随时可能注入新值”的场景时是刚需。比如一个UserSession单例用户登录后赋值登出后置空刷新下次登录再赋值。by lazy是val的结构化缓存初始化块执行一次后就锁死。想换新值没门除非你自己在初始化块里解析一个可变状态但那等于绕道。例如你需要一个当前用户信息对象登录后如果切换了用户这个对象应该跟着变——用by lazy就是自找麻烦。我给自己的判断规则很简单问自己“这个属性在一生中可能被重新赋值吗”。可能用lateinit绝无可能用lazy。4.2 线程安全和可见性lateinit var本身没有任何线程保护。它靠的是“你保证单线程访问”或者外部有显式加锁。如果多个线程都可能在onCreate之后访问一个lateinit属性而赋值发生在onCreate里因为onCreate通常在主线程执行所以实际风险不算大。但一旦你把这个模式搬到多线程环境比如用一个后台线程池去初始化一个lateinit字段另一个线程同时读就可能读到未初始化状态甚至看到一半被构造的对象。by lazy默认SYNCHRONIZED模式则提供了完整的线程可见性保障。初始化的值和后续访问的读取都通过双检锁保证多个线程第一次访问时也只会有一个线程执行初始化块其他线程阻塞等待结果。所以在并发场景下lazy是默认更稳妥的选择但前提是你接受“值只算一次”的约束。4.3 内存布局和性能损耗从性能角度讲lateinit var的开销几乎可以忽略。它就是在getter里多了一个“判空可能抛异常”的分支。对现代CPU来说这种判断的代价几乎为零。by lazy第一次访问时会进入双检锁路径有缓存判断和同步块但初始化完成之后后续访问也只是一次判断加一次直接返回和lateinit的判空成本相当。真正有差距的是在首次并发到达时的锁竞争以及Lazy对象本身占用的堆内存。内存布局上lateinit var name: String在对象里就是一个字段val name by lazy { ... }则多出一个Lazy实例字段。这个Lazy对象内部还持有initializer函数引用和_value缓存直到初始化完成后initializer会被置空但Lazy实例本身仍然存在。如果类里有很多lazy属性每个属性的额外内存开销大概几十到上百字节。几个还好几百个就有感知了。我见过一个配置类里堆了50多个lazy属性一查内存光委托对象就占了几KB。4.4 典型使用场景速查表场景推荐方案理由Android View/资源绑定在onCreate里赋值lateinit var生命周期节点的强制性且支持重新绑定依赖注入框架在构造后塞入组件lateinit var注入时机完全由外部控制可能需要重复注入只需计算一次的昂贵资源网络配置、正则表达式、大对象by lazy保证单次计算默认线程安全单线程内、初始化逻辑无副作用、需要提升极限性能by lazy(NONE)去掉无意义的锁开销属性需要频繁重置或重新赋值lateinit varvar语义天然支持蜜罐统计类属性、允许失败重试by lazy异常后重新初始化的行为可用作轻量重试5. 实战中的坑既踩过lateinit异常也踩过lazy泄漏5.1 Android生命周期里用lazy一定要当心Android的开发和普通JVM开发有一个显著差异对象的生命周期不一定等于你持有它的生命周期。Activity在配置变更时会销毁重建但有些对象可能还捏在别的单例手里。一个经典反模式是这样的class SomeManager { val activityContext: Context by lazy { currentActivityRef.get() ?: throw IllegalStateException(Activity未被引用) } fun doSomething() { activityContext.getString(R.string.app_name) } }如果SomeManager是单例而currentActivityRef又被更新成了新Activity那旧的Activity可能已经被销毁但还被强引用着内存泄漏就是这么来的。用by lazy封装Context、View、Fragment之类的生命周期绑定对象时一定要确认委托的持有方和被封装对象的生命周期一致。否则宁可不用lazy改用显式传入或弱引用。还有个不太明显但经常踩的坑lazy初始化块里访问了另一个lazy属性两个lazy互相依赖就会导致栈溢出。我遇过一个同事写的循环依赖val a: Int by lazy { b 1 } val b: Int by lazy { a 1 }访问a的时候初始化块里要读bb的初始化块又要读aa还没初始化完于是无限递归直接StackOverflowError。这种错误在代码层面看不出什么必须靠运行时崩溃才能暴露。5.2 测试代码里重置lazy的三种思路单元测试里by lazy的“一次性”特性很让人头疼。你写了几个测试用例每个用例想验证不同的初始化行为但lazy根本不给你重置的机会。我在项目里用过三种思路解决这个问题。第一种反射重置。思路是把lazy委托对象里的_value字段重置为未初始化值再清空initializer。这种方式能跑通但很脆只要Kotlin改内部实现就废了不推荐。第二种用可重置的自定义委托。这个思路最干净后面第6章我会给完整代码。第三种测试类里避免直接使用lazy属性。你可以把初始化逻辑抽成一个普通函数测试时直接手工调用函数获得新实例lazy只负责生产环境下的缓存。class Service { fun buildClient(): HttpClient HttpClient() val client: HttpClient by lazy { buildClient() } } // 测试代码 val service Service() val client service.buildClient() // 绕过lazy缓存这种抽法既保住了lazy的好处又让你在测试时完全绕过缓存。5.3 依赖注入框架与两者的配合问题服务端开发常碰Spring Boot。Spring的构造器注入天然适合val属性用by lazy反而显得多余。但字段注入比如Autowired在Kotlin里会要求属性可空或者lateinit否则直接编译失败。// 常见做法 Autowired private lateinit var userService: UserService // 这种写法是错的Autowired注入时机在构造之后val不满足 // Autowired // private val userService: UserService问题在于Spring是通过反射在构造之后给属性赋值的如果你把属性声明为val by lazySpring根本不知道该从哪下手赋值也没意义。所以和Spring字段注入配合lateinit var几乎是唯一解。但如果你用Kotlin官方推荐的构造器注入那就压根不需要这两个方案普通val属性就行。这个对比再次说明选型不能脱离框架和生命周期来空谈。6. 整活玩法自定义可重置Lazy和混合初始化6.1 手写一个ResetableLazyDelegate作为对照组你在项目里可能遇到真正需要“重置的lazy”比如单元测试。我给你一个基于委托API的成熟方案能保存lazy的所有优势并额外支持手动重置。class ResetableLazyT(private val initializer: () - T) : ReadOnlyPropertyAny?, T { Volatile JvmField var initialized: Boolean false private var cached: T? null override fun getValue(thisRef: Any?, property: KProperty*): T { if (!initialized) { synchronized(this) { if (!initialized) { cached initializer() initialized true } } } Suppress(UNCHECKED_CAST) return cached as T } fun reset() { synchronized(this) { initialized false cached null } } } fun T resetableLazy(initializer: () - T) ResetableLazy(initializer) // 使用 class Demo { val config: Config by resetableLazy { loadConfig() } } fun testDemo() { val demo Demo() val first demo.config // 怎么在测试里重置 val delegate extractDelegate(demo) // 这里需要一点反射或状态注入 delegate.reset() }这个方案的优势是语义和by lazy一致还多一个reset()方法。缺点是你得想办法拿到委托实例才能重置。如果你的测试类本身能持有委托那就很从容。class Demo { val configDelegate resetableLazy { loadConfig() } val config: Config by configDelegate fun resetConfig() configDelegate.reset() }这种做法把委托对象作为类的成员暴露出来测试里就能直接demo.resetConfig()。6.2 “lateinit lazy”的最佳组合姿势你可能会问能不能同时享受lateinit的“外部赋值”和lazy的“自动缓存”在Kotlin里它们不能直接组合但可以用模式组合来达到目的。以Android的ViewModel为例class MainViewModel : ViewModel() { private var userProfile: UserProfile? null fun loadUser() { // 模拟异步加载 viewModelScope.launch { userProfile repository.fetchUser() } } val displayName: String get() userProfile?.name ?: 未登录 }这里我用了一个可空的var兜底再用一个普通getter封装“有值就展示、无值给默认”。它既不要求lateinit那样的严格赋值时机也不要求lazy那样的一次性缓存而是保留了两者之间的弹性。更常见的混合用法是用lateinit承接外部注入的依赖再用lazy为依赖构建基于它的派生对象。class ReportBuilder { private lateinit var dataSource: DataSource fun bind(source: DataSource) { this.dataSource source } private val reportTemplate: ReportTemplate by lazy { ReportTemplate(dataSource.computeTemplate()) } fun build(): Report { return reportTemplate.createReport() } }lateinit负责可变的外部依赖lazy负责依赖到位后只计算一次的派生资源。这正好把两者的优点拼在一起。6.3 进阶思路object单例、顶层变量和构建器除了lateinit和lazyKotlin里还有几种“延迟初始化”的替代姿势我简单说下方便你在更复杂的工程场景里做判断。object单例object Config { val timeout: Long 3000 }JVM上的object本质是饿汉式单例类加载即初始化不算延迟。但如果懒加载只针对单例的属性用by lazy可以做到。顶层变量val globalConfig by lazy { loadGlobalConfig() }顶层属性的lazy是全局共享的注意不要被classloader卸载不了拖累内存。构建器模式如果你的对象初始化参数很多、且依赖外部上下文用构建器模式拿到足够参数后再构造一个完整对象。这比lateinit更显式、更类型安全。拿构建器模式对比lateinit你会发现一个有意思的规律lateinit把“先创建后填值”这个程序员习惯搬到了语言层面但它把“漏填值”的风险留到了运行时构建器则是把风险留到编译期——参数没给齐就编译不过。所以能用构建器的地方别懒别依赖lateinit的便利性。结尾想再说的几句体己话写了这么多最后分享一下我的选型心法也算这轮的总结。我判断一个属性到底用lateinit还是lazy总是先问三个问题第一这个属性在对象生命周期内需要被重新赋值吗需要选lateinit不需要选lazy。第二初始化过程有昂贵成本且希望只执行一次吗是选lazy不是用lateinit或普通属性都行。第三调用方可能从多线程同时访问吗如果存在这种可能lazy的默认线程安全是很大的加分项而lateinit你得自己保证并发安全。如果看到这里你还有点犹豫我通常建议手头项目里没写错过几次就吃不准。真的我当年第一次用lateinit在Fragment里踩到UninitializedPropertyAccessException的时候也和现在的你一样一头雾水。后来把这两个机制的字节码翻出来看了一遍很多疑问就自动消失了。希望这篇够长的“华山论剑”能帮你省掉我当初那段弯路。
返回列表